feat: 新增风险图谱算法与系统仪表盘及操作反馈体系

后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-30 15:46:51 +08:00
parent 4c59941ec6
commit 7989f3a159
314 changed files with 30073 additions and 20626 deletions

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { ref } from 'vue'
import {
buildApplicationPreviewFooterMessage,
@@ -21,6 +22,7 @@ import {
createMessage as createConversationMessage,
hasMeaningfulSessionMessages
} from '../src/views/scripts/travelReimbursementConversationModel.js'
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
@@ -42,6 +44,10 @@ const messageItemStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-item.css', import.meta.url)),
'utf8'
)
const applicationMessageStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-application.css', import.meta.url)),
'utf8'
)
const conversationModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
'utf8'
@@ -50,6 +56,38 @@ const previewEditorScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)),
'utf8'
)
const flowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
function createFlowHarness() {
return useTravelReimbursementFlow({
activeSessionType: ref('application'),
reviewDrawerMode: ref(''),
insightPanelCollapsed: ref(true),
isKnowledgeSession: ref(false),
fetchAgentRunDetail: async () => null,
buildLocalIntentPreview: () => '本地意图预览',
buildLocalExtractionProgressMessages: () => ['正在抽取信息'],
summarizeSemanticIntentDetail: () => '模型已完成意图识别',
summarizeSemanticParseDetail: () => '模型已完成信息抽取',
SCENARIO_LABELS: {},
INTENT_LABELS: {},
EXPENSE_TYPE_LABELS: {},
FLOW_STEP_FALLBACKS: {
intent: { title: '意图识别', tool: 'SemanticRouter', runningText: '正在识别业务意图...', completedText: '已识别业务意图' },
extraction: { title: '信息抽取', tool: 'SemanticExtractor', runningText: '正在抽取关键信息...', completedText: '已抽取关键信息' },
'application-submit-success': { title: '申请单提交成功', tool: 'ApplicationSubmit', runningText: '正在提交申请单...', completedText: '申请单提交成功' }
},
REVIEW_DRAWER_MODE_FLOW: 'flow',
REVIEW_DRAWER_MODE_REVIEW: 'review',
FLOW_STEP_STATUS_PENDING: 'pending',
FLOW_STEP_STATUS_RUNNING: 'running',
FLOW_STEP_STATUS_COMPLETED: 'completed',
FLOW_STEP_STATUS_FAILED: 'failed'
})
}
test('application intent uses local preview instead of immediate orchestrator call', () => {
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目出差3天高铁预计金额2358元'
@@ -119,8 +157,11 @@ test('application preview cleans empty time labels and keeps only business reaso
const preview = buildLocalApplicationPreview('发生时间去九江出差3天服务美团业务部署预计费用1800元火车', {
name: '李文静',
grade: 'P5'
}, {
today: '2026-05-29'
})
assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31')
assert.equal(preview.fields.location, '九江')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.fields.reason, '服务美团业务部署')
@@ -130,7 +171,7 @@ test('application preview cleans empty time labels and keeps only business reaso
test('application preview can be refined by ontology model extraction', () => {
const rawText = '发生时间去九江出差3天服务美团业务部署预计费用1800元火车'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' }, { today: '2026-05-29' })
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
@@ -153,11 +194,23 @@ test('application preview can be refined by ontology model extraction', () => {
assert.equal(refinedPreview.parseStrategy, 'llm_primary')
assert.equal(refinedPreview.modelReviewStatus, 'completed')
assert.equal(refinedPreview.fields.applicationType, '差旅费用申请')
assert.equal(refinedPreview.fields.time, '')
assert.equal(refinedPreview.fields.time, '2026-05-29 至 2026-05-31')
assert.equal(refinedPreview.fields.reason, '服务美团业务部署')
assert.equal(refinedPreview.fields.transportMode, '火车')
})
test('application preview precomputes a date range from today when only days are provided', () => {
const preview = buildLocalApplicationPreview(
'去北京出差3天支撑国网仿生产环境部署飞机预计费用12000元',
{ name: '李文静', grade: 'P5' },
{ today: '2026-05-29' }
)
assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.readyToSubmit, true)
})
test('application preview keeps rule fallback distinct from model reviewed result', () => {
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署出差3天火车预计费用1800元'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
@@ -279,8 +332,22 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /补齐后我再帮您提交申请。/)
assert.match(messageItemTemplate, /class="application-preview-footer message-answer-content message-answer-markdown"/)
assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/)
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
assert.match(messageItemTemplate, /application-preview-date-chip/)
assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /application-draft-head/)
assert.match(messageItemTemplate, /mdi mdi-file-document-check-outline/)
assert.match(messageItemTemplate, /'is-primary': item\.label === '单号'/)
assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/)
assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/)
assert.match(messageItemTemplate, /ui\.openApplicationDraftDetail\(message\)/)
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/)
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/)
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
assert.match(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.draftValue"/)
assert.doesNotMatch(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.singleDate"/)
assert.match(messageItemTemplate, /application-preview-select/)
assert.match(messageItemTemplate, /resolveApplicationPreviewEditorOptions/)
assert.match(messageItemTemplate, /row\.editable && !ui\.isApplicationPreviewEditing\(message, row\.key\).*ui\.openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
@@ -291,13 +358,26 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /openApplicationPreviewEditor/)
assert.match(messageItemTemplate, /commitApplicationPreviewEditor/)
assert.match(createViewScript, /resolveApplicationPreviewMissingFields/)
assert.match(createViewScript, /function applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
assert.doesNotMatch(createViewTemplate, /@click="applyComposerDateSelection"/)
assert.match(previewEditorScript, /normalizeApplicationPreview/)
assert.match(previewEditorScript, /APPLICATION_TRANSPORT_MODE_OPTIONS/)
assert.match(previewEditorScript, /getTodayDateValue/)
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
assert.match(previewEditorScript, /targetRow\.editable === false/)
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
assert.match(previewEditorScript, /fieldKey === 'time'\) return 'date'/)
assert.match(previewEditorScript, /commitApplicationPreviewDateEditor/)
assert.match(messageItemStyles, /@import "\.\/travel-reimbursement-message-application\.css";/)
assert.match(messageItemStyles, /\.application-preview-row\.missing \{[\s\S]*--theme-primary-rgb/)
assert.match(messageItemStyles, /\.application-preview-table \{[\s\S]*border: 1px solid #d7e4f2;[\s\S]*background: #ffffff;/)
assert.match(messageItemStyles, /\.application-preview-row \{[\s\S]*grid-template-columns: 108px minmax\(0, 1fr\);/)
@@ -307,6 +387,65 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-action-link\) \{[\s\S]*text-decoration: underline;/)
assert.match(messageItemStyles, /\.application-preview-footer-missing \{[\s\S]*margin-top: 48px;[\s\S]*background: transparent;/)
assert.match(messageItemStyles, /\.application-preview-missing-chip \{[\s\S]*background: rgba\(var\(--theme-primary-rgb/)
assert.doesNotMatch(applicationMessageStyles, /\.application-date-editor-layer/)
assert.match(applicationMessageStyles, /\.application-draft-preview \.application-draft-head \{[\s\S]*grid-template-columns: 36px minmax\(0, 1fr\) auto;/)
assert.match(applicationMessageStyles, /\.application-draft-brief \{[\s\S]*border: 1px solid #d7e4f2;/)
assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/)
assert.match(flowScript, /application-submit-success/)
assert.match(flowScript, /function shouldHideToolCall/)
assert.match(flowScript, /semantic_ontology/)
assert.match(flowScript, /return null/)
assert.match(flowScript, /申请单提交成功/)
assert.match(flowScript, /function resolveDurationFromFields/)
assert.match(flowScript, /function resolveStartedTimestamp/)
assert.match(flowScript, /function resolveFinishedTimestamp/)
assert.match(flowScript, /syntheticTiming/)
assert.match(flowScript, /refreshCompleted/)
})
test('flow panel durations use backend timing instead of local preview delay', () => {
const flow = createFlowHarness()
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
flow.startFlowStep('intent', '正在识别业务意图...')
flow.completeFlowStep('intent', '本地预览完成', 80)
flow.startFlowStep('extraction', '正在抽取关键信息...')
flow.completeFlowStep('extraction', '本地抽取完成', 90)
flow.startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交申请单...'
})
flow.completeFlowStep('application-submit-success', '本地提交完成', 100)
flow.mergeFlowRunDetail({
started_at: '2026-05-29T00:00:00.000Z',
finished_at: '2026-05-29T00:00:05.000Z',
status: 'succeeded',
semantic_parse: {},
ontology_json: {},
tool_calls: [
{
id: 'submit-1',
run_id: 'run-1',
tool_type: 'application',
tool_name: 'application.submit',
request_json: {},
response_json: { status: 'submitted', draft_payload: { status: 'submitted' } },
status: 'succeeded',
duration_ms: 2360,
created_at: '2026-05-29T00:00:04.000Z'
}
]
})
const durationByKey = Object.fromEntries(flow.flowSteps.value.map((step) => [step.key, step.durationMs]))
assert.equal(durationByKey.intent, 1400)
assert.equal(durationByKey.extraction, 2600)
assert.equal(durationByKey['application-submit-success'], 2360)
assert.equal(flow.flowTotalDurationText.value, '5.0s')
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: 0 }), '--')
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: null }), '--')
})
test('assistant markdown tables render with component-scoped table styling', () => {