feat: 增强知识库索引与设置页面模块化拆分

扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优
化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件
和 Hermes 员工同步子面板并重构样式,新增日志详情组件和
知识入库日志模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 23:47:28 +08:00
parent 88ff04bef8
commit 5b388d08c0
84 changed files with 10170 additions and 2599 deletions

View File

@@ -7,7 +7,10 @@ import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage,
buildOcrFilePreviews,
buildReviewFilePreviewsFromReviewPayload
buildReviewFilePreviewsFromReviewPayload,
buildUnsavedDraftAttachmentConfirmationMessage,
filterPersistableFilePreviews,
mergeFilePreviews
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
import {
buildDraftAssociationQueryPayload,
@@ -99,6 +102,14 @@ test('attachment upload association uses conversation selection instead of legac
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const flowSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
const conversationSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
'utf8'
)
assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/)
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
@@ -112,6 +123,26 @@ test('attachment upload association uses conversation selection instead of legac
submitComposerSource,
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
)
assert.match(submitComposerSource, /mode:\s*'save_then_associate'/)
assert.match(submitComposerSource, /review_action:\s*'save_draft'[\s\S]*review_action:\s*'link_to_existing_draft'/)
assert.match(submitComposerSource, /appendToCurrentFlow:\s*true/)
assert.match(submitComposerSource, /const appendToCurrentFlow = Boolean\(options\.appendToCurrentFlow\)/)
assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/)
assert.match(flowSource, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/)
assert.match(flowSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)
})
test('unsaved review attachment prompt asks for explicit rich-text confirmation', () => {
const message = buildUnsavedDraftAttachmentConfirmationMessage({
fileNames: ['taxi.pdf']
})
const rendered = renderMarkdown(message)
assert.match(message, /当前这笔报销信息还没有保存为草稿/)
assert.match(message, /本次待归集附件1 份/)
assert.match(message, new RegExp(`\\*\\*\\[确定\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)\\*\\*`))
assert.match(rendered, /<strong><a href="#confirm-attachment-association" class="markdown-action-link markdown-action-link-confirm">确定<\/a><\/strong>/)
})
test('OCR preview builders keep hotel receipt image previews when preview kind is omitted', () => {
@@ -137,6 +168,26 @@ test('OCR preview builders keep hotel receipt image previews when preview kind i
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
})
test('file preview cache replaces temporary object urls and never persists them', () => {
const merged = mergeFilePreviews(
[
{ filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/old-preview' },
{ filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' }
],
[
{ filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/new-preview' },
{ filename: 'hotel.png', kind: 'image' }
]
)
assert.equal(merged.length, 2)
assert.equal(merged[0].url, 'blob:http://localhost/new-preview')
assert.equal(merged[1].url, 'data:image/png;base64,stable')
assert.deepEqual(filterPersistableFilePreviews(merged), [
{ filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' }
])
})
test('draft association query keeps a single candidate selectable in the conversation', () => {
const payload = buildDraftAssociationQueryPayload([
{
@@ -182,6 +233,30 @@ test('expense query payload keeps structured risk items for claim-level risk dri
assert.equal(payload.records[0].riskItems[0].summary, '住宿金额超过城市标准')
})
test('expense query info items render as prompts instead of low risk', () => {
const payload = normalizeExpenseQueryPayload({
result_type: 'expense_claim_list',
records: [
{
claim_id: 'claim-info',
claim_no: 'EXP-202605-010',
amount: 59.1,
risk_flags: [
{
key: 'normal-tip',
level: 'info',
title: '票据提示',
summary: '票据已识别,当前没有异常。'
}
]
}
]
})
assert.equal(payload.records[0].riskItems[0].levelLabel, '提示')
assert.notEqual(payload.records[0].riskItems[0].levelLabel, '低风险')
})
test('expense query hint guides users to the reimbursement center after the top five results', () => {
const payload = normalizeExpenseQueryPayload({
result_type: 'expense_claim_list',

View File

@@ -0,0 +1,117 @@
import assert from 'node:assert/strict'
import {
buildKnowledgeIngestLogModel,
isKnowledgeIngestRun
} from '../src/utils/knowledgeIngestLogModel.js'
function buildRun() {
return {
status: 'running',
route_json: {
job_type: 'knowledge_index_sync',
folder: '制度文件',
phase: 'indexing',
progress: {
total_documents: 2,
completed_documents: 1,
failed_documents: 0,
percent: 55
},
knowledge_ingest: {
status: 'running',
phase: 'indexing',
current_document_id: 'doc-2',
graph: {
chunk_count: 5,
entity_count: 3,
relation_count: 2,
entities: ['远光软件', '支出管理'],
relations: [{ source: '远光软件', target: '支出管理', type: '关联' }]
},
documents: [
{
document_id: 'doc-1',
name: '公司支出管理办法.pdf',
folder: '制度文件',
extension: 'pdf',
status: 'succeeded',
phase: 'indexed',
chunk_count: 3,
entity_count: 2,
relation_count: 1,
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围' }],
sections: [{ title: '第一章 总则', excerpt: '适用于公司支出。' }],
events: [{ at: '2026-05-22T08:00:00Z', level: 'info', message: '完成' }]
},
{
document_id: 'doc-2',
name: '费用审批台账.xlsx',
folder: '制度文件',
extension: 'xlsx',
status: 'running',
phase: 'indexing',
chunk_count: 2,
entity_count: 1,
relation_count: 1
}
]
}
}
}
}
function testDetectsKnowledgeIngestRun() {
assert.equal(isKnowledgeIngestRun(buildRun()), true)
assert.equal(isKnowledgeIngestRun({ route_json: { job_type: 'daily_check' } }), false)
}
function testBuildsInteractiveModel() {
const model = buildKnowledgeIngestLogModel(buildRun())
assert.equal(model.available, true)
assert.equal(model.folder, '制度文件')
assert.equal(model.selectedDocumentId, 'doc-2')
assert.equal(model.documents.length, 2)
assert.equal(model.documents[0].statusLabel, '已完成')
assert.equal(model.documents[0].chunks[0].summary, '支出管理范围')
assert.equal(model.graph.entityCount, 3)
assert.equal(model.graph.relations[0].source, '远光软件')
assert.equal(model.metrics[1].value, '5')
}
function testFallsBackToToolCallDocuments() {
const model = buildKnowledgeIngestLogModel({
status: 'succeeded',
route_json: {
job_type: 'knowledge_index_sync',
requested_document_ids: ['doc-1']
},
tool_calls: [
{
response_json: {
documents: [
{
document_id: 'doc-1',
name: '归集结果.docx',
status: 'succeeded',
chunk_count: 1
}
]
}
}
]
})
assert.equal(model.documents[0].name, '归集结果.docx')
assert.equal(model.graph.chunkCount, 1)
}
function run() {
testDetectsKnowledgeIngestRun()
testBuildsInteractiveModel()
testFallsBackToToolCallDocuments()
console.log('knowledge ingest log model tests passed')
}
run()

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import {
resolveAppViewFromRoute,
resolveTargetRouteName
} from '../src/composables/useNavigation.js'
function testDerivesViewFromRouteName() {
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'logs')
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'requests')
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'logs' } }), 'policies')
}
function testFallsBackToValidMeta() {
assert.equal(resolveAppViewFromRoute({ name: 'custom', meta: { appView: 'employees' } }), 'employees')
assert.equal(resolveAppViewFromRoute({ name: 'custom', meta: { appView: 'unknown' } }), 'overview')
}
function testResolvesMainRouteNames() {
assert.equal(resolveTargetRouteName('logs'), 'app-logs')
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
assert.equal(resolveTargetRouteName('missing'), 'app-overview')
}
function run() {
testDerivesViewFromRouteName()
testFallsBackToValidMeta()
testResolvesMainRouteNames()
console.log('navigation route resolution tests passed')
}
run()

View File

@@ -24,6 +24,19 @@ test('local flow intent preview names transport expense for riding fare text', (
(item) => item.includes('交通出行') && item.includes('交通费')
)
)
assert.ok(
buildLocalExtractionProgressMessages(ridingFareMessage).some(
(item) => item.includes('正在判断待补项') && !item.includes('客户名称') && !item.includes('参与人员')
)
)
})
test('local flow recognizes broader reimbursement scene keywords', () => {
assert.equal(inferLocalFlowCandidates('报销会议场地费').expenseType, '会务费')
assert.equal(inferLocalFlowCandidates('报销打印纸和硒鼓').expenseType, '办公用品费')
assert.equal(inferLocalFlowCandidates('报销培训课程费').expenseType, '培训费')
assert.equal(inferLocalFlowCandidates('报销手机话费和流量费').expenseType, '通讯费')
assert.equal(inferLocalFlowCandidates('报销员工体检费').expenseType, '福利费')
})
test('semantic intent detail includes recognized expense type', () => {

View File

@@ -3,7 +3,14 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { buildReviewPlainFollowupCopy } from '../src/views/scripts/travelReimbursementReviewModel.js'
import {
buildLocallySyncedReviewPayload,
buildReviewNextStepRichCopy,
buildReviewPlainFollowupCopy,
isTravelReviewPayload,
resolveReviewFooterActions
} from '../src/views/scripts/travelReimbursementReviewModel.js'
import { renderMarkdown } from '../src/utils/markdown.js'
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
@@ -33,6 +40,26 @@ const attachmentsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)),
'utf8'
)
const sessionStateScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
'utf8'
)
const createViewBaseStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view.css', import.meta.url)),
'utf8'
)
const createViewPart2Styles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part2.css', import.meta.url)),
'utf8'
)
const createViewPart3Styles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part3.css', import.meta.url)),
'utf8'
)
const createViewPart4Styles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part4.css', import.meta.url)),
'utf8'
)
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewOverviewDrawerAvailable"[\s\S]*title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
@@ -58,6 +85,157 @@ test('review drawer tool buttons switch modes instead of toggling the active mod
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
})
test('document review drawer fills sidebar height and preview dialog is centered', () => {
assert.match(createViewTemplate, /class="insight-body"[\s\S]*:class="\{ 'document-review-body': isReviewDocumentDrawer \}"/)
assert.match(createViewBaseStyles, /\.insight-panel-shell\s*\{[\s\S]*display:\s*flex;[\s\S]*min-height:\s*0;/)
assert.match(createViewPart2Styles, /\.insight-body\.document-review-body\s*\{[\s\S]*display:\s*flex;[\s\S]*overflow:\s*hidden;/)
assert.match(createViewPart2Styles, /\.review-ticket-drawer\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);[\s\S]*height:\s*100%;[\s\S]*overflow:\s*hidden;/)
assert.match(createViewPart2Styles, /\.review-document-stage\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\);/)
assert.match(createViewPart2Styles, /\.review-document-scroll\s*\{[\s\S]*max-height:\s*none;[\s\S]*min-height:\s*0;/)
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image\s*\{[\s\S]*place-items:\s*center;[\s\S]*min-height:\s*220px;/)
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*height:\s*auto;[\s\S]*object-fit:\s*contain;/)
assert.doesNotMatch(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*object-fit:\s*cover;/)
assert.match(createViewPart4Styles, /\.review-overlay\s*\{[\s\S]*align-items:\s*center;[\s\S]*justify-content:\s*center;/)
assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/)
})
test('document preview avoids restored stale object urls', () => {
assert.match(createViewTemplate, /v-if="documentPreviewDialog\.kind === 'image'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
assert.match(createViewTemplate, /v-else-if="documentPreviewDialog\.kind === 'pdf'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
assert.match(reviewDrawerScript, /renderKey:\s*''/)
assert.match(reviewDrawerScript, /renderKey:\s*\[[\s\S]*Date\.now\(\)[\s\S]*\]\.join\('__'\)/)
assert.match(attachmentsScript, /isTemporaryPreviewUrl/)
assert.match(attachmentsScript, /existingPreview\?\.url && !isTemporaryPreviewUrl\(existingPreview\.url\)/)
assert.match(sessionStateScript, /filterPersistableFilePreviews\(state\.reviewFilePreviews\)/)
assert.doesNotMatch(sessionStateScript, /filterPersistableFilePreviews\(nextState\.reviewFilePreviews\)/)
})
test('local transport review no longer uses the travel hotel template', () => {
const reviewPayload = {
slot_cards: [
{
key: 'expense_type',
label: '报销类型',
value: '交通费',
normalized_value: 'transport',
status: 'identified'
}
],
document_cards: [
{
document_type: 'taxi_receipt',
suggested_expense_type: 'transport',
scene_label: '交通费'
}
]
}
assert.equal(isTravelReviewPayload(reviewPayload, { expense_type: '交通费' }), false)
assert.match(createViewScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
assert.doesNotMatch(
createViewScript,
/key:\s*'customer_name'[\s\S]{0,220}placeholder:\s*'请输入客户名称'[\s\S]{0,80}\},\s*\{[\s\S]{0,80}key:\s*'attachments'/
)
assert.match(createViewTemplate, /placeholder="例如:出租车\/网约车票据 \/ 火车\/高铁票"/)
assert.doesNotMatch(createViewTemplate, /票据场景[\s\S]{0,260}例如:业务招待费 \/ 差旅费/)
})
test('local save of changed reimbursement category updates edit fields too', () => {
const nextPayload = buildLocallySyncedReviewPayload(
{
can_proceed: false,
edit_fields: [
{ key: 'expense_type', label: '报销分类', value: '交通费' },
{ key: 'reason', label: '事由', value: '打车去客户现场' }
],
slot_cards: [
{
key: 'expense_type',
label: '报销类型',
value: '交通费',
normalized_value: 'transport',
required: true,
status: 'identified'
}
],
confirmation_actions: []
},
{
expense_type: '办公用品费',
reason_value: '右侧核对后改为办公用品费'
}
)
const expenseTypeField = nextPayload.edit_fields.find((item) => item.key === 'expense_type')
assert.equal(expenseTypeField.value, '办公用品费')
assert.equal(nextPayload.slot_cards[0].value, '办公用品费')
})
test('next step action uses rich text guidance and confirm dialog instead of footer button', () => {
const reviewPayload = {
can_proceed: true,
risk_briefs: [
{ level: 'low', title: '票据提示', content: '普通提示' }
],
confirmation_actions: [
{ action_type: 'save_draft', label: '保存为草稿' },
{ action_type: 'next_step', label: '继续下一步', emphasis: 'primary' }
]
}
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/requests/claim-1' })
const rendered = renderMarkdown(copy)
assert.match(copy, /系统识别您的单据已经填写完所有已知信息/)
assert.match(copy, /现存在 1 条低风险0 条中风险0 条高风险/)
assert.doesNotMatch(copy, /#review-risk-low/)
assert.doesNotMatch(copy, /#review-risk-medium/)
assert.doesNotMatch(copy, /#review-risk-high/)
assert.match(copy, /\[右侧\]\(#review-risk-panel\) 风险信息提示窗/)
assert.match(copy, /\[继续下一步\]\(#review-next-step\)/)
assert.match(copy, /\[快速修改单据信息\]\(\/app\/requests\/claim-1\)/)
assert.doesNotMatch(rendered, /markdown-risk-link-/)
assert.match(rendered, /<span class="markdown-risk-text-low">低风险<\/span>/)
assert.match(rendered, /<span class="markdown-risk-text-medium">中风险<\/span>/)
assert.match(rendered, /<span class="markdown-risk-text-high">高风险<\/span>/)
assert.doesNotMatch(rendered, /href="#review-risk-low"/)
assert.doesNotMatch(rendered, /href="#review-risk-medium"/)
assert.doesNotMatch(rendered, /href="#review-risk-high"/)
assert.match(rendered, /markdown-action-link-risk/)
assert.match(rendered, /markdown-action-link-next/)
assert.deepEqual(resolveReviewFooterActions(reviewPayload), [])
const highRiskCopy = buildReviewNextStepRichCopy(
{
...reviewPayload,
risk_briefs: [{ level: 'high', title: '金额超标' }]
},
{ detailHref: '/app/requests/claim-1' }
)
assert.doesNotMatch(highRiskCopy, /\[继续下一步\]\(#review-next-step\)/)
assert.match(createViewTemplate, /class="review-next-step-rich-copy message-answer-markdown"[\s\S]*renderMarkdown\(buildReviewNextStepRichCopyForMessage\(message\)\)/)
assert.match(createViewTemplate, /class="message-bubble" :class="buildMessageBubbleClass\(message\)"/)
assert.match(createViewTemplate, /:open="nextStepConfirmDialog\.open"[\s\S]*title="确认提交当前单据?"[\s\S]*confirm-text="确认提交"/)
assert.match(createViewScript, /const REVIEW_NEXT_STEP_HREF = '#review-next-step'/)
assert.match(createViewScript, /buildReviewRiskLevelCounts/)
assert.match(createViewScript, /function buildMessageBubbleClass\(message\)/)
assert.match(createViewScript, /message-bubble-review-risk-high/)
assert.match(createViewScript, /message-bubble-review-risk-medium/)
assert.match(createViewScript, /message-bubble-review-risk-low/)
assert.match(createViewScript, /function openReviewNextStepConfirm\(message\)/)
assert.match(createViewScript, /async function confirmReviewNextStepSubmit\(\)/)
assert.match(createViewScript, /href === REVIEW_NEXT_STEP_HREF[\s\S]*openReviewNextStepConfirm\(message\)/)
assert.match(createViewScript, /href\.startsWith\(REVIEW_RISK_PANEL_HREF_PREFIX\)[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-low\s*\{[\s\S]*border-color:\s*rgba\(37,\s*99,\s*235,/)
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-medium\s*\{[\s\S]*border-color:\s*rgba\(217,\s*119,\s*6,/)
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-high\s*\{[\s\S]*border-color:\s*rgba\(220,\s*38,\s*38,/)
assert.doesNotMatch(createViewBaseStyles, /markdown-risk-link-low/)
assert.match(createViewBaseStyles, /\.markdown-risk-text-low[\s\S]*color:\s*#2563eb/)
assert.match(createViewBaseStyles, /\.markdown-risk-text-medium[\s\S]*color:\s*#d97706/)
assert.match(createViewBaseStyles, /\.markdown-risk-text-high[\s\S]*color:\s*#dc2626/)
assert.match(createViewPart3Styles, /\.review-next-step-rich-copy\s*\{[\s\S]*margin-top:\s*30px;/)
})
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/)
assert.ok(riskItemsBlock, 'risk item builder should be present')
@@ -80,8 +258,12 @@ test('review risk drawer lists risk briefs without score and posts details into
)
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
assert.match(createViewScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
assert.match(createViewScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
assert.match(createViewScript, /\$\{isInfo \? '提示内容' : '风险点'\}\$\{summary\}/)
assert.match(createViewScript, /\$\{isInfo \? '处理建议' : '修改建议'\}\$\{suggestion\}/)
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
@@ -98,7 +280,8 @@ test('review risk drawer lists risk briefs without score and posts details into
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
)
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
assert.match(createViewScript, /function appendExpenseQueryRiskToConversation\(record, risk\) \{[\s\S]*进入 \$\{claimNo\} 详情重新填写/)

View File

@@ -67,15 +67,15 @@ const attachmentMeta = {
severity: 'high',
label: '高风险',
headline: '票据类型不匹配',
summary: '交通票据挂在办公费明细下。',
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公费'],
summary: '交通票据挂在办公用品费明细下。',
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公用品费'],
suggestion: '把费用项目调整为交通费,或更换为办公用品票据。'
}
}
test('attachment insight exposes recognition fields and rule basis', () => {
const insight = buildAttachmentInsightViewModel(attachmentMeta, {
name: '办公费',
name: '办公用品费',
itemType: 'office'
})
@@ -90,7 +90,7 @@ test('AI advice card splits every attachment risk point with basis and suggestio
expenseItems: [
{
id: 'item-1',
name: '办公费',
name: '办公用品费',
invoiceId: 'taxi-invoice.pdf'
}
],
@@ -278,7 +278,7 @@ test('AI advice view model exposes grouped completion and risk sections', () =>
tone: 'high',
label: '高风险',
title: '票据类型不匹配',
risk: '交通票据挂在办公费明细下。',
risk: '交通票据挂在办公用品费明细下。',
ruleBasis: ['附件类型与当前费用项目不匹配。'],
suggestion: '把费用项目调整为交通费。'
}