refactor(travel): split reimbursement create workflow

完整修改内容:

- 拆分 TravelReimbursementCreateView:提取审核面板纯模型、消息操作、建议动作处理、生命周期 watcher/UI 映射、小财管家运行时、续办流程和运行时文本模型,减少主组件继续堆叠业务分支。
- 调整申请预览链路:新增本地申请意图 gate,完善复杂差旅申请的大模型复核判断、交通方式缺失/候选识别、规则中心交通费用预估合并和申请冲突处理。
- 优化小财管家流程:抽出 steward typewriter 分段策略,避免 Markdown 表格逐字闪烁;补齐跨助手 carry、字段补齐续办、文本确认提交和行程规划推荐动作。
- 调整消息与样式:移除申请预览日期 chip 样式,收敛申请卡片/报销草稿消息的展示与复制、朗读、反馈入口逻辑。
- 更新测试:将源码锚点迁移到新模块,覆盖申请预览、提交确认、小财管家续办、引导流和审核抽屉相关断言。

验证:

- node --check web/src/views/scripts/TravelReimbursementCreateView.js 及新增拆分模块
- npm --prefix web run build
- node --test web/tests/expense-application-fast-preview.test.mjs web/tests/expense-application-submit-rich-confirm.test.mjs web/tests/travel-reimbursement-guided-flow.test.mjs

说明:

- 后端/规则/容器配置/Audit 页面等工作区已有改动未纳入本提交。
- 容器内后端定向 pytest 曾运行 timeout 180s /tmp/x-financial-server-venv/bin/pytest -q <相关后端测试>,180 秒超时且超时前已有失败标记,未作为通过项记录。
- TravelReimbursementCreateView 当前仍超过 800 行,后续仍需继续拆分;本提交先把新增职责模块控制在 800 行内,阻止主类/主模块继续膨胀。
This commit is contained in:
Codex
2026-06-13 14:52:26 +00:00
parent 336fee9d93
commit 8b952c9a26
28 changed files with 4510 additions and 2730 deletions

View File

@@ -1,14 +1,3 @@
.application-preview-date-chip {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active, #255b7d);
font-weight: 850;
}
.application-draft-preview {
width: min(100%, 620px);
max-width: 620px;

View File

@@ -532,10 +532,11 @@
.message-answer-markdown :deep(table) {
width: 100%;
min-width: 460px;
min-width: 560px;
border: 0;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
background: #ffffff;
font-size: inherit;
}
@@ -547,6 +548,25 @@
text-align: left;
vertical-align: top;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
}
.message-answer-markdown :deep(th:first-child),
.message-answer-markdown :deep(td:first-child) {
width: 88px;
white-space: nowrap;
word-break: keep-all;
overflow-wrap: normal;
}
.message-answer-markdown :deep(th:last-child),
.message-answer-markdown :deep(td:last-child) {
width: 112px;
text-align: right;
white-space: nowrap;
word-break: keep-all;
overflow-wrap: normal;
}
.message-answer-markdown :deep(th) {
@@ -786,30 +806,6 @@
border-top: 1px solid #e6edf5;
}
.structured-card-reveal-enter-active .application-preview-row {
animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(2) {
animation-delay: 35ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(3) {
animation-delay: 70ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(4) {
animation-delay: 105ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(5) {
animation-delay: 140ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(n + 6) {
animation-delay: 165ms;
}
.application-preview-row.editable {
cursor: pointer;
}

View File

@@ -207,7 +207,6 @@
<template v-else>
<span
class="application-preview-text"
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
>{{ row.value }}</span>
<button
v-if="row.editable"

View File

@@ -1,4 +1,7 @@
import ontologyBusinessContract from '../../../shared/ontology_business_contract.json' with { type: 'json' }
export const ASSISTANT_SCOPE_ACTION_SWITCH = 'switch_assistant_session'
export const ASSISTANT_SCOPE_ACTION_UNSUPPORTED = 'unsupported_business_intent'
export const ASSISTANT_SCOPE_SESSION_APPLICATION = 'application'
export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense'
@@ -6,7 +9,7 @@ export const ASSISTANT_SCOPE_SESSION_APPROVAL = 'approval'
export const ASSISTANT_SCOPE_SESSION_KNOWLEDGE = 'knowledge'
export const ASSISTANT_SCOPE_SESSION_STEWARD = 'steward'
const SESSION_SCOPE_CONFIG = {
const FALLBACK_SESSION_SCOPE_CONFIG = {
[ASSISTANT_SCOPE_SESSION_STEWARD]: {
label: '小财管家',
icon: 'mdi mdi-account-tie-outline',
@@ -34,7 +37,10 @@ const SESSION_SCOPE_CONFIG = {
}
}
const ONTOLOGY_BUSINESS_CONTRACT = ontologyBusinessContract || {}
const SESSION_SCOPE_CONFIG = ONTOLOGY_BUSINESS_CONTRACT.sessions || FALLBACK_SESSION_SCOPE_CONFIG
const SESSION_SCOPE_TYPES = Object.keys(SESSION_SCOPE_CONFIG)
const BUSINESS_SIGNAL_GROUPS = ONTOLOGY_BUSINESS_CONTRACT.businessSignals || {}
const APPLICATION_PATTERN =
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|申请出差|差旅申请|申请差旅|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
@@ -56,6 +62,45 @@ const KNOWLEDGE_PATTERN =
/制度|政策|标准|规则|规定|流程|口径|依据|上限|额度|补贴|住宿标准|差旅标准|报销标准|票据要求|可不可以|能不能|怎么规定|如何计算|怎么算/
const EXPENSE_OPERATION_PATTERN = /发起报销|报销单|票据|发票|火车票|高铁票|机票|的士票|草稿|归集|上传|关联单据|继续下一步/
const CURRENT_CLAIM_RISK_PATTERN = /这张|当前|本单|该单|单据|风险|超标|异常|重复|待补/
const FINANCE_OPERATING_PATTERN = buildKeywordPattern([
...(BUSINESS_SIGNAL_GROUPS.budget || []),
...(BUSINESS_SIGNAL_GROUPS.accounts_receivable || []),
...(BUSINESS_SIGNAL_GROUPS.accounts_payable || [])
])
const CONTEXTUAL_FOLLOW_UP_PATTERN = buildExactKeywordPattern(ONTOLOGY_BUSINESS_CONTRACT.contextualFollowUps || [])
export const SUPPORTED_BUSINESS_SCOPE_TEXT = Array.isArray(ONTOLOGY_BUSINESS_CONTRACT.supportedBusinessScopes)
? ONTOLOGY_BUSINESS_CONTRACT.supportedBusinessScopes
: [
'费用申请/事前审批',
'报销与票据识别',
'审批审核与风险解释',
'财务制度、报销标准和流程规则问答',
'预算、应收、应付等财务经营查询',
'小财管家多任务拆解和附件归集'
]
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildKeywordPattern(keywords = []) {
const source = keywords
.map((keyword) => String(keyword || '').trim())
.filter(Boolean)
.map(escapeRegExp)
.join('|')
return source ? new RegExp(source) : /$a/
}
function buildExactKeywordPattern(keywords = []) {
const source = keywords
.map((keyword) => String(keyword || '').trim())
.filter(Boolean)
.map(escapeRegExp)
.join('|')
return source ? new RegExp(`^(${source})$`) : /$a/
}
function normalizeSessionType(sessionType) {
const normalized = String(sessionType || '').trim()
@@ -131,6 +176,10 @@ export function inferAssistantScopeTarget(rawText, options = {}) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}
if (FINANCE_OPERATING_PATTERN.test(text)) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
if (knowledgeMatched && !expenseMatched && !approvalMatched && !applicationMatched) {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
@@ -198,10 +247,58 @@ function buildScopeBoundaryText(currentSessionType, targetSessionType) {
].join('\n')
}
function shouldAllowContextualFollowUp(rawText, currentSessionType, options = {}) {
const text = normalizeText(rawText)
if (options.hasActiveReviewPayload && CURRENT_CLAIM_RISK_PATTERN.test(text)) {
return true
}
if (!text || !CONTEXTUAL_FOLLOW_UP_PATTERN.test(text)) {
return false
}
return Boolean(
options.hasActiveReviewPayload ||
options.hasPendingApplicationPreview ||
options.reviewAction ||
currentSessionType
)
}
function buildUnsupportedBusinessScopeText() {
const message = ONTOLOGY_BUSINESS_CONTRACT.unsupportedIntentMessage || {}
return [
message.title || '此意图系统不支持。',
'',
`当前系统支持的业务范围:${SUPPORTED_BUSINESS_SCOPE_TEXT.join('、')}`,
'',
message.body || '你这条内容没有识别到相关财务业务意图,系统暂不支持处理。',
'',
message.retryHint || '请重新描述你的财务业务要求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。'
].join('\n')
}
function buildUnsupportedBusinessScopeGuard() {
return {
targetSessionType: '',
targetLabel: '不支持的意图',
blocked: true,
text: buildUnsupportedBusinessScopeText(),
meta: ['意图不支持'],
suggestedActions: [],
actionType: ASSISTANT_SCOPE_ACTION_UNSUPPORTED
}
}
export function resolveAssistantScopeGuard(rawText, currentSessionType, options = {}) {
const normalizedCurrent = normalizeSessionType(currentSessionType)
const targetSessionType = inferAssistantScopeTarget(rawText, options)
if (!targetSessionType || targetSessionType === normalizedCurrent) {
if (!targetSessionType) {
if (shouldAllowContextualFollowUp(rawText, normalizedCurrent, options)) {
return null
}
return normalizeText(rawText) ? buildUnsupportedBusinessScopeGuard() : null
}
if (targetSessionType === normalizedCurrent) {
return null
}

View File

@@ -6,7 +6,7 @@ const LOCATION_BANDS = {
const TRANSPORT_PRICE_BASE = {
火车: { default: 360, premium: 520, remote: 900, coastal: 520 },
飞机: { default: 850, premium: 1100, remote: 1800, coastal: 1050 },
飞机: { default: 600, premium: 650, remote: 1600, coastal: 700 },
轮船: { default: 320, premium: 480, remote: 680, coastal: 520 }
}
@@ -125,8 +125,8 @@ export function buildMockApplicationTransportEstimate({
queryDate,
priceFactor,
simulatedLatencyMs,
source: 'mock_ticket_price_query_v1',
confidence: 'mock',
source: 'fallback_transport_budget_estimate_v1',
confidence: 'fallback',
basisText: `预估交通费用 ${amountDisplay}`
}
}

View File

@@ -0,0 +1,93 @@
const APPLICATION_SESSION_TYPE = 'application'
const APPLICATION_ASSISTANT_ECHO_PATTERN = /(?:这是)?费用申请核对结果|申请核对结果|请核对上述信息|确认无误后.*提交至审批流程/
const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料清单|需要哪些|制度|标准|规则|怎么规定/
const APPLICATION_CREATE_PATTERN = /申请|发起|提交|创建|新建|事前|前置|出差|差旅|采购|会务|会议|培训/
const APPLICATION_REASON_PATTERN = /支撑|支持|部署|上线|实施|驻场|拜访|验收|培训|协助|处理|办理|参加|服务/
export function evaluateLocalApplicationIntentGate(rawText, options = {}) {
const compact = compactText(rawText)
if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) {
return block('out_of_scope', 0.98, '不是申请会话。')
}
if (options.systemGenerated || options.reviewAction || Number(options.attachmentCount || 0) > 0) {
return block('out_of_scope', 0.96, '系统生成、审核动作或附件场景不走本地申请预览。')
}
if (!compact) {
return block('chitchat_or_noise', 0.95, '输入为空。')
}
if (APPLICATION_ASSISTANT_ECHO_PATTERN.test(compact)) {
return block('assistant_echo', 0.96, '用户输入是助手申请核对文案回显。')
}
if (APPLICATION_QUERY_PATTERN.test(compact)) {
return block('ask_question', 0.86, '用户更像是在查询或询问。')
}
const fields = collectLocalApplicationIntentFields(rawText)
const fieldCount = Object.keys(fields).length
const hasCreateSignal = APPLICATION_CREATE_PATTERN.test(compact)
if (hasCreateSignal && fieldCount >= 2) {
return allow('create_application', 0.78, '申请动作和结构化申请事实同时存在。', fields)
}
if (hasCreateSignal && fieldCount === 1 && hasStrongBusinessSignal(compact)) {
return allow('create_application', 0.72, '申请动作和明确业务事实同时存在。', fields)
}
return block(
hasCreateSignal ? 'unknown' : 'chitchat_or_noise',
hasCreateSignal ? 0.58 : 0.82,
'未识别到足够的新建申请意图。',
fields
)
}
function allow(intent, confidence, reason, fields = {}) {
return {
intent,
allowed: true,
confidence,
reason,
fields
}
}
function block(intent, confidence, reason, fields = {}) {
return {
intent,
allowed: false,
confidence,
reason,
fields
}
}
function collectLocalApplicationIntentFields(rawText) {
const text = String(rawText || '')
const compact = compactText(text)
const fields = {}
if (/(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|[1-9]\d?月[1-3]?\d日?)/.test(compact)) {
fields.time = 'time_text'
}
if (/(?:\d+|[一二两三四五六七八九十]{1,3})天/.test(compact)) {
fields.days = 'days_text'
}
if (/\d+(?:\.\d+)?\s*(?:万|千|k|K|元|块|人民币)/.test(text)) {
fields.amount = 'amount_text'
}
if (/(?:飞机|机票|航班|火车|高铁|动车|轮船|船票|客轮|渡轮)/.test(compact)) {
fields.transportMode = 'transport_text'
}
if (/(?:去|到|赴|前往)[\u4e00-\u9fa5]{1,24}(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|$)/.test(compact)) {
fields.location = 'location_text'
}
if (APPLICATION_REASON_PATTERN.test(compact)) {
fields.reason = 'reason_text'
}
return fields
}
function hasStrongBusinessSignal(compactTextValue) {
return APPLICATION_REASON_PATTERN.test(compactTextValue) || /出差|差旅/.test(compactTextValue)
}
function compactText(value) {
return String(value || '').replace(/\s+/g, '')
}

View File

@@ -127,6 +127,13 @@ export function resolveApplicationAmount(ontology) {
}
}
function resolveApplicationTypedAmount(ontology, type) {
const entity = resolveEntity(ontology, type)
const rawValue = entity?.normalized_value || entity?.value || ''
const numericValue = Number(String(rawValue).replace(/[^\d.]/g, ''))
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : 0
}
export function resolveTimeRangeText(ontology) {
const range = ontology?.time_range || {}
if (range.start_date && range.end_date) {
@@ -261,7 +268,8 @@ function cleanupApplicationReasonCandidate(value, location = '') {
if (!text) return ''
text = text
.replace(/^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)\s*[:]\s*/u, '')
.replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
.replace(/^(?:类型|申请类型|费用类型|报销类型|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)\s*[:]\s*/u, '')
.replace(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/gu, '')
.replace(/(?:出差|申请)?(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/gu, '')
.replace(/(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元|人民币)?/gu, '')
@@ -281,6 +289,7 @@ function cleanupApplicationReasonCandidate(value, location = '') {
}
if (!text) return ''
if (isInvalidApplicationReason(text)) return ''
if (/^20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?$/.test(text)) return ''
if (/^(?:\d+|[一二两三四五六七八九十]{1,3})\s*天$/.test(text)) return ''
if (/^[\u4e00-\u9fa5]{1,8}$/.test(text) && !/服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(text)) {
@@ -303,22 +312,44 @@ export function resolveApplicationReason(prompt, ontology = null) {
const reasonEntity = resolveEntity(ontology, 'reason') || resolveEntity(ontology, 'business_reason')
const entityReason = String(reasonEntity?.normalized_value || reasonEntity?.value || '').trim()
if (entityReason) {
return cleanupApplicationReasonCandidate(entityReason, location) || entityReason
const cleanedEntityReason = cleanupApplicationReasonCandidate(entityReason, location)
if (cleanedEntityReason && !isInvalidApplicationReason(cleanedEntityReason)) {
return cleanedEntityReason
}
}
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
if (labeled) {
return cleanupApplicationReasonCandidate(labeled, location) || labeled
const cleanedLabeledReason = cleanupApplicationReasonCandidate(labeled, location)
if (cleanedLabeledReason && !isInvalidApplicationReason(cleanedLabeledReason)) {
return cleanedLabeledReason
}
}
const candidates = String(prompt || '')
.split(/[\n;]+/u)
.map((item) => cleanupApplicationReasonCandidate(item, location))
.filter(Boolean)
.filter((item) => item && !isSystemGeneratedApplicationReason(item) && !isInvalidApplicationReason(item))
const businessCandidate = candidates.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item))
return businessCandidate || candidates.sort((left, right) => right.length - left.length)[0] || ''
}
function isInvalidApplicationReason(value = '') {
const compact = String(value || '').replace(/\s+/g, '')
if (!compact) return true
if (/^(?:类型|申请类型|费用类型|报销类型)[:]?/.test(compact)) return true
if (/^(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)$/.test(compact)) return true
return false
}
function isSystemGeneratedApplicationReason(value = '') {
const compact = String(value || '').replace(/\s+/g, '')
return compact.startsWith('小财管家继续执行')
|| compact.startsWith('处理要求')
|| compact.startsWith('已识别信息')
|| compact.startsWith('用户已补充')
}
function resolveApplicationTransportMode(ontology, prompt) {
const transportEntity = resolveEntity(ontology, 'transport_mode')
|| resolveEntity(ontology, 'transport')
@@ -383,6 +414,13 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
const reason = resolveApplicationReason(prompt, ontology) || '待补充'
const days = resolvePromptDays(prompt)
const transportMode = resolveApplicationTransportMode(ontology, prompt)
const transportEstimatedAmount = resolveApplicationTypedAmount(ontology, 'transport_estimated_amount')
const trainEstimatedAmount = resolveApplicationTypedAmount(ontology, 'train_estimated_amount')
const flightEstimatedAmount = resolveApplicationTypedAmount(ontology, 'flight_estimated_amount')
const hotelAmount = resolveApplicationTypedAmount(ontology, 'hotel_amount')
const allowanceAmount = resolveApplicationTypedAmount(ontology, 'allowance_amount')
const policyTotalAmount = resolveApplicationTypedAmount(ontology, 'policy_total_amount')
const reimbursementAmount = resolveApplicationTypedAmount(ontology, 'reimbursement_amount')
const fields = {
documentType: documentTypeEntity?.normalized_value || 'expense_application',
@@ -393,6 +431,13 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
amount: amount.value,
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
transportEstimatedAmount,
trainEstimatedAmount,
flightEstimatedAmount,
hotelAmount,
allowanceAmount,
policyTotalAmount,
reimbursementAmount,
timeRange,
location,
reason,

View File

@@ -1,14 +1,13 @@
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
import { evaluateLocalApplicationIntentGate } from './expenseApplicationIntentGate.js'
import {
buildMockApplicationTransportEstimate,
formatApplicationEstimateMoney,
parseApplicationEstimateMoney,
buildSystemApplicationEstimate
} from './expenseApplicationEstimate.js'
import { getTodayDateValue } from './workbenchComposerDate.js'
const APPLICATION_SESSION_TYPE = 'application'
const APPLICATION_CREATE_PATTERN = /申请|事前|前置|预算|出差|差旅|采购|会务|会议|培训|办公用品|交通|住宿/
const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料清单|需要哪些|制度|标准|规则|怎么规定/
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
{ key: 'applicationType', label: '申请类型' },
{ key: 'applicant', label: '姓名', editable: false, required: false },
@@ -111,6 +110,29 @@ function buildEndDateFromDays(startText, daysText = '') {
return formatIsoDate(end)
}
function buildDateFromMonthDay(year, month, day) {
const normalized = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return parseIsoDate(normalized) ? normalized : ''
}
function resolveShortMonthDayRange(text, options = {}) {
const match = String(text || '').match(
/(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?\s*(?:至|到|~|—||--|-)\s*(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日/u
)
if (!match?.groups) return ''
const referenceYear = Number(resolvePreviewToday(options).slice(0, 4))
const startMonth = Number(match.groups.startMonth)
const startDay = Number(match.groups.startDay)
const endMonth = Number(match.groups.endMonth || match.groups.startMonth)
const endDay = Number(match.groups.endDay)
const startDate = buildDateFromMonthDay(referenceYear, startMonth, startDay)
const endYear = endMonth < startMonth ? referenceYear + 1 : referenceYear
const endDate = buildDateFromMonthDay(endYear, endMonth, endDay)
if (!startDate || !endDate) return ''
return startDate === endDate ? startDate : `${startDate}${endDate}`
}
function resolveDaysFromDateRange(rangeText) {
const match = String(rangeText || '').match(/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*至\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/)
if (!match) return ''
@@ -125,6 +147,80 @@ export function resolveApplicationDaysFromDateRange(rangeText) {
return resolveDaysFromDateRange(rangeText)
}
function resolveApplicationValidationIssues(fields = {}) {
const issues = []
const rangeDaysText = resolveDaysFromDateRange(fields.time)
const rangeDays = parseApplicationDaysValue(rangeDaysText)
const explicitDays = parseApplicationDaysValue(fields.days)
if (rangeDays && explicitDays && rangeDays !== explicitDays) {
issues.push({
code: 'time_days_conflict',
field: 'days',
message: `申请时间 ${fields.time} 按自然日为 ${rangeDays} 天,但填写的是 ${explicitDays} 天。`
})
}
return issues
}
function shouldTrustModelApplicationFields(preview = {}) {
const status = String(preview?.modelReviewStatus || '').trim()
const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim()
return Boolean(preview?.modelRefined)
|| status === 'completed'
|| strategy === 'llm_primary'
}
function resolveApplicationSourceValidationIssues(sourceText = '', fields = {}, preview = {}) {
if (shouldTrustModelApplicationFields(preview)) {
return []
}
const issues = []
const locationCandidates = extractApplicationLocationCandidates(sourceText)
if (locationCandidates.length > 1) {
issues.push({
code: 'location_candidates_conflict',
field: 'location',
message: `当前输入里同时出现多个地点:${locationCandidates.join('、')}。请确认本次申请地点。`
})
}
const transportCandidates = extractApplicationTransportCandidates(sourceText)
if (transportCandidates.length > 1) {
issues.push({
code: 'transport_candidates_conflict',
field: 'transportMode',
message: `当前输入里同时出现多个出行方式:${transportCandidates.join('、')}。请确认本次申请使用哪一种。`
})
}
const amountCandidates = extractApplicationAmountCandidates(sourceText)
if (amountCandidates.length > 1) {
issues.push({
code: 'amount_candidates_conflict',
field: 'amount',
message: `当前输入里同时出现多个金额:${amountCandidates.join('、')}。请确认本次申请金额。`
})
}
return issues
}
export function shouldRequireApplicationModelReview(rawText = '') {
const text = String(rawText || '').trim()
const compact = compactText(text)
if (!compact) return false
const hasDateRange = /(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|(?:\d{1,2}月)?\d{1,2}日?)\s*(?:至|到|~|—||--|-)\s*(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|\d{1,2}月?\d{1,2}日?)/u.test(text)
const hasTravelIntent = /差旅|出差|去|到|赴|前往/.test(compact)
const hasTransport = /高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮/.test(compact)
const hasBusinessPurpose = /服务|支撑|支持|辅助|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(compact)
const hasInlinePurpose = hasBusinessPurpose && !/(?:事由|申请事由|出差事由|原因|用途)\s*[:]/.test(text)
const hasMultipleClauses = /[,。;;\n]/.test(text) || compact.length >= 24
const signalCount = [hasDateRange, hasTravelIntent, hasTransport, hasBusinessPurpose].filter(Boolean).length
return (hasInlinePurpose && signalCount >= 2) || (hasMultipleClauses && signalCount >= 3)
}
export function resolveApplicationDateRange(rangeText, daysText = '') {
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
const startDate = normalizeDateText(matchedDates[0] || '')
@@ -179,14 +275,106 @@ function resolveApplicationType(text) {
function resolveApplicationAmount(text) {
const compact = compactText(text)
const labeled = resolveFirstMatch(text, [
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[:]?\s*(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)?/u,
/(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)/u
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[:]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?)/u,
/(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/u
])
if (labeled) return `${labeled}`
const normalized = normalizeApplicationAmountText(labeled)
if (normalized) return normalized
if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
return ''
}
function normalizeApplicationAmountText(value) {
const text = String(value || '').replace(/[,]/g, '').trim()
const match = text.match(/(?<number>\d+(?:\.\d+)?)\s*(?<unit>万|千|k|K)?/u)
if (!match?.groups) return ''
let amount = Number(match.groups.number)
if (!Number.isFinite(amount) || amount <= 0) return ''
const unit = String(match.groups.unit || '').toLowerCase()
if (unit === '万') amount *= 10000
if (unit === '千' || unit === 'k') amount *= 1000
return `${Number.isInteger(amount) ? amount : Number(amount.toFixed(2))}`
}
function extractApplicationLocationCandidates(text) {
const candidates = []
const labeled = resolveFirstMatch(text, [
/(?:地点|业务地点|发生地点|目的地)\s*[:]\s*(?<value>[^。;;\n,]+)/u
])
if (labeled) candidates.push(normalizeLocationCandidate(labeled))
const compact = compactText(text)
const patterns = [
/(?:去|到|赴|前往)(?<value>[\u4e00-\u9fa5]{1,24})/gu,
/(?<value>[\u4e00-\u9fa5]{1,12})(?:出差|驻场)/gu
]
for (const pattern of patterns) {
for (const match of compact.matchAll(pattern)) {
candidates.push(normalizeLocationCandidate(match.groups?.value || ''))
}
}
return uniqueApplicationCandidates(candidates)
.filter((item) => !isInvalidApplicationLocationCandidate(item))
}
function normalizeLocationCandidate(value) {
let cleaned = String(value || '').replace(/\s+/g, '')
for (const marker of ['前往', '去', '到', '赴']) {
if (cleaned.includes(marker)) {
cleaned = cleaned.slice(cleaned.lastIndexOf(marker) + marker.length)
break
}
}
cleaned = cleaned
.replace(/^(?:去|到|赴|前往)/u, '')
.replace(/(?:出差|驻场|现场|支撑|支持|部署|上线|实施|拜访|验收|会议|采购|培训|协助|处理|办理|参加|进行).*$/u, '')
.replace(/[:,。;;、\s]/g, '')
return cleaned.replace(/^(北京|上海|天津|重庆)市$/u, '$1')
}
function isInvalidApplicationLocationCandidate(value) {
const compact = compactText(value)
if (!compact) return true
if (/^(?:类型|申请类型|费用类型|报销类型)$/.test(compact)) return true
if (/^(?:费用申请|差旅费用申请|交通费用申请|住宿费用申请|会务费用申请|采购费用申请|培训费用申请|报销申请|申请)$/.test(compact)) return true
if (/(?:类型|申请类型|费用类型|报销类型)/.test(compact)) return true
if (/(?:交通方式|出行方式|交通工具|预算|金额|费用|票据|附件|天数|时间|事由|原因|用途)/.test(compact)) return true
return false
}
function extractApplicationTransportCandidates(text) {
const compact = compactText(text)
return uniqueApplicationCandidates([
resolveApplicationTransportMode(resolveFirstMatch(text, [
/(?:出行方式|交通方式|交通工具|出行工具)\s*[:]\s*(?<value>[^。;;\n,]+)/u
])),
/高铁|动车|火车|铁路/.test(compact) ? '火车' : '',
/飞机|机票|航班/.test(compact) ? '飞机' : '',
/轮船|船票|客轮|渡轮|邮轮/.test(compact) ? '轮船' : ''
])
}
function extractApplicationAmountCandidates(text) {
const candidates = []
const source = String(text || '')
const labelPattern = /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[:]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?\s*(?:元|块|人民币)?)/gu
for (const match of source.matchAll(labelPattern)) {
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
}
const amountPattern = /(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/gu
for (const match of source.matchAll(amountPattern)) {
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
}
return uniqueApplicationCandidates(candidates)
}
function uniqueApplicationCandidates(values) {
return values
.map((item) => String(item || '').trim())
.filter(Boolean)
.filter((item, index, list) => list.indexOf(item) === index)
}
function resolveCurrentUserGrade(currentUser = {}) {
return String(
currentUser.grade
@@ -282,12 +470,45 @@ function formatDailyPolicyMoney(value) {
function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
const mode = String(transportMode || '').trim()
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
return estimate.basisText
}
function buildTransportEstimateFromPolicyResult(result = {}, fields = {}) {
const amount = parseMoneyNumber(result?.transport_estimated_amount)
if (!amount || amount <= 0) return null
const amountDisplay = formatPolicyMoney(amount)
const mode = String(result?.transport_mode || fields.transportMode || '').trim()
const origin = String(result?.transport_origin || '').trim()
const destination = String(result?.transport_destination || result?.matched_city || fields.location || '').trim()
const basis = String(result?.transport_estimate_basis || '').trim()
const ruleName = String(result?.transport_estimate_rule_name || '交通费用预估表').trim()
const routeText = [origin, destination].filter(Boolean).join('-')
const modeText = mode ? `${mode}往返` : '往返'
const routeModeText = routeText ? `${routeText}${modeText}` : modeText
const displayBasis = routeModeText && basis.startsWith(routeModeText)
? basis.slice(routeModeText.length).trim()
: basis
const basisSuffix = displayBasis ? `${displayBasis}` : ''
return {
mode,
amount,
amountDisplay,
routeType: '往返',
origin,
destination,
queryDate: String(result?.travel_date || '').trim(),
source: String(result?.transport_estimate_source || 'basic_rule_transport_estimate').trim(),
confidence: String(result?.transport_estimate_confidence || 'basic_rule').trim(),
basis,
ruleCode: String(result?.transport_estimate_rule_code || '').trim(),
ruleName,
ruleVersion: String(result?.transport_estimate_rule_version || '').trim(),
basisText: `当前尚未接通实时票务价格查询 API无法获取当前实际票价先按《${ruleName}${routeModeText}${basisSuffix}暂估 ${amountDisplay}元用于申请阶段预算占用,最终报销以实际票据金额为准`
}
}
function ensureApplicationPolicyFields(fields = {}) {
const nextFields = { ...fields }
if (!String(nextFields.lodgingDailyCap || '').trim()) {
@@ -321,6 +542,11 @@ function resolveApplicationTime(text, daysText = '', options = {}) {
return `${normalizeDateText(range[1])}${normalizeDateText(range[2])}`
}
const shortMonthDayRange = resolveShortMonthDayRange(text, options)
if (shortMonthDayRange) {
return shortMonthDayRange
}
const single = resolveFirstMatch(text, [
/(?:发生时间|业务发生时间|申请时间|时间)\s*[:]\s*(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
/(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
@@ -332,7 +558,7 @@ function resolveApplicationTime(text, daysText = '', options = {}) {
}
function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
const resolvedTime = resolveApplicationTime(text, daysText)
const resolvedTime = resolveApplicationTime(text, daysText, options)
if (resolvedTime || !parseApplicationDaysValue(daysText)) {
return resolvedTime
}
@@ -349,7 +575,39 @@ function resolveApplicationLocation(text) {
])
}
function looksLikeTransportPromptText(text) {
const compact = compactText(text)
return /(?:还需要补充|当前还缺|缺少|请先补充|请选择|可以选择|可以选|打算怎么出行|怎么出行).{0,24}(?:出行方式|交通方式|交通工具)/u.test(compact)
|| /(?:出行方式|交通方式|交通工具).{0,24}(?:待补充|缺失|请选择|可以选择|可以选)/u.test(compact)
}
function resolveApplicationTransportMode(text) {
const labeled = resolveFirstMatch(text, [
/(?:出行方式|交通方式|交通工具|出行工具)\s*[:]\s*(?<value>[^。;;\n,]+)/u
])
const labeledMode = normalizeTransportModeOption(labeled, '')
if (labeledMode && !looksLikeTransportPromptText(labeled) && !/(?:请选择|可以选择|可以选)/u.test(String(labeled || ''))) {
return labeledMode
}
const fullTextLooksLikePrompt = looksLikeTransportPromptText(text)
const segments = String(text || '')
.split(/[\n,。;;]+/u)
.map((item) => item.trim())
.filter(Boolean)
for (const segment of segments) {
if (looksLikeTransportPromptText(segment)) continue
const compactSegment = compactText(segment)
if (
fullTextLooksLikePrompt
&& !/(?:申请|出差|去|到|赴|前往|服务|支撑|支持|部署|上线|实施|拜访|会议)/u.test(compactSegment)
) {
continue
}
if (/高铁|动车|火车|铁路/.test(compactSegment)) return '火车'
if (/飞机|机票|航班/.test(compactSegment)) return '飞机'
if (/轮船|船票|客轮|渡轮/.test(compactSegment)) return '轮船'
}
if (fullTextLooksLikePrompt) return ''
const compact = compactText(text)
if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
if (/飞机|机票|航班/.test(compact)) return '飞机'
@@ -360,6 +618,7 @@ function resolveApplicationTransportMode(text) {
function stripKnownContextFromReason(value, context = {}) {
const location = String(context.location || '').trim()
let cleaned = String(value || '')
.replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
.replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[:]\s*(?=[,、。;;\s]|$)/gu, '')
.replace(/(?:地点|业务地点|发生地点|目的地)\s*[:]\s*(?=[,、。;;\s]|$)/gu, '')
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—||--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
@@ -387,10 +646,20 @@ function pickBusinessReasonSegment(text) {
const segments = String(text || '')
.split(/[,、。;;\n]+/u)
.map((item) => item.trim())
.filter(Boolean)
.filter((item) => item && !isSystemGeneratedReasonText(item))
return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
}
function isSystemGeneratedReasonText(value = '') {
const compact = compactText(value)
return compact.startsWith('小财管家继续执行')
|| /请直接生成申请单核对结果|信息足够时生成申请单|入库或提交审批前|请直接生成报销核对结果/.test(compact)
|| compact.startsWith('处理要求')
|| compact.startsWith('已识别信息')
|| compact.startsWith('用户已补充')
|| /^(?:类型|申请类型|费用类型|报销类型)[:]?(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)?$/.test(compact)
}
function resolveApplicationReason(text, context = {}) {
const labeled = resolveFirstMatch(text, [
/(?:事由|申请事由|出差事由|原因|用途)\s*[:]\s*(?<value>[^,。;;\n]+)/u
@@ -401,6 +670,7 @@ function resolveApplicationReason(text, context = {}) {
const withoutContext = stripKnownContextFromReason(cleaned, context)
const businessSegment = pickBusinessReasonSegment(withoutContext)
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
if (isSystemGeneratedReasonText(withoutContext)) return ''
return withoutContext
}
@@ -447,7 +717,7 @@ function resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', cur
}
function normalizeAmountFromOntology(fields = {}, fallback = '') {
const numericAmount = Number(fields.amount || 0)
const numericAmount = Number(fields.amount || fields.policyTotalAmount || fields.reimbursementAmount || 0)
if (Number.isFinite(numericAmount) && numericAmount > 0) {
return `${numericAmount}`
}
@@ -461,6 +731,14 @@ function normalizeAmountFromOntology(fields = {}, fallback = '') {
return fallback
}
function normalizeTypedOntologyAmount(value, fallback = '') {
const amount = Number(value || 0)
if (Number.isFinite(amount) && amount > 0) {
return `${amount}`
}
return fallback
}
function buildMissingFields(fields) {
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
.filter((item) => item.key !== 'applicationType' && item.required !== false)
@@ -478,6 +756,14 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
const transportMode = String(fields.transportMode || '').trim()
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
if (/差旅|出差/.test(applicationType) && !transportMode) {
return {
canCalculate: false,
reason: '缺少出行方式',
payload: null
}
}
if (!shouldEstimate || !days || !location) {
return {
canCalculate: false,
@@ -492,27 +778,88 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
payload: {
days,
location,
grade
grade,
transport_mode: transportMode || null,
origin_location: String(
currentUser.location
|| currentUser.officeLocation
|| currentUser.office_location
|| currentUser.baseCity
|| currentUser.base_city
|| ''
).trim() || null,
travel_date: resolveApplicationTripDateParts(fields).startDate || null
}
}
}
export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, currentUser = {}) {
const fields = { ...(preview?.fields || {}) }
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
const resultTransportMode = String(result?.transport_mode || '').trim()
const fields = {
...(preview?.fields || {}),
...(!String(preview?.fields?.transportMode || '').trim() && resultTransportMode
? { transportMode: resultTransportMode }
: {})
}
const hotelRate = formatPolicyMoney(result?.hotel_rate)
const hotelAmount = formatPolicyMoney(result?.hotel_amount)
const allowanceRate = formatPolicyMoney(result?.total_allowance_rate)
const allowanceAmount = formatPolicyMoney(result?.allowance_amount)
const matchedCity = String(result?.matched_city || fields.location || '').trim()
const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).trim()
const systemEstimate = buildSystemApplicationEstimate({
if (isTravelApplicationType(fields.applicationType) && !String(fields.transportMode || '').trim()) {
return normalizeApplicationPreview({
...preview,
fields: {
...fields,
grade,
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
transportPolicy: APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
policyEstimate: APPLICATION_POLICY_PENDING_TEXT,
matchedCity,
ruleName: String(result?.rule_name || '').trim(),
ruleVersion: String(result?.rule_version || '').trim(),
hotelAmount: hotelAmount ? `${hotelAmount}` : '',
allowanceAmount: allowanceAmount ? `${allowanceAmount}` : '',
transportEstimatedAmount: '',
transportEstimateDate: '',
transportQueryLatencyMs: '',
transportEstimateSource: '',
transportEstimateConfidence: '',
policyTotalAmount: ''
},
policyEstimateStatus: 'pending'
})
}
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
let systemEstimate = buildSystemApplicationEstimate({
transportMode: fields.transportMode,
location: matchedCity || fields.location,
time: fields.time,
lodgingAmount: result?.hotel_amount,
allowanceAmount: result?.allowance_amount
})
const policyTransportEstimate = buildTransportEstimateFromPolicyResult(result, fields)
if (policyTransportEstimate) {
const lodging = parseApplicationEstimateMoney(result?.hotel_amount)
const allowance = parseApplicationEstimateMoney(result?.allowance_amount)
const backendTotal = parseApplicationEstimateMoney(result?.total_amount)
const totalAmount = backendTotal > 0
? backendTotal
: policyTransportEstimate.amount + lodging + allowance
systemEstimate = {
transportEstimate: policyTransportEstimate,
transportAmount: policyTransportEstimate.amount,
lodgingAmount: lodging,
allowanceAmount: allowance,
totalAmount,
transportAmountDisplay: policyTransportEstimate.amountDisplay,
lodgingAmountDisplay: formatApplicationEstimateMoney(lodging),
allowanceAmountDisplay: formatApplicationEstimateMoney(allowance),
totalAmountDisplay: formatApplicationEstimateMoney(totalAmount)
}
}
const transportEstimate = systemEstimate.transportEstimate
const transportText = transportEstimate
? `交通 ${systemEstimate.transportAmountDisplay}元 + `
@@ -621,22 +968,22 @@ export function applyApplicationPolicyEstimateError(preview = {}, error = null,
}
export function shouldUseLocalApplicationPreview(rawText, options = {}) {
if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) return false
if (options.systemGenerated || options.reviewAction || Number(options.attachmentCount || 0) > 0) return false
const compact = compactText(rawText)
if (!compact || APPLICATION_QUERY_PATTERN.test(compact)) return false
return APPLICATION_CREATE_PATTERN.test(compact)
return evaluateLocalApplicationIntentGate(rawText, options).allowed
}
export function normalizeApplicationPreview(preview = {}) {
const fields = ensureApplicationPolicyFields(preview?.fields || {})
const missingFields = buildMissingFields(fields)
const validationIssues = [
...resolveApplicationValidationIssues(fields),
...resolveApplicationSourceValidationIssues(preview?.sourceText, fields, preview)
]
return {
...preview,
fields,
missingFields,
readyToSubmit: missingFields.length === 0
validationIssues,
readyToSubmit: missingFields.length === 0 && validationIssues.length === 0
}
}
@@ -688,6 +1035,16 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields),
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
transportEstimatedAmount: normalizeTypedOntologyAmount(
ontologyFields.transportEstimatedAmount || ontologyFields.trainEstimatedAmount || ontologyFields.flightEstimatedAmount,
currentFields.transportEstimatedAmount
),
trainEstimatedAmount: normalizeTypedOntologyAmount(ontologyFields.trainEstimatedAmount, currentFields.trainEstimatedAmount),
flightEstimatedAmount: normalizeTypedOntologyAmount(ontologyFields.flightEstimatedAmount, currentFields.flightEstimatedAmount),
hotelAmount: normalizeTypedOntologyAmount(ontologyFields.hotelAmount, currentFields.hotelAmount),
allowanceAmount: normalizeTypedOntologyAmount(ontologyFields.allowanceAmount, currentFields.allowanceAmount),
policyTotalAmount: normalizeTypedOntologyAmount(ontologyFields.policyTotalAmount, currentFields.policyTotalAmount),
reimbursementAmount: normalizeTypedOntologyAmount(ontologyFields.reimbursementAmount, currentFields.reimbursementAmount),
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
department: resolveProvidedValue(ontologyFields.department, currentFields.department || resolveCurrentUserDepartment(currentUser)),
@@ -827,6 +1184,10 @@ export function buildLocalApplicationPreviewMessage(preview) {
export function buildApplicationPreviewFooterMessage(preview) {
const normalized = normalizeApplicationPreview(preview)
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
const validationIssues = Array.isArray(normalized.validationIssues) ? normalized.validationIssues : []
if (validationIssues.length) {
return `${validationIssues[0].message} 请先修正后再提交申请。`
}
if (missingFields.length) {
return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。`
}

File diff suppressed because it is too large Load Diff

View File

@@ -125,7 +125,7 @@ export function buildStewardFieldCompletionRawText({
'已识别信息:',
...knownLines,
'',
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。',
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。',
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
].filter((line) => line !== '').join('\n')
}

View File

@@ -508,13 +508,6 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
group?.attachmentNames?.length ? `相关附件:${group.attachmentNames.join('、')}` : '',
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
actionType === 'confirm_create_application'
? missingFields
? '请先追问上述缺失信息,不要直接生成申请单核对表,也不要替用户默认填写。'
: '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: missingFields
? '请先追问上述缺失信息,不要直接生成报销核对结果,也不要替用户默认填写。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
]
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
if (remainingTaskText) {

View File

@@ -0,0 +1,103 @@
export const STEWARD_TYPEWRITER_TEXT_CHUNK_SIZE = 3
export const STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE = 2
export function resolveStewardTypewriterNextIndex(chars = [], index = 0) {
const total = chars.length
const safeIndex = Math.max(0, Math.min(Number(index) || 0, total))
const tableStart = resolveMarkdownTableStart(chars, safeIndex)
if (tableStart >= 0) {
return resolveMarkdownTableBlockEnd(chars, tableStart)
}
const chunkSize = resolveStewardTypewriterChunkSize(chars, safeIndex)
const nextIndex = Math.min(total, safeIndex + chunkSize)
const crossedTableStart = findMarkdownTableLineStart(chars, safeIndex, nextIndex)
return crossedTableStart >= 0 ? crossedTableStart : nextIndex
}
function resolveStewardTypewriterChunkSize(chars = [], index = 0) {
const line = resolveCurrentTypewriterLine(chars, index)
const trimmed = line.trim()
if (!trimmed) return STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE
if (/^(#{1,6}\s+|[-*]\s+|\d+\.\s+)/.test(trimmed)) {
return STEWARD_TYPEWRITER_STRUCTURED_CHUNK_SIZE
}
return STEWARD_TYPEWRITER_TEXT_CHUNK_SIZE
}
function resolveCurrentTypewriterLine(chars = [], index = 0) {
const safeIndex = Math.max(0, Math.min(Number(index) || 0, chars.length))
let start = safeIndex
while (start > 0 && chars[start - 1] !== '\n') {
start -= 1
}
let end = safeIndex
while (end < chars.length && chars[end] !== '\n') {
end += 1
}
return chars.slice(start, end).join('')
}
function resolveMarkdownTableStart(chars = [], index = 0) {
const currentLineStart = resolveCurrentLineStart(chars, index)
if (isMarkdownTableLine(resolveLine(chars, currentLineStart).trim())) {
return currentLineStart
}
if (chars[index] === '\n') {
const nextLineStart = index + 1
if (isMarkdownTableLine(resolveLine(chars, nextLineStart).trim())) {
return nextLineStart
}
}
return -1
}
function resolveMarkdownTableBlockEnd(chars = [], tableStart = 0) {
let cursor = tableStart
let blockEnd = tableStart
while (cursor < chars.length) {
const line = resolveLine(chars, cursor)
if (!isMarkdownTableLine(line.trim())) {
break
}
const lineEnd = cursor + line.length
blockEnd = chars[lineEnd] === '\n' ? lineEnd + 1 : lineEnd
cursor = blockEnd
}
return blockEnd
}
function findMarkdownTableLineStart(chars = [], start = 0, end = 0) {
const safeStart = Math.max(0, start)
const safeEnd = Math.min(chars.length, Math.max(safeStart, end))
for (let index = safeStart; index < safeEnd; index += 1) {
if (index !== 0 && chars[index - 1] !== '\n') continue
if (isMarkdownTableLine(resolveLine(chars, index).trim())) {
return index
}
}
return -1
}
function resolveCurrentLineStart(chars = [], index = 0) {
let start = Math.max(0, Math.min(Number(index) || 0, chars.length))
while (start > 0 && chars[start - 1] !== '\n') {
start -= 1
}
return start
}
function resolveLine(chars = [], start = 0) {
let end = Math.max(0, Math.min(Number(start) || 0, chars.length))
while (end < chars.length && chars[end] !== '\n') {
end += 1
}
return chars.slice(start, end).join('')
}
function isMarkdownTableLine(line = '') {
if (!line.includes('|')) return false
if (/^\|?[\s:|-]+\|[\s:|-]+/.test(line)) return true
return /^\|.+\|$/.test(line) || line.split('|').length >= 3
}

View File

@@ -0,0 +1,322 @@
import {
DATE_INPUT_FORMAT,
buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel,
buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel,
cloneReviewEditFields,
createEmptyInlineReviewState,
formatAmountDisplay,
formatReviewSceneDisplayValue,
isTravelReviewPayload as isTravelReviewPayloadModel,
normalizeReviewRiskLevel,
buildReviewAttachmentStatus,
resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel,
shouldShowReviewFactCard
} from './travelReimbursementReviewModel.js'
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
const REVIEW_PANEL_SCOPE_RISK = 'risk'
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
const REVIEW_RISK_LEVEL_META = {
high: {
label: '高风险',
icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
},
medium: {
label: '中风险',
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
},
low: {
label: '低风险',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
}
}
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
export function normalizeReviewPanelScope(scope) {
const normalized = String(scope || '').trim()
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
? normalized
: ''
}
export function canExposeReviewPanelScope(scope) {
return Boolean(normalizeReviewPanelScope(scope))
}
export function buildBusinessTimeContextFromReviewValues(values = {}) {
return buildBusinessTimeContextFromReviewValuesModel(values)
}
export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState)
}
export function buildReviewCorrectionMessage(fields) {
const lines = ['请按以下核对后的报销信息更新当前识别结果:']
for (const item of cloneReviewEditFields(fields)) {
if (!item.label || (!item.value && !item.required)) {
continue
}
lines.push(`${item.label}${String(item.value || '').trim() || '待补充'}`)
}
return lines.join('\n')
}
export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
return isTravelReviewPayloadModel(reviewPayload, inlineState)
}
export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText)
}
export function resolveReviewRiskBriefs(reviewPayload) {
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
return reviewPayload.risk_briefs.filter((item) => {
const title = String(item?.title || '').trim()
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
})
}
export function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0))
const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0))
const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount)
const attachmentStatus =
pendingAttachmentCount > 0
? existingAttachmentCount > 0
? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount}`
: `待保存 ${pendingAttachmentCount}`
: totalAttachmentCount > 0
? `已上传 ${totalAttachmentCount}`
: buildReviewAttachmentStatus(reviewPayload)
if (isTravelReviewPayload(reviewPayload, inlineState)) {
return [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'transport_type',
label: '交通类型',
value: String(inlineState.transport_type || '').trim() || '待确认',
icon: 'mdi mdi-train-car',
editor: 'text',
modelKey: 'transport_type',
placeholder: '例如 火车/高铁、飞机'
},
{
key: 'hotel_name',
label: '酒店名称',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-bed-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店名称'
},
{
key: 'travel_purpose',
label: '出差事宜',
value: String(inlineState.reason_value || '').trim() || '待补充',
icon: 'mdi mdi-briefcase-edit-outline',
editor: 'textarea',
modelKey: 'reason_value',
placeholder: '请填写本次出差的具体工作内容或业务意图',
wide: true
}
]
}
const cards = [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'scene',
label: '场景 / 事由',
value: formatReviewSceneDisplayValue(inlineState),
icon: 'mdi mdi-silverware-fork-knife',
editor: 'select',
modelKey: 'scene_label',
placeholder: '请选择场景'
},
{
key: 'attachments',
label: '票据状态',
value: attachmentStatus,
icon: 'mdi mdi-file-document-outline',
editor: 'upload',
modelKey: 'attachment_names',
placeholder: ''
}
]
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
cards.splice(cards.length - 1, 0, {
key: 'customer_name',
label: '关联客户',
value: String(inlineState.customer_name || '').trim() || '待补充',
icon: 'mdi mdi-domain',
editor: 'text',
modelKey: 'customer_name',
placeholder: '请输入客户名称'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
cards.splice(cards.length - 1, 0, {
key: 'location',
label: '业务地点',
value: String(inlineState.location || '').trim() || '待补充',
icon: 'mdi mdi-map-marker-outline',
editor: 'text',
modelKey: 'location',
placeholder: '请输入业务地点'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) {
cards.splice(cards.length - 1, 0, {
key: 'merchant_name',
label: '酒店/商户',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-storefront-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店或商户名称'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) {
cards.splice(cards.length - 1, 0, {
key: 'participants',
label: '同行人员',
value: String(inlineState.participants || '').trim() || '待补充',
icon: 'mdi mdi-account-group-outline',
editor: 'text',
modelKey: 'participants',
placeholder: '例如 客户 2 人,我方 1 人'
})
}
return cards
}
function normalizeReviewRiskTitle(title, fallbackTitle) {
const normalized = String(title || '').trim()
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
if (!normalized) return fallback
const cleaned = normalized
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
.replace(/(高风险|中风险|低风险)/g, '')
.replace(/^[:\-—\s]+|[:\-—\s]+$/g, '')
.trim()
return cleaned || fallback
}
export function buildReviewRiskItems(reviewPayload) {
return resolveReviewRiskBriefs(reviewPayload)
.map((brief, index) => {
const title = String(brief?.title || '').trim()
const content = String(brief?.content || '').trim()
const detail = String(brief?.detail || '').trim()
const suggestion = String(brief?.suggestion || '').trim()
const level = normalizeReviewRiskLevel(brief?.level)
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
const summary = content || normalizedTitle
if (!normalizedTitle && !summary) return null
return {
key: `${level}-${normalizedTitle}-${index}`,
title: normalizedTitle,
summary,
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
level,
levelLabel: meta.label,
icon: meta.icon,
sourceLabel: meta.label,
suggestion: suggestion || meta.suggestion
}
})
.filter(Boolean)
}
export function buildReviewRiskConversationText(item, detailTarget = {}) {
const title = String(item?.title || '风险提示').trim()
const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim()
const isInfo = String(item?.level || '').trim() === 'info'
const detailHref = String(detailTarget?.href || '').trim()
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
const lines = [`${title}`]
if (summary) {
lines.push('', `${isInfo ? '提示内容' : '风险点'}${summary}`)
}
if (detail && detail !== summary) {
lines.push('', `规则依据:${detail}`)
}
if (suggestion) {
lines.push('', `${isInfo ? '处理建议' : '修改建议'}${suggestion}`)
}
if (detailHref) {
lines.push('', `[${detailLabel}](${detailHref})`)
}
return lines.join('\n')
}
export function buildReviewMainMessageText(message) {
const text = String(message?.text || '')
if (!message?.reviewPayload) {
return text
}
return text
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}

View File

@@ -0,0 +1,255 @@
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE
} from './travelReimbursementConversationModel.js'
import {
buildStewardFieldItems,
formatStewardMissingFieldList,
formatStewardOntologyFields
} from './stewardPlanModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
import { STEWARD_ASSISTANT_NAME } from './travelReimbursementStewardRuntimeTextModel.js'
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 8
const STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5
export function buildStewardContinuationAfterAction({
createMessage,
message,
completedLabel = '当前动作已完成'
}) {
const continuation = message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
if (!remainingTasks.length) {
return null
}
const nextTask = remainingTasks[0]
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const targetSessionType = nextTaskType === 'expense_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION
? '继续创建申请单'
: '继续填写报销单'
const restTasks = remainingTasks.slice(1)
return createMessage(
'assistant',
[
`**${completedLabel}。**`,
'',
'我会重新检查剩余任务队列。',
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}`,
'请回复“确定”,我再继续执行。'
].join('\n'),
[],
{
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '等待用户确认'],
suggestedActions: [
{
label: nextLabel,
description: '确认后小财管家继续调用对应助手完成下一步。',
icon: targetSessionType === SESSION_TYPE_APPLICATION
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
auto_submit: true,
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
steward_current_task: nextTask,
steward_remaining_tasks: restTasks
}
}
]
}
)
}
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
return {
planId: planId || `steward-followup-${Date.now()}`,
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function extractStewardCarryLine(text = '', label = '') {
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[:]([^\\n]+)`, 'u'))
return match ? match[1].trim() : ''
}
function extractStewardFollowupNextTitle(text = '') {
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[:]([^。\n]+)/u)
if (taskMatch?.[1]) {
return taskMatch[1].trim()
}
const nextMatch = String(text || '').match(/下一步[:]([^。\n]+)/u)
return nextMatch?.[1]?.trim() || '下一项财务任务'
}
function buildStewardFollowupThinkingEvents(finalMessage = null, actions = []) {
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const firstAction = Array.isArray(actions) ? actions[0] : null
const actionPayload = firstAction?.payload && typeof firstAction.payload === 'object' ? firstAction.payload : {}
const carryText = String(actionPayload.carry_text || '').trim()
const finalText = String(finalMessage?.text || '').trim()
const nextTitle = extractStewardFollowupNextTitle(carryText || finalText)
const nextSummary = extractStewardCarryLine(carryText, '任务摘要')
const nextMissing = extractStewardCarryLine(carryText, '还需要补充')
return [
{
eventId: `${eventPrefix}-review`,
title: '复盘结果',
content: finalText.includes('申请单已完成')
? '申请单已经完成,我把当前出差申请标记为已处理,不会重复创建。'
: '当前动作已经完成,我会把已完成事项从任务队列中移除。'
},
{
eventId: `${eventPrefix}-next`,
title: '读取剩余任务',
content: nextSummary
? `剩余队列里的下一项是“${nextTitle}”:${nextSummary}`
: `剩余队列里的下一项是“${nextTitle}”。`
},
{
eventId: `${eventPrefix}-gate`,
title: '判断下一步条件',
content: nextMissing
? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。`
: '我会先等你确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。'
}
]
}
function waitStewardFollowupTick(intervalMs) {
return new Promise((resolve) => {
window.setTimeout(resolve, intervalMs)
})
}
export async function pushStewardContinuationMessage({
finalMessage,
messages,
nextTick,
persistSessionState,
scrollToBottom
}) {
if (!finalMessage) {
return
}
const finalText = String(finalMessage.text || '')
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const finalActions = Array.isArray(finalMessage.suggestedActions)
? finalMessage.suggestedActions
: []
finalMessage.text = ''
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
finalMessage.suggestedActions = []
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
messages.value.push(finalMessage)
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
for (const eventData of buildStewardFollowupThinkingEvents(finalMessage, finalActions)) {
const event = {
eventId: eventData.eventId,
stage: 'steward_followup',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(eventData.content)
for (let index = 0; index < chars.length;) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
index = Math.min(chars.length, index + STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE)
event.content = chars.slice(0, index).join('')
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
if (index % STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
event.content = eventData.content
event.status = 'completed'
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
}
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
const chars = Array.from(finalText)
for (let index = 0; index < chars.length;) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
index = resolveStewardTypewriterNextIndex(chars, index)
finalMessage.text = chars.slice(0, index).join('')
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
nextTick(scrollToBottom)
}
finalMessage.text = finalText
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
finalMessage.suggestedActions = finalActions
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
}
export function buildStewardContinuationCarryText(task, restTasks = []) {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
const missingFields = formatStewardMissingFieldList(
task?.missing_fields || task?.missingFields || [],
taskType,
{ includeHints: false }
)
const lines = [
taskType === 'expense_application'
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}`
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}`,
task.summary ? `任务摘要:${task.summary}` : '',
fields ? `已识别信息:${fields}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
missingFields
? '请先追问上述缺失信息,不要直接生成核对结果,也不要替用户默认填写。'
: '请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
]
if (restTasks.length) {
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
restTasks.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
})
}
return lines.filter(Boolean).join('\n')
}
export function resolveStewardMissingFieldItems(task) {
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) {
return task.missingFieldItems
}
const fields = task?.missingFields || task?.missing_fields || []
const taskType = String(task?.taskType || task?.task_type || '').trim()
return buildStewardFieldItems(fields, taskType)
}

View File

@@ -0,0 +1,77 @@
export const STEWARD_ASSISTANT_NAME = '小财管家'
export const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
const APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN = /^(确认|确定|确认提交|确定提交|提交|提交审批|确认审批|确认无误|核对无误|信息无误|无误|没问题|可以提交|确认进入审批|提交至审批流程|确认提交审批|同意提交)$/
const APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN = /^(不|否|否定|取消|暂不|先不|不确认|不提交|再检查|再看看|等等|等一下)/
const STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN = /^(继续|继续执行|下一步|继续下一步|开始下一步|处理下一项|继续处理|确认开始|确定开始|可以|好的|好|行)$/
const STEWARD_RUNTIME_CANCEL_TEXT_PATTERN = /^(取消|暂不|先不|不用|不要|不继续|不处理|先等等|等一下|停止|终止|算了)$/
const STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH = 12
const STEWARD_RUNTIME_BUSINESS_HINT_PATTERN = /(申请|报销|出差|差旅|招待|交通费|住宿费|餐费|发票|票据|费用|预算|借款|付款|审批|审核)/
const STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN = /(今天|明天|后天|昨天|前天|\d{1,2}月\d{1,2}日|\d{4}-\d{1,2}-\d{1,2}|我要|帮我|需要|创建|填写|处理|去|前往)/
const STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN = /(当前|这个|这一步|上面|上述|申请单|核对表|出行方式|交通方式|火车|高铁|动车|飞机|轮船|提交|审批|确认)/
export function isApplicationSubmitConfirmationText(value = '') {
const normalized = String(value || '')
.replace(/\s+/g, '')
.replace(/[,。.!?;:]/g, '')
if (!normalized || APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN.test(normalized)) {
return false
}
return APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN.test(normalized)
}
export function normalizeStewardRuntimeInputText(value = '') {
return String(value || '')
.replace(/\s+/g, '')
.replace(/[,。.!?;:]/g, '')
.trim()
}
export function isStewardRuntimeContinueText(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
return Boolean(normalized && STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN.test(normalized))
}
export function isStewardRuntimeCancelText(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
return Boolean(normalized && STEWARD_RUNTIME_CANCEL_TEXT_PATTERN.test(normalized))
}
export function resolveStewardRuntimeTransportAlias(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
if (!normalized) {
return ''
}
const matchedModes = []
if (/火车|高铁|动车|列车|铁路/.test(normalized)) {
matchedModes.push('火车')
}
if (/飞机|机票|航班|航空/.test(normalized)) {
matchedModes.push('飞机')
}
if (/轮船|船票|客轮|渡轮|坐船/.test(normalized)) {
matchedModes.push('轮船')
}
return matchedModes.length === 1 ? matchedModes[0] : ''
}
export function shouldPlanNewStewardTasksLocally(rawText = '', runtimeState = {}) {
const text = String(rawText || '').trim()
const normalized = normalizeStewardRuntimeInputText(text)
if (
normalized.length < STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH ||
isApplicationSubmitConfirmationText(normalized) ||
isStewardRuntimeContinueText(normalized) ||
isStewardRuntimeCancelText(normalized)
) {
return false
}
if (!STEWARD_RUNTIME_BUSINESS_HINT_PATTERN.test(text) || !STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN.test(text)) {
return false
}
const waitingFor = String(runtimeState?.waiting_for || '').trim()
if (waitingFor && STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN.test(text)) {
return false
}
return true
}

View File

@@ -11,7 +11,6 @@ import {
resolveApplicationDaysFromDateRange,
refreshApplicationPreviewTransportEstimate
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import {
buildWorkbenchDateLabel,
canApplyWorkbenchDateSelection,
@@ -210,7 +209,6 @@ export function useApplicationPreviewEditor({
)
})
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
const transportMode = String(nextPreview.fields?.transportMode || '').trim()
message.applicationPreview = needRefreshEstimate
? buildTransportEstimatePendingPreview(nextPreview)
: nextPreview
@@ -218,13 +216,6 @@ export function useApplicationPreviewEditor({
cancelApplicationPreviewEditor()
persistSessionState?.()
if (needRefreshEstimate) {
if (transportMode) {
await waitForMockApplicationTransportQuote({
transportMode,
location: nextPreview.fields.matchedCity || nextPreview.fields.location,
time: nextPreview.fields.time
})
}
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
message.applicationPreview = refreshedPreview
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)

View File

@@ -4,11 +4,11 @@ import {
buildStewardSuggestedActions,
normalizeStewardPlan
} from './stewardPlanModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
const STEWARD_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
export function useStewardPlanFlow({
@@ -176,7 +176,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) {
return
}
index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
index = resolveStewardTypewriterNextIndex(chars, index)
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
@@ -188,9 +188,7 @@ export function useStewardPlanFlow({
...normalizedPlan,
streamStatus: 'typing'
}
if (index % 4 === 0 || index === total) {
nextTick(scrollToBottom)
}
nextTick(scrollToBottom)
}
const message = messages.value.find((item) => item.id === messageId)

View File

@@ -0,0 +1,241 @@
import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
import {
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
MAX_ATTACHMENTS,
VISIBLE_ATTACHMENT_CHIPS,
buildReviewFilePreviewsFromReviewPayload
} from './travelReimbursementAttachmentModel.js'
import {
SESSION_TYPE_EXPENSE,
createMessage,
buildWelcomeInsight
} from './travelReimbursementConversationModel.js'
export function useTravelReimbursementCreateViewLifecycle({
activeFlowSteps,
activeReviewPanelScope,
activeReviewPayload,
activeSessionType,
adjustComposerTextareaHeight,
attachedFiles,
clearExpenseSessionForDeletedClaim,
clearStewardThinkingTimers,
closeAfterBusy,
composerDraft,
composerFilesExpanded,
composerUploadIntent,
conversationId,
currentInsight,
currentUser,
draftClaimId,
guidedFlowState,
handleComposerDatePickerOutside,
hasInsightPanelContent,
insightPanelCollapsed,
linkedRequest,
maybeFinalizeDeferredClose,
mergeFilesWithLimit,
messages,
persistSessionState,
props,
rememberFilePreviews,
resetReviewDrawerFromPayload,
resolveActiveClaimId,
restorePersistedDraftAttachmentPreviews,
reviewDocumentDrawerAvailable,
reviewDrawerMode,
reviewFilePreviews,
reviewFlowDrawerAvailable,
reviewRiskDrawerAvailable,
scrollToBottom,
startFlowTick,
stopAttachmentRuntime,
stopFlowRuntime,
submitComposer,
toast,
workbenchVisible,
REVIEW_DRAWER_MODE_DOCUMENTS,
REVIEW_DRAWER_MODE_FLOW,
REVIEW_DRAWER_MODE_REVIEW,
REVIEW_DRAWER_MODE_RISK,
SESSION_TYPE_EXPENSE: sessionTypeExpense = SESSION_TYPE_EXPENSE
}) {
watch(
() => [activeReviewPayload.value, activeReviewPanelScope.value],
([payload]) => {
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
const shouldKeepFlowDrawer = reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && activeFlowSteps.value.length > 0
resetReviewDrawerFromPayload(payload)
if (shouldKeepFlowDrawer) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
}
},
{ immediate: true }
)
watch(
() => hasInsightPanelContent.value,
(available) => {
if (!available) {
insightPanelCollapsed.value = false
}
}
)
watch(
() => reviewDocumentDrawerAvailable.value,
(available) => {
if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
}
}
)
watch(
() => reviewRiskDrawerAvailable.value,
(available) => {
if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
}
}
)
watch(
() => reviewFlowDrawerAvailable.value,
(available) => {
if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
}
}
)
watch(
() => [activeSessionType.value, activeFlowSteps.value.length],
([, activeCount], [, previousActiveCount] = []) => {
if (activeCount <= 0 || previousActiveCount > 0) {
return
}
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
insightPanelCollapsed.value = false
}
)
watch(
() => composerDraft.value,
() => {
nextTick(adjustComposerTextareaHeight)
}
)
watch(
() => ({
sessionType: activeSessionType.value,
conversationId: conversationId.value,
draftClaimId: draftClaimId.value,
messages: messages.value,
currentInsight: currentInsight.value,
reviewFilePreviews: reviewFilePreviews.value,
composerDraft: composerDraft.value,
composerUploadIntent: composerUploadIntent.value,
guidedFlowState: guidedFlowState.value,
insightPanelCollapsed: insightPanelCollapsed.value
}),
() => {
persistSessionState()
},
{ deep: true }
)
watch(
() => [activeSessionType.value, resolveActiveClaimId()],
([sessionType, claimId]) => {
if (sessionType !== sessionTypeExpense || !claimId) {
return
}
void restorePersistedDraftAttachmentPreviews(claimId)
},
{ immediate: true }
)
watch(
() => props.invalidatedDraftClaimId,
(claimId) => {
clearExpenseSessionForDeletedClaim(claimId)
},
{ immediate: true }
)
watch(
() => workbenchVisible.value,
(visible) => {
if (visible) {
scrollToBottom()
} else {
maybeFinalizeDeferredClose()
}
}
)
watch(
() => props.reopenToken,
(token, previousToken) => {
if (token === previousToken) {
return
}
closeAfterBusy.value = false
workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
)
onMounted(() => {
document.addEventListener('click', handleComposerDatePickerOutside)
startFlowTick()
nextTick(() => {
workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
})
currentInsight.value =
currentInsight.value
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
if (props.initialApplicationPreview && typeof props.initialApplicationPreview === 'object') {
const applicationPreview = normalizeApplicationPreview(props.initialApplicationPreview)
messages.value.push(createMessage('assistant', buildLocalApplicationPreviewMessage(applicationPreview), [], {
meta: ['修改申请'],
applicationPreview
}))
persistSessionState()
}
if (props.initialPrompt?.trim() || props.initialFiles.length) {
const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS)
composerDraft.value = props.initialPrompt.trim()
attachedFiles.value = initialMerge.files
composerFilesExpanded.value = initialMerge.files.length > VISIBLE_ATTACHMENT_CHIPS
if (initialMerge.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
nextTick(() => {
adjustComposerTextareaHeight()
})
if (props.initialPromptAutoSubmit !== false) {
submitComposer()
}
}
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleComposerDatePickerOutside)
clearStewardThinkingTimers()
stopFlowRuntime()
stopAttachmentRuntime()
})
}

View File

@@ -0,0 +1,145 @@
import { computed } from 'vue'
export function useTravelReimbursementCreateViewUi(ctx) {
const messageItemUi = computed(() => ({
ASSISTANT_DISPLAY_NAME: ctx.ASSISTANT_DISPLAY_NAME,
aiAvatar: ctx.aiAvatar,
userAvatar: ctx.userAvatar,
submitting: ctx.submitting.value,
reviewActionBusy: ctx.reviewActionBusy.value,
sessionSwitchBusy: ctx.sessionSwitchBusy.value,
applicationPreviewEditor: ctx.applicationPreviewEditor.value,
buildMessageBubbleClass: ctx.buildMessageBubbleClass,
resolveStewardMissingFieldItems: ctx.resolveStewardMissingFieldItems,
buildReviewMainMessageText: ctx.buildReviewMainMessageText,
renderMarkdown: ctx.renderMarkdown,
handleAssistantMarkdownClick: ctx.handleAssistantMarkdownClick,
resolveApplicationPreviewRows: ctx.resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl: ctx.resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions: ctx.resolveApplicationPreviewEditorOptions,
resolveApplicationPreviewMissingFields: ctx.resolveApplicationPreviewMissingFields,
isApplicationPreviewEditing: ctx.isApplicationPreviewEditing,
isApplicationPreviewDateEditorOpen: ctx.isApplicationPreviewDateEditorOpen,
openApplicationPreviewEditor: ctx.openApplicationPreviewEditorFromUi,
commitApplicationPreviewEditor: ctx.commitApplicationPreviewEditor,
commitApplicationPreviewDateEditor: ctx.commitApplicationPreviewDateEditor,
setApplicationPreviewDateMode: ctx.setApplicationPreviewDateMode,
canApplyApplicationPreviewDateSelection: ctx.canApplyApplicationPreviewDateSelection,
handleApplicationPreviewEditorKeydown: ctx.handleApplicationPreviewEditorKeydown,
buildApplicationPreviewFooterText: ctx.buildApplicationPreviewFooterText,
isApplicationDraftPayload: ctx.isApplicationDraftPayload,
resolveApplicationDraftStatusLabel: ctx.resolveApplicationDraftStatusLabel,
buildApplicationDraftSummaryItems: ctx.buildApplicationDraftSummaryItems,
shouldShowDraftSavedCard: ctx.shouldShowDraftSavedCard,
canOpenDraftDetail: ctx.canOpenDraftDetail,
resolveReimbursementDraftClaimNo: ctx.resolveReimbursementDraftClaimNo,
openApplicationDraftDetail: ctx.openApplicationDraftDetail,
shouldShowAssistantMessageActions: ctx.shouldShowAssistantMessageActions,
copyAssistantMessage: ctx.copyAssistantMessage,
speakAssistantMessage: ctx.speakAssistantMessage,
isMessageFeedbackSelected: ctx.isMessageFeedbackSelected,
submitOperationFeedbackForMessage: ctx.submitOperationFeedbackForMessage,
runWelcomeQuickAction: ctx.runShortcut,
handleSuggestedAction: ctx.handleSuggestedAction,
isSuggestedActionSelected: ctx.isSuggestedActionSelected,
buildExpenseQueryWindowLabel: ctx.buildExpenseQueryWindowLabel,
buildExpenseQueryHint: ctx.buildExpenseQueryHint,
getExpenseQueryActivePage: ctx.getExpenseQueryActivePage,
getExpenseQueryTotalPages: ctx.getExpenseQueryTotalPages,
getExpenseQueryVisibleRecords: ctx.getExpenseQueryVisibleRecords,
handleExpenseQueryRecordClick: ctx.handleExpenseQueryRecordClick,
appendExpenseQueryRiskToConversation: ctx.appendExpenseQueryRiskToConversation,
shiftExpenseQueryPage: ctx.shiftExpenseQueryPage,
setExpenseQueryPage: ctx.setExpenseQueryPage,
buildReviewPlainFollowupForMessage: ctx.buildReviewPlainFollowupForMessage,
canUseInlineSaveDraft: ctx.canUseInlineSaveDraft,
handleInlineSaveDraft: ctx.handleInlineSaveDraft,
buildReviewNextStepRichCopyForMessage: ctx.buildReviewNextStepRichCopyForMessage,
resolveReviewFooterActions: ctx.resolveReviewFooterActions,
handleReviewAction: ctx.handleReviewAction,
buildReviewPrimaryButtonLabel: ctx.buildReviewPrimaryButtonLabel
}))
const insightPanelUi = computed(() => ({
showInsightPanel: ctx.showInsightPanel.value,
isKnowledgeSession: ctx.isKnowledgeSession.value,
activeReviewPayload: ctx.activeReviewPayload.value,
isReviewFlowDrawer: ctx.isReviewFlowDrawer.value,
currentInsight: ctx.currentInsight.value,
currentIntentLabel: ctx.currentIntentLabel.value,
reviewDrawerTitle: ctx.reviewDrawerTitle.value,
reviewOverviewDrawerAvailable: ctx.reviewOverviewDrawerAvailable.value,
isReviewOverviewDrawer: ctx.isReviewOverviewDrawer.value,
submitting: ctx.submitting.value,
reviewActionBusy: ctx.reviewActionBusy.value,
switchToReviewOverviewDrawer: ctx.switchToReviewOverviewDrawer,
reviewDocumentDrawerAvailable: ctx.reviewDocumentDrawerAvailable.value,
isReviewDocumentDrawer: ctx.isReviewDocumentDrawer.value,
toggleReviewDocumentDrawer: ctx.toggleReviewDocumentDrawer,
reviewDocumentDrawerIcon: ctx.reviewDocumentDrawerIcon.value,
reviewRiskDrawerAvailable: ctx.reviewRiskDrawerAvailable.value,
isReviewRiskDrawer: ctx.isReviewRiskDrawer.value,
toggleReviewRiskDrawer: ctx.toggleReviewRiskDrawer,
reviewRiskDrawerIcon: ctx.reviewRiskDrawerIcon.value,
reviewFlowDrawerAvailable: ctx.reviewFlowDrawerAvailable.value,
flowOverallStatusTone: ctx.flowOverallStatusTone.value,
toggleReviewFlowDrawer: ctx.toggleReviewFlowDrawer,
reviewFlowDrawerIcon: ctx.reviewFlowDrawerIcon.value,
activeSessionType: ctx.activeSessionType.value,
reviewDrawerMode: ctx.reviewDrawerMode.value,
hotKnowledgeQuestions: ctx.hotKnowledgeQuestions,
deleteSessionBusy: ctx.deleteSessionBusy.value,
sessionSwitchBusy: ctx.sessionSwitchBusy.value,
askHotKnowledgeQuestion: ctx.askHotKnowledgeQuestion,
resolveKnowledgeRankTone: ctx.resolveKnowledgeRankTone,
resolveKnowledgeRankLabel: ctx.resolveKnowledgeRankLabel,
flowOverallStatusText: ctx.flowOverallStatusText.value,
flowTotalDurationText: ctx.flowTotalDurationText.value,
flowRunId: ctx.flowRunId.value,
flowRefreshBusy: ctx.flowRefreshBusy.value,
refreshFlowRunDetail: ctx.refreshFlowRunDetail,
flowSteps: ctx.activeFlowSteps.value,
visibleFlowSteps: ctx.visibleFlowSteps.value,
resolveFlowStepStatusLabel: ctx.resolveFlowStepStatusLabel,
formatFlowStepDuration: ctx.formatFlowStepDuration,
resolveFlowStepDetail: ctx.resolveFlowStepDetail,
reviewIntentText: ctx.reviewIntentText.value,
reviewFactCards: ctx.reviewFactCards.value,
reviewInlineEditorKey: ctx.reviewInlineEditorKey.value,
reviewInlineErrors: ctx.reviewInlineErrors.value,
reviewInlineForm: ctx.reviewInlineForm.value,
DATE_INPUT_FORMAT: ctx.DATE_INPUT_FORMAT,
REVIEW_SCENE_OPTIONS: ctx.REVIEW_SCENE_OPTIONS,
REVIEW_SCENE_OTHER_OPTION: ctx.REVIEW_SCENE_OTHER_OPTION,
clearInlineReviewFieldError: ctx.clearInlineReviewFieldError,
commitInlineReviewEditor: ctx.commitInlineReviewEditor,
selectInlineScene: ctx.selectInlineScene,
reviewInlinePendingFiles: ctx.reviewInlinePendingFiles.value,
openInlineReviewEditor: ctx.openInlineReviewEditor,
reviewPanelConfidence: ctx.reviewPanelConfidence.value,
reviewCategoryOptions: ctx.reviewCategoryOptions.value,
selectReviewCategory: ctx.selectReviewCategory,
reviewSelectedOtherCategory: ctx.reviewSelectedOtherCategory.value,
reviewOtherCategoryOpen: ctx.reviewOtherCategoryOpen.value,
reviewOtherCategoryOptions: ctx.reviewOtherCategoryOptions.value,
selectReviewOtherCategory: ctx.selectReviewOtherCategory,
activeReviewDocumentIndex: ctx.activeReviewDocumentIndex.value,
reviewDocumentCount: ctx.reviewDocumentCount.value,
goReviewDocument: ctx.goReviewDocument,
activeReviewDocument: ctx.activeReviewDocument.value,
activeReviewDocumentPreview: ctx.activeReviewDocumentPreview.value,
canPreviewActiveReviewDocument: ctx.canPreviewActiveReviewDocument.value,
openActiveReviewDocumentPreview: ctx.openActiveReviewDocumentPreview,
reviewRiskSummary: ctx.reviewRiskSummary.value,
reviewRiskItems: ctx.reviewRiskItems.value,
appendReviewRiskBriefToConversation: ctx.appendReviewRiskBriefToConversation,
reviewRiskEmpty: ctx.reviewRiskEmpty.value,
reviewHasUnsavedChanges: ctx.reviewHasUnsavedChanges.value,
saveInlineReviewChanges: ctx.saveInlineReviewChanges
}))
return {
insightPanelUi,
messageItemUi
}
}

View File

@@ -601,7 +601,7 @@ export function useTravelReimbursementFlow({
startFlowStep('pre-submit-review', {
title: '自动检测与风险识别',
tool: 'ExpenseClaimService.submit_claim',
detail: '正在校验财务规则、风险规则和审批路径...'
detail: '正在校验基础规则、风险规则和审批路径...'
})
}
@@ -847,7 +847,7 @@ export function useTravelReimbursementFlow({
if (String(response.status || '').trim() === 'submitted') {
return isApplicationSessionActive()
? '申请单提交成功'
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
: `已完成基础规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
}
if (response.submission_blocked) {
return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'

View File

@@ -0,0 +1,476 @@
import {
buildOperationFeedbackPayload,
normalizeOperationFeedbackContext
} from '../../composables/useOperationFeedback.js'
import { createOperationFeedback } from '../../services/operationFeedback.js'
import {
buildApplicationPreviewFooterMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
import {
buildReviewNextStepRichCopy,
buildReviewRiskLevelCounts,
resolveReviewNextStepAction
} from './travelReimbursementReviewModel.js'
import { buildReviewRiskConversationText } from './travelReimbursementReviewPanelModel.js'
export function useTravelReimbursementMessageActions({
activeSessionType,
buildMessageActionRows,
conversationId,
createMessage,
currentInsight,
currentUser,
draftClaimId,
emit,
getHandleReviewActionInternal,
latestReviewMessage,
linkedRequest,
messages,
nextStepConfirmDialog,
nextTick,
persistSessionState,
props,
resolveActiveClaimId,
resolveCurrentUserId,
reviewActionBusy,
router,
scrollToBottom,
submitComposer,
submitting,
toast
}) {
function queryDraftByClaimNo(claimNo) {
const normalized = String(claimNo || '').trim()
if (!normalized || submitting.value || reviewActionBusy.value) return
submitComposer({
rawText: `查看报销草稿 ${normalized} 的当前信息`,
userText: `查看草稿 ${normalized}`,
systemGenerated: true
})
}
function appendReviewRiskBriefToConversation(item) {
if (!item) return
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item, resolveReviewRiskDetailTarget()), [], {
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
metaTone: item.level || 'low'
}))
nextTick(scrollToBottom)
}
function appendExpenseQueryRiskToConversation(record, risk) {
if (!record || !risk) return
const claimId = String(record.claimId || '').trim()
const claimNo = String(record.claimNo || '该单据').trim()
const route = claimId
? router.resolve({
name: 'app-document-detail',
params: { requestId: claimId }
})
: null
messages.value.push(createMessage(
'assistant',
buildReviewRiskConversationText(
{
title: `${claimNo} ${risk.levelLabel || '风险提示'}${risk.title || '风险提示'}`,
summary: risk.summary,
detail: risk.detail,
suggestion: '请进入单据详情核对费用明细、票据附件和附加说明;如属于合理例外,请补充业务说明后再继续流程。',
sourceLabel: risk.levelLabel,
level: risk.level
},
route?.href
? {
href: route.href,
label: `进入 ${claimNo} 详情重新填写`
}
: {}
),
[],
{
meta: [`${claimNo} 风险详情`],
metaTone: risk.level || 'medium'
}
))
nextTick(scrollToBottom)
}
function resolveReviewDetailTarget(message = null) {
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
const candidates = [
message?.draftPayload,
currentInsight.value.agent?.draftPayload,
latestReviewMessage.value?.draftPayload,
latestDraftMessage?.draftPayload,
linkedRequest.value
].filter(Boolean)
const claimTarget = candidates.find((item) => String(item?.claim_id || item?.claimId || item?.id || '').trim())
const claimId = String(claimTarget?.claim_id || claimTarget?.claimId || claimTarget?.id || draftClaimId.value || resolveActiveClaimId() || '').trim()
if (!claimId) {
return {}
}
const claimNoTarget = candidates.find((item) => String(item?.claim_no || item?.claimNo || item?.documentNo || '').trim())
const claimNo = String(claimNoTarget?.claim_no || claimNoTarget?.claimNo || claimNoTarget?.documentNo || '').trim()
const route = router.resolve({
name: 'app-document-detail',
params: { requestId: claimId }
})
return {
href: route.href,
label: claimNo ? `进入 ${claimNo} 详情重新填写` : '进入该单据详情重新填写'
}
}
function resolveReviewRiskDetailTarget() {
return resolveReviewDetailTarget()
}
function buildReviewNextStepRichCopyForMessage(message) {
const target = resolveReviewDetailTarget(message)
return buildReviewNextStepRichCopy(message?.reviewPayload, {
detailHref: target.href || ''
})
}
function buildMessageBubbleClass(message) {
if (message?.role === 'assistant' && message?.budgetReport) {
return 'message-bubble-budget-report'
}
if (message?.role === 'assistant' && message?.applicationPreview) {
return 'message-bubble-application-preview'
}
if (message?.role !== 'assistant' || !resolveReviewNextStepAction(message?.reviewPayload)) {
return ''
}
const counts = buildReviewRiskLevelCounts(message.reviewPayload)
if (counts.high > 0) {
return 'message-bubble-review-risk-high'
}
if (counts.medium > 0) {
return 'message-bubble-review-risk-medium'
}
if (counts.low > 0) {
return 'message-bubble-review-risk-low'
}
return ''
}
function openReviewNextStepConfirm(message) {
const action = resolveReviewNextStepAction(message?.reviewPayload)
if (!action) {
return
}
nextStepConfirmDialog.value = {
open: true,
message,
action
}
}
function closeReviewNextStepConfirm() {
if (reviewActionBusy.value) {
return
}
nextStepConfirmDialog.value = {
open: false,
message: null,
action: null
}
}
async function confirmReviewNextStepSubmit() {
const message = nextStepConfirmDialog.value.message
const action = nextStepConfirmDialog.value.action
const handleReviewActionInternal = getHandleReviewActionInternal()
if (!message || !action || reviewActionBusy.value || !handleReviewActionInternal) {
return
}
try {
await handleReviewActionInternal(message, action)
} finally {
nextStepConfirmDialog.value = {
open: false,
message: null,
action: null
}
}
}
function buildApplicationPreviewFooterText(message) {
if (!message?.applicationPreview) {
return ''
}
return buildApplicationPreviewFooterMessage(message.applicationPreview)
}
function isApplicationDraftPayload(draftPayload) {
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
}
function resolveDraftPayloadBodyField(draftPayload, label) {
const body = String(draftPayload?.body || '')
const pattern = new RegExp(`^${label}(.+)$`, 'm')
return String(body.match(pattern)?.[1] || '').trim()
}
function resolveApplicationDraftStatusLabel(draftPayload) {
const status = String(draftPayload?.status || '').trim()
if (status === 'submitted') return '审批中'
return status || '已生成'
}
function buildApplicationDraftSummaryItems(draftPayload) {
if (!isApplicationDraftPayload(draftPayload)) {
return []
}
return [
{ label: '单号', value: String(draftPayload?.claim_no || '').trim() || '待生成' },
{ label: '类型', value: String(draftPayload?.title || '').trim() || '费用申请' },
{ label: '节点', value: String(draftPayload?.approval_stage || '').trim() || '直属领导审批' },
{ label: '时间', value: resolveDraftPayloadBodyField(draftPayload, '发生时间') },
{ label: '费用', value: resolveDraftPayloadBodyField(draftPayload, '用户预估费用') }
].filter((item) => String(item.value || '').trim() && item.value !== '待补充')
}
function shouldShowDraftSavedCard(message) {
const draftPayload = message?.draftPayload || null
return Boolean(
draftPayload
&& (
String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|| String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|| String(draftPayload.title || '').trim()
|| String(draftPayload.body || '').trim()
)
)
}
function canOpenDraftDetail(message) {
const draftPayload = message?.draftPayload || {}
return Boolean(String(draftPayload.claim_id || draftPayload.claimId || '').trim())
}
function resolveReimbursementDraftClaimNo(draftPayload) {
return String(
draftPayload?.claim_no
|| draftPayload?.claimNo
|| draftPayload?.claim_id
|| draftPayload?.claimId
|| ''
).trim() || '保存后生成'
}
function updateMessageOperationFeedback(message, patch = {}) {
if (!message?.id) {
return
}
messages.value = messages.value.map((item) => (
item.id === message.id
? {
...item,
operationFeedback: {
...(item.operationFeedback || {}),
...patch
}
}
: item
))
}
function shouldShowAssistantMessageActions(message) {
return Boolean(
message?.role === 'assistant'
&& (
String(message.text || '').trim()
|| message.applicationPreview
|| message.reviewPayload
|| message.queryPayload
|| message.draftPayload
|| message.budgetReport
)
&& !(message.stewardPlan?.streamStatus === 'streaming' && !String(message.text || '').trim())
)
}
function buildMessageActionText(message) {
const parts = []
const text = String(message?.text || '').trim()
if (text) {
parts.push(text)
}
const actionRows = buildMessageActionRows(message)
if (actionRows.length) {
parts.push(actionRows.join('\n'))
}
if (message?.draftPayload) {
const claimNo = resolveReimbursementDraftClaimNo(message.draftPayload)
if (claimNo) {
parts.push(`单据:${claimNo}`)
}
}
return parts.join('\n\n').trim()
}
async function copyAssistantMessage(message) {
const text = buildMessageActionText(message)
if (!text) {
toast('当前消息没有可复制的内容。')
return
}
try {
if (globalThis.navigator?.clipboard?.writeText) {
await globalThis.navigator.clipboard.writeText(text)
} else {
const textarea = globalThis.document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'readonly')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
globalThis.document.body.appendChild(textarea)
textarea.select()
globalThis.document.execCommand('copy')
globalThis.document.body.removeChild(textarea)
}
toast('已复制。')
} catch (error) {
console.warn('Failed to copy assistant message:', error)
toast('复制失败,请稍后重试。')
}
}
function speakAssistantMessage(message) {
const text = buildMessageActionText(message)
if (!text) {
toast('当前消息没有可播报的内容。')
return
}
const speech = globalThis.speechSynthesis
if (!speech || typeof globalThis.SpeechSynthesisUtterance === 'undefined') {
toast('当前浏览器不支持语音播报。')
return
}
speech.cancel()
const utterance = new globalThis.SpeechSynthesisUtterance(text.slice(0, 1200))
utterance.lang = 'zh-CN'
utterance.rate = 1
speech.speak(utterance)
}
function buildMessageOperationFeedbackContext(message) {
const existingContext = message?.operationFeedback?.context || null
if (existingContext) {
return existingContext
}
const messageId = String(message?.id || '').trim()
const assistantName = String(message?.assistantName || '').trim()
return normalizeOperationFeedbackContext({
run_id: messageId.slice(0, 50) || null,
conversation_id: String(conversationId.value || '').trim(),
user_id: resolveCurrentUserId(),
selected_agent: assistantName || (message?.stewardPlan ? STEWARD_ASSISTANT_NAME : 'user_agent'),
source: 'assistant_message_action',
session_type: activeSessionType.value,
operation_type: message?.stewardPlan ? 'steward_message' : 'assistant_message',
operation_status: 'succeeded',
status: 'succeeded',
entry_source: props.entrySource,
result_summary: buildMessageActionText(message).slice(0, 500)
}, currentUser.value || {})
}
async function submitOperationFeedbackForMessage(message, feedback = {}) {
const rating = Number(feedback.rating || 0)
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
updateMessageOperationFeedback(message, { error: '请选择 1 到 5 星评分。' })
return
}
const context = buildMessageOperationFeedbackContext(message)
if (!context) {
return
}
updateMessageOperationFeedback(message, {
submitting: true,
rating,
reason: String(feedback.reason || '').trim(),
context,
error: ''
})
try {
await createOperationFeedback(
buildOperationFeedbackPayload(context, feedback, currentUser.value || {})
)
updateMessageOperationFeedback(message, {
submitting: false,
submitted: true,
dismissed: false,
rating,
reason: String(feedback.reason || '').trim(),
error: ''
})
persistSessionState()
} catch (error) {
updateMessageOperationFeedback(message, {
submitting: false,
error: error?.message || '评价提交失败,请稍后重试。'
})
}
}
function isMessageFeedbackSelected(message, rating) {
return Boolean(
message?.operationFeedback?.submitted
&& Number(message.operationFeedback.rating || 0) === Number(rating || 0)
)
}
async function openApplicationDraftDetail(message) {
const draftPayload = message?.draftPayload || {}
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
if (!claimId) {
toast('暂未获取到单据 ID稍后可在单据中心查看。')
return
}
await router.push({
name: 'app-document-detail',
params: { requestId: claimId }
})
emit('close')
}
function resolveApplicationPreviewMissingFields(message) {
if (!message?.applicationPreview) {
return []
}
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
return Array.isArray(normalizedPreview.missingFields) ? normalizedPreview.missingFields : []
}
return {
appendExpenseQueryRiskToConversation,
appendReviewRiskBriefToConversation,
buildApplicationDraftSummaryItems,
buildApplicationPreviewFooterText,
buildMessageBubbleClass,
buildReviewNextStepRichCopyForMessage,
canOpenDraftDetail,
closeReviewNextStepConfirm,
confirmReviewNextStepSubmit,
copyAssistantMessage,
isApplicationDraftPayload,
isMessageFeedbackSelected,
openApplicationDraftDetail,
openReviewNextStepConfirm,
queryDraftByClaimNo,
resolveApplicationDraftStatusLabel,
resolveApplicationPreviewMissingFields,
resolveReimbursementDraftClaimNo,
shouldShowAssistantMessageActions,
shouldShowDraftSavedCard,
speakAssistantMessage,
submitOperationFeedbackForMessage
}
}

View File

@@ -0,0 +1,775 @@
import { fetchStewardRuntimeDecision } from '../../services/steward.js'
import {
buildApplicationPreviewSubmitText,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
buildTravelPlanningNudgeMessage,
buildTravelPlanningSuggestedActions
} from '../../utils/travelApplicationPlanning.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_STEWARD
} from './travelReimbursementConversationModel.js'
import {
APPLICATION_PREVIEW_FIELD_ACTION_SET,
STEWARD_ASSISTANT_NAME,
isApplicationSubmitConfirmationText,
isStewardRuntimeCancelText,
isStewardRuntimeContinueText,
normalizeStewardRuntimeInputText,
resolveStewardRuntimeTransportAlias,
shouldPlanNewStewardTasksLocally
} from './travelReimbursementStewardRuntimeTextModel.js'
import {
buildStewardContinuationAfterAction,
pushStewardContinuationMessage,
resolveStewardMissingFieldItems
} from './travelReimbursementStewardFollowupFlow.js'
export { STEWARD_ASSISTANT_NAME } from './travelReimbursementStewardRuntimeTextModel.js'
export function useTravelReimbursementStewardRuntime(ctx) {
const {
activeSessionType,
applicationSubmitConfirmDialog,
attachedFiles,
composerDraft,
createMessage,
currentUser,
emit,
handleSuggestedAction,
isStewardSession,
linkedRequest,
messages,
nextTick,
persistSessionState,
props,
reviewActionBusy,
scrollToBottom,
sessionSwitchBusy,
submitComposer,
submitStewardPlan,
submitting,
toast,
adjustComposerTextareaHeight,
resolveCurrentUserId
} = ctx
function findLatestApplicationPreviewMessage() {
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
!message.applicationPreview ||
message.applicationSubmitConfirmed
) {
continue
}
return message
}
return null
}
function findPendingApplicationSubmitMessage() {
const message = findLatestApplicationPreviewMessage()
if (!message) {
return null
}
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
if (normalizedPreview.readyToSubmit) {
message.applicationPreview = normalizedPreview
return message
}
return null
}
function pushApplicationSubmitBlockedMessage(userText = '', message = null, options = {}) {
const normalizedPreview = normalizeApplicationPreview(message?.applicationPreview || {})
const missingFields = Array.isArray(normalizedPreview.missingFields)
? normalizedPreview.missingFields
: []
const validationIssues = Array.isArray(normalizedPreview.validationIssues)
? normalizedPreview.validationIssues
: []
if (userText && !options.userMessageAlreadyAdded) {
messages.value.push(createMessage('user', userText))
}
messages.value.push(createMessage(
'assistant',
[
'我理解你是在确认当前申请单,但这张申请单还不能提交。',
'',
missingFields.length
? `还需要先补充:**${missingFields.join('、')}**。`
: validationIssues.length
? `需要先修正:**${validationIssues[0].message}**`
: '请先把申请核对表中的待补充信息补齐。',
'',
'补齐后再输入“确认”,我会继续提交至审批流程。'
].join('\n'),
[],
{
assistantName: String(message?.assistantName || '').trim() || undefined,
meta: ['等待补充']
}
))
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
async function handleApplicationSubmitConfirmationText(options = {}) {
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
const files = Array.from(options.files ?? attachedFiles.value ?? [])
if (!isApplicationSubmitConfirmationText(rawText) || files.length) {
return false
}
const latestApplicationMessage = findLatestApplicationPreviewMessage()
if (!latestApplicationMessage) {
return false
}
const targetMessage = findPendingApplicationSubmitMessage()
if (!targetMessage) {
pushApplicationSubmitBlockedMessage(rawText, latestApplicationMessage)
return true
}
applicationSubmitConfirmDialog.value = {
open: true,
message: targetMessage
}
await confirmApplicationSubmit({ userText: rawText })
return true
}
function findPendingStewardSuggestedActionContext(decision = null) {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetTaskId = String(decision?.target_task_id || decision?.targetTaskId || '').trim()
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
if (targetMessageId && String(message.id || '') !== targetMessageId) {
continue
}
const action = message.suggestedActions.find((item) => {
if (String(item?.action_type || '').trim() === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return false
}
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}
return !targetTaskId ||
String(payload.steward_next_task_id || payload.target_task_id || '').trim() === targetTaskId
}) || message.suggestedActions[0]
if (action) {
return { message, action }
}
}
return null
}
function findPendingSlotSuggestedActionContext(decision = null) {
const fieldKey = String(decision?.field_key || decision?.fieldKey || '').trim()
const fieldValue = String(decision?.field_value || decision?.fieldValue || '').trim()
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
const action = message.suggestedActions.find((item) => {
if (String(item?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return false
}
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}
const payloadField = String(payload.field_key || payload.fieldKey || '').trim()
const payloadValue = String(payload.value || item?.label || '').trim()
return payloadField && (!fieldKey || payloadField === fieldKey) && (!fieldValue || payloadValue === fieldValue)
})
if (action) {
return { message, action }
}
}
return null
}
function findPendingSlotSuggestedActionContextByInput(rawText = '') {
const normalizedInput = normalizeStewardRuntimeInputText(rawText)
if (!normalizedInput) {
return null
}
const transportAlias = resolveStewardRuntimeTransportAlias(normalizedInput)
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
const exactMatches = []
const fuzzyMatches = []
message.suggestedActions.forEach((action) => {
if (String(action?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return
}
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const fieldKey = String(payload.field_key || payload.fieldKey || '').trim()
const value = String(payload.value || action?.label || '').trim()
const label = String(action?.label || value).trim()
const tokens = [value, label]
.map((item) => normalizeStewardRuntimeInputText(item))
.filter(Boolean)
if (!fieldKey || !value || !tokens.length) {
return
}
if (tokens.includes(normalizedInput)) {
exactMatches.push({ message, action })
return
}
const actionTransportAlias = resolveStewardRuntimeTransportAlias(`${value}${label}`)
if (
transportAlias &&
(
tokens.includes(normalizeStewardRuntimeInputText(transportAlias)) ||
actionTransportAlias === transportAlias
)
) {
fuzzyMatches.push({ message, action })
return
}
if (tokens.some((token) => token.length >= 2 && normalizedInput.includes(token))) {
fuzzyMatches.push({ message, action })
}
})
if (exactMatches.length === 1) {
return exactMatches[0]
}
if (exactMatches.length > 1) {
return null
}
const uniqueFuzzyMatches = fuzzyMatches.filter((item, index, list) =>
list.findIndex((candidate) => candidate.action === item.action) === index
)
if (uniqueFuzzyMatches.length === 1) {
return uniqueFuzzyMatches[0]
}
if (uniqueFuzzyMatches.length > 1) {
return null
}
}
return null
}
function buildStewardRuntimeState() {
const latestApplicationMessage = findLatestApplicationPreviewMessage()
const applicationPreview = latestApplicationMessage?.applicationPreview
? normalizeApplicationPreview(latestApplicationMessage.applicationPreview)
: null
const applicationContinuation = latestApplicationMessage?.stewardContinuation || null
const pendingSlotContext = findPendingSlotSuggestedActionContext()
const pendingStewardContext = pendingSlotContext ? null : findPendingStewardSuggestedActionContext()
const pendingActionPayload = pendingStewardContext?.action?.payload && typeof pendingStewardContext.action.payload === 'object'
? pendingStewardContext.action.payload
: {}
const pendingSlotPayload = pendingSlotContext?.action?.payload && typeof pendingSlotContext.action.payload === 'object'
? pendingSlotContext.action.payload
: {}
const continuation = applicationContinuation || pendingStewardContext?.message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
const pendingApplication = latestApplicationMessage && applicationPreview
? {
message_id: String(latestApplicationMessage.id || '').trim(),
task_id: String(
applicationContinuation?.currentTaskId ||
applicationContinuation?.current_task_id ||
applicationContinuation?.currentTask?.task_id ||
applicationContinuation?.currentTask?.taskId ||
''
).trim(),
ready_to_submit: Boolean(applicationPreview.readyToSubmit),
missing_fields: Array.isArray(applicationPreview.missingFields) ? applicationPreview.missingFields : [],
fields: applicationPreview.fields || {}
}
: null
return {
waiting_for: pendingApplication
? (pendingApplication.ready_to_submit ? 'application_submit_confirmation' : 'application_field_completion')
: pendingSlotContext
? 'application_field_completion'
: pendingStewardContext
? 'steward_next_task_confirmation'
: '',
current_task: continuation?.currentTask || continuation?.current_task || null,
remaining_tasks: remainingTasks,
completed_tasks: messages.value
.filter((message) => message?.applicationSubmitConfirmed)
.map((message) => ({
message_id: String(message.id || '').trim(),
task_type: 'expense_application'
})),
pending_application: pendingApplication,
pending_steward_action: pendingStewardContext
? {
message_id: String(pendingStewardContext.message?.id || '').trim(),
action_type: String(pendingStewardContext.action?.action_type || '').trim(),
label: String(pendingStewardContext.action?.label || '').trim(),
target_task_id: String(pendingActionPayload.steward_next_task_id || pendingActionPayload.target_task_id || '').trim(),
payload: pendingActionPayload
}
: null,
pending_slot_action: pendingSlotContext
? {
message_id: String(pendingSlotContext.message?.id || '').trim(),
field_key: String(pendingSlotPayload.field_key || pendingSlotPayload.fieldKey || '').trim(),
label: String(pendingSlotContext.action?.label || '').trim(),
payload: pendingSlotPayload
}
: null
}
}
function hasActiveStewardRuntimeDecisionContext(runtimeState = {}) {
return Boolean(
String(runtimeState?.waiting_for || '').trim() ||
runtimeState?.pending_application ||
runtimeState?.pending_steward_action ||
runtimeState?.pending_slot_action ||
runtimeState?.current_task ||
(Array.isArray(runtimeState?.remaining_tasks) && runtimeState.remaining_tasks.length > 0) ||
(Array.isArray(runtimeState?.completed_tasks) && runtimeState.completed_tasks.length > 0)
)
}
function pushStewardRuntimeUserMessage(userText = '') {
const normalizedText = String(userText || '').trim()
if (!normalizedText) {
return false
}
messages.value.push(createMessage('user', normalizedText))
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
return true
}
function pushStewardRuntimeResponse(userText = '', decision = null, options = {}) {
if (userText && !options.userMessageAlreadyAdded) {
messages.value.push(createMessage('user', userText))
}
const text = String(decision?.question || decision?.response_text || decision?.responseText || decision?.rationale || '').trim()
if (text) {
messages.value.push(createMessage('assistant', text, [], {
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME]
}))
}
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
function buildStewardRuntimeFastPathDecision(rawText = '', runtimeState = {}) {
const normalizedText = String(rawText || '').trim()
if (!normalizedText) {
return null
}
if (shouldPlanNewStewardTasksLocally(normalizedText, runtimeState)) {
return {
next_action: 'plan_new_tasks'
}
}
if (isStewardRuntimeCancelText(normalizedText)) {
return {
next_action: 'cancel_current_action',
response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果你要重新规划,请直接告诉我新的财务事项。'
}
}
const slotContext = findPendingSlotSuggestedActionContextByInput(normalizedText)
const payload = slotContext?.action?.payload && typeof slotContext.action.payload === 'object'
? slotContext.action.payload
: {}
if (slotContext) {
return {
next_action: 'fill_current_slot',
target_message_id: String(slotContext.message?.id || '').trim(),
field_key: String(payload.field_key || payload.fieldKey || '').trim(),
field_value: String(payload.value || slotContext.action?.label || normalizedText).trim()
}
}
if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) {
if (runtimeState?.pending_application?.ready_to_submit) {
return {
next_action: 'submit_current_application',
target_message_id: runtimeState.pending_application.message_id || ''
}
}
if (runtimeState?.pending_steward_action) {
return {
next_action: 'continue_next_task',
target_message_id: runtimeState.pending_steward_action.message_id || '',
target_task_id: runtimeState.pending_steward_action.target_task_id || ''
}
}
}
if (String(runtimeState?.waiting_for || '').trim() === 'application_field_completion') {
if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) {
const missingFields = Array.isArray(runtimeState?.pending_application?.missing_fields)
? runtimeState.pending_application.missing_fields
: []
return {
next_action: 'ask_user',
response_text: missingFields.length
? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}。你可以直接回复对应选项或填写具体内容。`
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
}
}
}
return null
}
function shouldUseStewardRuntimeLlmDecision(rawText = '', runtimeState = {}) {
if (shouldPlanNewStewardTasksLocally(rawText, runtimeState)) {
return false
}
const normalizedText = normalizeStewardRuntimeInputText(rawText)
if (!normalizedText) {
return false
}
if (
isApplicationSubmitConfirmationText(normalizedText) ||
isStewardRuntimeContinueText(normalizedText) ||
isStewardRuntimeCancelText(normalizedText)
) {
return false
}
if (
findPendingSlotSuggestedActionContextByInput(normalizedText)
) {
return false
}
return true
}
async function executeStewardRuntimeDecision(decision = null, rawText = '', options = {}) {
const nextAction = String(decision?.next_action || decision?.nextAction || '').trim()
const userMessageAlreadyAdded = Boolean(options.userMessageAlreadyAdded)
if (nextAction === 'submit_current_application') {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetMessage = targetMessageId
? messages.value.find((message) => String(message.id || '') === targetMessageId)
: findPendingApplicationSubmitMessage()
if (!targetMessage?.applicationPreview) {
return false
}
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
if (!normalizedPreview.readyToSubmit) {
pushApplicationSubmitBlockedMessage(rawText, targetMessage, { userMessageAlreadyAdded })
return true
}
targetMessage.applicationPreview = normalizedPreview
applicationSubmitConfirmDialog.value = { open: true, message: targetMessage }
await confirmApplicationSubmit({ userText: rawText, skipUserMessage: userMessageAlreadyAdded })
return true
}
if (nextAction === 'continue_next_task') {
const context = findPendingStewardSuggestedActionContext(decision)
if (!context) {
return false
}
if (rawText && !userMessageAlreadyAdded) {
messages.value.push(createMessage('user', rawText))
}
context.action.confirmedByText = true
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
await handleSuggestedAction(context.message, context.action)
return true
}
if (nextAction === 'fill_current_slot') {
const context = findPendingSlotSuggestedActionContext(decision)
if (!context) {
return false
}
await handleSuggestedAction(context.message, {
...context.action,
label: String(decision?.field_value || decision?.fieldValue || context.action.label || '').trim(),
suppressUserEcho: userMessageAlreadyAdded
})
return true
}
if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') {
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
return true
}
return false
}
async function handleStewardRuntimeDecision(options = {}) {
if (!isStewardSession.value || options.skipStewardPlan) {
return false
}
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
const files = Array.from(options.files ?? attachedFiles.value ?? [])
if (!rawText || files.length) {
return false
}
const runtimeState = buildStewardRuntimeState()
if (!hasActiveStewardRuntimeDecisionContext(runtimeState)) {
return false
}
const userMessageAlreadyAdded = options.skipUserMessage
? false
: pushStewardRuntimeUserMessage(rawText)
try {
const fastDecision = buildStewardRuntimeFastPathDecision(rawText, runtimeState)
if (fastDecision) {
if (String(fastDecision.next_action || fastDecision.nextAction || '').trim() === 'plan_new_tasks') {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage
})
return true
}
const fastExecuted = await executeStewardRuntimeDecision(fastDecision, rawText, { userMessageAlreadyAdded })
if (fastExecuted) {
return true
}
}
if (!shouldUseStewardRuntimeLlmDecision(rawText, runtimeState)) {
if (userMessageAlreadyAdded) {
pushStewardRuntimeResponse('', {
response_text: '我还需要先确认当前等待项。请回复系统刚刚追问的选项或具体补充内容。'
}, { userMessageAlreadyAdded: true })
return true
}
return false
}
const decision = await fetchStewardRuntimeDecision({
user_message: rawText,
session_type: SESSION_TYPE_STEWARD,
runtime_state: runtimeState,
context_json: {
entry_source: props.entrySource,
user_id: resolveCurrentUserId()
}
}, {
timeoutMs: 45000,
timeoutMessage: '小财管家运行时决策超时,已回到当前上下文兜底处理。'
})
if (String(decision?.next_action || decision?.nextAction || '').trim() === 'plan_new_tasks') {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage
})
return true
}
const executed = await executeStewardRuntimeDecision(decision, rawText, { userMessageAlreadyAdded })
if (executed) {
return true
}
if (userMessageAlreadyAdded) {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: true
})
return true
}
return false
} catch (error) {
console.warn('Steward runtime decision failed:', error)
if (userMessageAlreadyAdded) {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: true
})
return true
}
return false
}
}
function openApplicationSubmitConfirm(message) {
if (!message) {
return
}
if (message.applicationPreview) {
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
message.applicationPreview = normalizedPreview
message.text = buildLocalApplicationPreviewMessage(normalizedPreview)
if (!normalizedPreview.readyToSubmit) {
const validationIssues = Array.isArray(normalizedPreview.validationIssues)
? normalizedPreview.validationIssues
: []
toast(
validationIssues.length
? validationIssues[0].message
: `请先补充:${normalizedPreview.missingFields.join('、')}`
)
persistSessionState()
return
}
}
applicationSubmitConfirmDialog.value = {
open: true,
message
}
}
function closeApplicationSubmitConfirm() {
if (reviewActionBusy.value) {
return
}
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
}
function resolveApplicationEditClaimId() {
if (activeSessionType.value !== SESSION_TYPE_APPLICATION) {
return ''
}
const request = linkedRequest.value || {}
if (!request.applicationEditMode) {
return ''
}
return String(request.claimId || request.claim_id || '').trim()
}
async function confirmApplicationSubmit(options = {}) {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
return
}
const applicationPreview = message?.applicationPreview && typeof message.applicationPreview === 'object'
? normalizeApplicationPreview(message.applicationPreview)
: null
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
const applicationEditClaimId = resolveApplicationEditClaimId()
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
const stewardSubmitContinuation = message?.stewardContinuation || null
reviewActionBusy.value = true
try {
const payload = await submitComposer({
rawText: applicationSubmitText,
userText: String(options.userText || '').trim() || '确认提交',
skipUserMessage: Boolean(options.skipUserMessage),
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
skipStewardPlan: true,
stewardContinuation: stewardSubmitContinuation,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText,
...(applicationEditClaimId
? {
application_edit_claim_id: applicationEditClaimId,
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
application_edit_mode: true,
draft_claim_id: applicationEditClaimId,
selected_claim_id: applicationEditClaimId
}
: {})
}
})
const draftPayload = payload?.result?.draft_payload || {}
const claimNo = String(draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claim_id || '').trim()
if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) {
message.applicationSubmitConfirmed = true
emit('draft-saved', {
claimId,
claimNo,
status: 'submitted',
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
documentType: 'application'
})
}
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
...action,
payload: {
...(action.payload || {}),
applicationPreview,
draftPayload
}
}))
if (planningText && planningActions.length) {
messages.value.push(createMessage('assistant', planningText, [], {
meta: ['行程规划推荐'],
suggestedActions: planningActions
}))
persistSessionState()
nextTick(scrollToBottom)
}
const stewardFollowup = buildStewardContinuationAfterAction({
createMessage,
message,
completedLabel: '申请单已完成'
})
if (stewardFollowup) {
await pushStewardContinuationMessage({
finalMessage: stewardFollowup,
messages,
nextTick,
persistSessionState,
scrollToBottom
})
}
} finally {
reviewActionBusy.value = false
}
}
return {
closeApplicationSubmitConfirm,
confirmApplicationSubmit,
handleApplicationSubmitConfirmationText,
handleStewardRuntimeDecision,
isApplicationSubmitConfirmationText,
openApplicationSubmitConfirm,
resolveStewardMissingFieldItems
}
}

View File

@@ -17,9 +17,9 @@ import {
normalizeApplicationPreview,
normalizeTransportModeOption,
resolveApplicationDateRange,
shouldRequireApplicationModelReview,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
@@ -29,11 +29,11 @@ import {
handleBudgetCompileReportSubmit,
shouldUseBudgetCompileReport
} from './budgetAssistantReportModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
@@ -44,6 +44,13 @@ const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
reason: 'reason',
amount: 'amount',
transportMode: 'transport_mode',
transportEstimatedAmount: 'transport_estimated_amount',
trainEstimatedAmount: 'train_estimated_amount',
flightEstimatedAmount: 'flight_estimated_amount',
hotelAmount: 'hotel_amount',
allowanceAmount: 'allowance_amount',
policyTotalAmount: 'policy_total_amount',
reimbursementAmount: 'reimbursement_amount',
department: 'department_name',
applicant: 'employee_name',
grade: 'employee_grade'
@@ -75,6 +82,13 @@ const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
reason: 'reason',
amount: 'amount',
transport_mode: 'transportMode',
transport_estimated_amount: 'transportEstimatedAmount',
train_estimated_amount: 'trainEstimatedAmount',
flight_estimated_amount: 'flightEstimatedAmount',
hotel_amount: 'hotelAmount',
allowance_amount: 'allowanceAmount',
policy_total_amount: 'policyTotalAmount',
reimbursement_amount: 'reimbursementAmount',
department_name: 'department',
employee_name: 'applicant',
employee_grade: 'grade'
@@ -87,6 +101,13 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
reason: '事由',
amount: '金额',
transport_mode: '出行方式',
transport_estimated_amount: '交通费用预估',
train_estimated_amount: '火车费用预估',
flight_estimated_amount: '飞机费用预估',
hotel_amount: '住宿测算金额',
allowance_amount: '出差补贴金额',
policy_total_amount: '规则测算合计',
reimbursement_amount: '实际报销金额',
attachments: '附件/凭证',
customer_name: '客户或项目对象',
merchant_name: '商户/开票方',
@@ -97,6 +118,13 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
'amount',
'transport_estimated_amount',
'train_estimated_amount',
'flight_estimated_amount',
'hotel_amount',
'allowance_amount',
'policy_total_amount',
'reimbursement_amount',
'attachments',
'employee_no',
'department_name',
@@ -600,24 +628,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
if (!missingFields.length) {
return fallbackText
}
if (missingFields.includes('出行方式')) {
return [
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
'',
'**原因是:还缺少“出行方式”。**',
'',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'',
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
].join('\n')
}
return [
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
'',
@@ -710,13 +725,10 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
]
if (missingInfo) {
const transportMissing = /出行方式/.test(missingInfo)
events.push({
eventId: `${eventPrefix}-gap`,
title: '判断待补充信息',
content: transportMissing
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
content: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
})
} else {
events.push({
@@ -809,13 +821,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const chars = Array.from(text)
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
index = resolveStewardTypewriterNextIndex(chars, index)
message.text = chars.slice(0, index).join('')
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
nextTick(scrollToBottom)
}
Object.assign(message, finalExtras, {
@@ -839,13 +849,39 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
function isApplicationDraftPayload(draftPayload) {
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
}
function isSubmittedApplicationDraftPayload(draftPayload) {
return (
String(draftPayload?.draft_type || '').trim() === 'expense_application'
isApplicationDraftPayload(draftPayload)
&& String(draftPayload?.status || '').trim() === 'submitted'
)
}
function shouldExposeReviewPayloadForMessage(payload, options = {}) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
if (options.isApplicationSubmitOperation || isApplicationDraftPayload(result.draft_payload)) {
return false
}
return true
}
function buildPresentationPayload(payload, { exposeReviewPayload = true } = {}) {
if (exposeReviewPayload) {
return payload
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
return {
...payload,
result: {
...result,
review_payload: null
}
}
}
function buildOperationFeedbackState(context) {
if (!context) {
return null
@@ -1190,12 +1226,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return preview
}
try {
const fields = preview?.fields || {}
await waitForMockApplicationTransportQuote({
transportMode: fields.transportMode,
location: fields.location,
time: fields.time
})
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
@@ -1204,7 +1234,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
if (options.skipModelReview) {
const requireModelReview = shouldRequireApplicationModelReview(rawText)
if (options.skipModelReview && !requireModelReview) {
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
@@ -2042,24 +2073,31 @@ export function useTravelReimbursementSubmitComposer(ctx) {
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
})
: null
const assistantMessage = createMessage('assistant', resolveAssistantResultText(payload, fallbackAnswer), [], {
meta: buildMessageMeta(payload, effectiveFileNames),
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
? payload.result.suggested_actions
: [],
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
const exposeReviewPayload = shouldExposeReviewPayloadForMessage(payload, { isApplicationSubmitOperation })
const presentationPayload = buildPresentationPayload(payload, { exposeReviewPayload })
const presentationResult = presentationPayload?.result && typeof presentationPayload.result === 'object'
? presentationPayload.result
: {}
const resultReviewPayload = presentationResult.review_payload || null
const resultSuggestedActions = exposeReviewPayload && Array.isArray(presentationResult.suggested_actions)
? presentationResult.suggested_actions
: []
const assistantMessage = createMessage('assistant', resolveAssistantResultText(presentationPayload, fallbackAnswer), [], {
meta: buildMessageMeta(presentationPayload, effectiveFileNames),
citations: Array.isArray(presentationResult.citations) ? presentationResult.citations : [],
suggestedActions: resultSuggestedActions,
queryPayload: normalizeExpenseQueryPayload(presentationResult.query_payload),
draftPayload: presentationResult.draft_payload || null,
reviewPayload: resultReviewPayload,
reviewPanelScope: stewardDelegated
? ''
: resolveReviewPanelScope({
reviewPayload: payload?.result?.review_payload || null,
reviewPayload: resultReviewPayload,
reviewAction: reviewActionResult,
fileCount: files.length,
rawText
}),
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
riskFlags: Array.isArray(presentationResult.risk_flags) ? presentationResult.risk_flags : [],
operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
stewardContinuation: options.stewardContinuation || null
@@ -2084,7 +2122,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} else {
replaceMessage(pendingMessage.id, assistantMessage)
const nextInsight = buildAgentInsight(
payload,
presentationPayload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)

View File

@@ -0,0 +1,447 @@
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
normalizeApplicationPreview,
normalizeTransportModeOption
} from '../../utils/expenseApplicationPreview.js'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../../utils/assistantSuggestedActionPrefill.js'
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import {
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP,
buildTravelPlanningRecommendation
} from '../../utils/travelApplicationPlanning.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_BUDGET,
canUseBudgetAssistantSession
} from './travelReimbursementConversationModel.js'
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
import {
buildStewardFieldCompletionContinuation,
buildStewardFieldCompletionRawText
} from './stewardFieldCompletionModel.js'
import { MAX_ATTACHMENTS, VISIBLE_ATTACHMENT_CHIPS } from './travelReimbursementAttachmentModel.js'
export const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
export function useTravelReimbursementSuggestedActions({
applicationPreviewEditor,
attachedFiles,
buildExpenseSceneSelectionActions,
buildExpenseSceneSelectionMessage,
commitApplicationPreviewEditor,
composerDraft,
composerFilesExpanded,
composerTextareaRef,
createMessage,
currentUser,
emit,
handleGuidedShortcut,
handleGuidedSuggestedAction,
handleSceneSelectionApplicationGate,
lockSuggestedActionMessage,
mergeFilesWithLimit,
messages,
nextTick,
openApplicationPreviewEditor,
persistSessionState,
resolveApplicationPreviewMissingFields,
reviewActionBusy,
router,
scrollToBottom,
sessionSwitchBusy,
startExpenseSceneSelectionAfterIntentConfirmation,
submitComposer,
submitComposerInternal,
submitting,
switchSessionType,
toast,
adjustComposerTextareaHeight
}) {
async function runShortcut(shortcut) {
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
if (shortcut.targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
toast('目前暂无权限访问预算编制助手')
return
}
if (shortcut.active) {
return
}
await switchSessionType(shortcut.targetSessionType)
return
}
if (await handleGuidedShortcut(shortcut)) {
return
}
const prompt = String(shortcut?.prompt || '').trim()
if (!prompt) return
composerDraft.value = prompt
submitComposer()
}
function isSuggestedActionSelected(message, action) {
const selectedKey = String(message?.selectedSuggestedActionKey || '').trim()
return Boolean(selectedKey) && selectedKey === buildSuggestedActionKey(action)
}
function buildApplicationPreviewFieldAppliedText(message, fieldLabel = '', value = '') {
const missingFields = resolveApplicationPreviewMissingFields(message)
const resolvedFieldLabel = String(fieldLabel || '补充项').trim()
const resolvedValue = String(value || '').trim()
if (missingFields.length) {
return [
`已更新:**${resolvedFieldLabel}${resolvedValue}**。`,
'',
`我重新检查了一遍,当前还需要补充:**${missingFields.join('、')}**。`,
'',
'请继续补齐下方核对表里的待补充项;补齐后我再继续推进申请提交。'
].join('\n')
}
return [
`已更新:**${resolvedFieldLabel}${resolvedValue}**。`,
'',
'我已经重新同步下方申请核对表和费用测算。',
'',
'请继续核查表格内容;如果信息无误,点击确认进入审批环节。'
].join('\n')
}
function isStewardApplicationPreviewFieldCompletion(targetMessage, payload = {}) {
return Boolean(
payload.steward_delegated_field_completion ||
String(targetMessage?.assistantName || '').trim() === STEWARD_ASSISTANT_NAME ||
targetMessage?.stewardPlan
)
}
async function continueStewardApplicationFieldCompletion({
targetMessage,
action,
sourcePreview,
fieldKey,
fieldLabel,
value
}) {
if (!lockSuggestedActionMessage(targetMessage, action)) {
return true
}
const continuation = buildStewardFieldCompletionContinuation(
targetMessage?.stewardContinuation || null,
fieldKey,
value
)
const userText = `选择${fieldLabel || '补充项'}${value}`
const carryText = buildStewardFieldCompletionRawText({
preview: sourcePreview,
fieldKey,
fieldLabel,
value,
continuation
})
if (!action?.suppressUserEcho) {
messages.value.push(createMessage('user', userText))
}
persistSessionState()
nextTick(scrollToBottom)
await submitComposerInternal({
rawText: carryText,
userText,
pendingText: '小财管家正在根据补齐信息查询票据并测算费用...',
files: [],
skipScopeGuard: true,
skipApplicationModelReview: true,
skipStewardPlan: true,
skipUserMessage: true,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
stewardContinuation: continuation
})
return true
}
async function applyApplicationPreviewFieldAction(message, action) {
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const fieldKey = String(payload.field_key || payload.fieldKey || '').trim()
const fieldLabel = String(payload.field_label || payload.fieldLabel || action?.label || '').trim()
let value = String(payload.value || action?.label || '').trim()
const targetMessage = messages.value.find((item) => String(item.id || '') === String(message?.id || '')) || message
const sourcePreview = targetMessage?.applicationPreview ||
payload.applicationPreview ||
payload.application_preview ||
payload.preview ||
null
if (!sourcePreview || !fieldKey || !value) {
return false
}
if (fieldKey === 'transportMode') {
value = normalizeTransportModeOption(value, '')
}
if (fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(value)) {
toast('请选择有效的出行方式。')
return true
}
if (isStewardApplicationPreviewFieldCompletion(targetMessage, payload)) {
return continueStewardApplicationFieldCompletion({
targetMessage,
action,
sourcePreview,
fieldKey,
fieldLabel,
value
})
}
if (!lockSuggestedActionMessage(targetMessage, action)) {
return true
}
targetMessage.applicationPreview = normalizeApplicationPreview(sourcePreview)
messages.value.push(createMessage('user', `选择${fieldLabel || '补充项'}${value}`))
openApplicationPreviewEditor(targetMessage, fieldKey, targetMessage.applicationPreview?.fields?.[fieldKey] || '')
applicationPreviewEditor.value = {
...applicationPreviewEditor.value,
draftValue: value
}
await commitApplicationPreviewEditor(targetMessage)
if (String(targetMessage.assistantName || '').trim() === STEWARD_ASSISTANT_NAME || targetMessage.stewardPlan) {
targetMessage.assistantName = STEWARD_ASSISTANT_NAME
targetMessage.text = buildApplicationPreviewFieldAppliedText(targetMessage, fieldLabel, value)
const nextMeta = Array.isArray(targetMessage.meta) ? targetMessage.meta : []
targetMessage.meta = Array.from(new Set([
STEWARD_ASSISTANT_NAME,
resolveApplicationPreviewMissingFields(targetMessage).length ? '等待补充' : '等待用户确认',
...nextMeta.filter((item) => String(item || '').trim() && item !== STEWARD_ASSISTANT_NAME)
])).slice(0, 4)
}
persistSessionState()
nextTick(scrollToBottom)
return true
}
function pushExpenseSceneSelectionPrompt(originalMessage) {
const sourceText = String(originalMessage || '').trim()
if (!sourceText) {
return
}
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
messages.value.push(createMessage('user', '我要报销'))
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], {
meta: ['等待选择场景'],
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
}))
nextTick(scrollToBottom)
persistSessionState()
}
function applySuggestedActionPrefill(action) {
const prefillText = resolveSuggestedActionPrefill(action)
if (!prefillText) {
return false
}
composerDraft.value = mergeComposerPrefill(composerDraft.value, prefillText)
nextTick(() => {
adjustComposerTextareaHeight()
composerTextareaRef.value?.focus()
})
persistSessionState()
return true
}
async function handleSuggestedAction(message, action) {
const actionType = String(action?.action_type || '').trim()
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
if (message?.suggestedActionsLocked) return
if (applySuggestedActionPrefill(action)) return
if (await handleGuidedSuggestedAction(message, action)) return
if (await handleSceneSelectionApplicationGate(message, action)) return
if (actionType === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
await applyApplicationPreviewFieldAction(message, action)
return
}
if (actionType === 'open_application_detail') {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
if (!claimId) {
toast('当前没有可查看的申请单据。')
return
}
if (!lockSuggestedActionMessage(message, action)) return
await router.push({
name: 'app-document-detail',
params: { requestId: claimId }
})
emit('close')
return
}
if (actionType === 'open_receipt_folder') {
if (!lockSuggestedActionMessage(message, action)) return
await router.push({ name: 'app-receiptFolder' })
emit('close')
return
}
if (actionType === 'continue_upload_with_unlinked_receipts') {
if (!lockSuggestedActionMessage(message, action)) return
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
await submitComposer({
rawText: String(actionPayload.raw_text || composerDraft.value || '').trim(),
files: Array.from(attachedFiles.value || []),
skipReceiptFolderUnlinkedPrompt: true
})
return
}
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
if (!lockSuggestedActionMessage(message, action)) return
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
const sourceDraftPayload = action?.payload?.draftPayload || action?.payload?.draft_payload || null
const recommendation = buildTravelPlanningRecommendation(sourcePreview, sourceDraftPayload)
if (recommendation) {
messages.value.push(createMessage('user', '生成行程规划'))
messages.value.push(createMessage('assistant', recommendation, [], {
meta: ['行程规划建议']
}))
nextTick(scrollToBottom)
persistSessionState()
}
return
}
if (actionType === TRAVEL_PLANNING_ACTION_SKIP) {
if (!lockSuggestedActionMessage(message, action)) return
messages.value.push(createMessage('assistant', '好的,本次先保留申请结果。后续需要规划交通或酒店时,可以继续在这里告诉我。', [], {
meta: ['暂不规划']
}))
nextTick(scrollToBottom)
persistSessionState()
return
}
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const targetSessionType = String(actionPayload.session_type || '').trim()
if (!targetSessionType) return
if (targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
toast('目前暂无权限访问预算编制助手')
return
}
const carryText = String(actionPayload.carry_text || '').trim()
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return
if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText
await submitComposerInternal({
rawText: carryText,
userText: action.label || '确定',
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
? '小财管家正在调用申请助手生成申请单核对结果...'
: '小财管家正在调用报销助手整理报销核对结果...',
files: carryFiles,
skipScopeGuard: true,
skipApplicationModelReview: targetSessionType === SESSION_TYPE_APPLICATION,
skipStewardSlotDecision: targetSessionType === SESSION_TYPE_APPLICATION,
skipStewardPlan: true,
skipUserMessage: confirmedByText,
sessionTypeOverride: targetSessionType,
stewardContinuation: {
planId: String(actionPayload.steward_plan_id || '').trim(),
currentTaskId: String(actionPayload.steward_next_task_id || '').trim(),
currentTask: actionPayload.steward_current_task || null,
remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks)
? actionPayload.steward_remaining_tasks
: []
}
})
return
}
await switchSessionType(targetSessionType)
if (carryText) {
composerDraft.value = carryText
}
if (carryFiles.length) {
const fileMergeResult = mergeFilesWithLimit([], carryFiles, MAX_ATTACHMENTS)
attachedFiles.value = fileMergeResult.files
composerFilesExpanded.value = fileMergeResult.files.length > VISIBLE_ATTACHMENT_CHIPS
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
if (actionPayload.auto_submit && carryText) {
await submitComposer({
rawText: carryText,
userText: action.label || '确认继续处理',
pendingText: '正在按确认内容继续处理...',
files: carryFiles,
skipScopeGuard: true
})
}
return
}
if (actionType === 'confirm_expense_intent') {
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
if (!originalMessage) return
if (!lockSuggestedActionMessage(message, action)) return
pushExpenseSceneSelectionPrompt(originalMessage)
return
}
if (actionType !== 'select_expense_type') {
const fallbackText = String(action?.description || action?.label || '').trim()
if (!fallbackText) return
if (!lockSuggestedActionMessage(message, action)) return
await submitComposer({
rawText: fallbackText,
userText: fallbackText,
pendingText: '正在继续处理...'
})
return
}
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const expenseType = String(actionPayload.expense_type || '').trim()
const expenseTypeLabel = String(actionPayload.expense_type_label || action?.label || '').trim()
const originalMessage = String(actionPayload.original_message || message?.text || '').trim()
if (!expenseTypeLabel || !originalMessage) return
if (!lockSuggestedActionMessage(message, action)) return
await submitComposer({
rawText: `${originalMessage}\n用户选择报销场景:${expenseTypeLabel}`,
userText: `选择${expenseTypeLabel}`,
pendingText: `已选择${expenseTypeLabel},正在按该场景识别...`,
systemGenerated: true,
extraContext: {
draft_claim_id: '',
user_input_text: originalMessage,
expense_scene_selection: {
expense_type: expenseType,
expense_type_label: expenseTypeLabel,
original_message: originalMessage
},
review_form_values: {
expense_type: expenseTypeLabel
}
}
})
}
return {
handleSuggestedAction,
isSuggestedActionSelected,
runShortcut
}
}

View File

@@ -20,6 +20,7 @@ import {
normalizeTransportModeOption,
resolveApplicationDateRange,
resolveApplicationTimeLabel,
shouldRequireApplicationModelReview,
shouldUseLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
import {
@@ -50,6 +51,14 @@ import {
import {
shouldUseBudgetCompileReport
} from '../src/views/scripts/budgetAssistantReportModel.js'
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
import {
ASSISTANT_SCOPE_SESSION_APPLICATION,
ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_STEWARD,
inferAssistantScopeTarget,
resolveAssistantScopeGuard
} from '../src/utils/assistantSessionScope.js'
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
@@ -65,6 +74,26 @@ const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const messageActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementMessageActions.js', import.meta.url)),
'utf8'
)
const suggestedActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
'utf8'
)
const stewardRuntimeScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
'utf8'
)
const stewardRuntimeTextModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardRuntimeTextModel.js', import.meta.url)),
'utf8'
)
const stewardFollowupFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardFollowupFlow.js', import.meta.url)),
'utf8'
)
const stewardPlanFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useStewardPlanFlow.js', import.meta.url)),
'utf8'
@@ -131,7 +160,7 @@ function createFlowHarness() {
}
test('application intent uses local preview instead of immediate orchestrator call', () => {
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差3高铁预计金额2358元'
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差4高铁预计金额2358元'
assert.equal(
shouldUseLocalApplicationPreview(prompt, {
sessionType: 'application',
@@ -150,6 +179,33 @@ test('application intent uses local preview instead of immediate orchestrator ca
}),
false
)
assert.equal(
shouldUseLocalApplicationPreview('小财管家\n23:04\n这是费用申请核对结果请核对', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
false
)
assert.equal(
shouldUseLocalApplicationPreview('我要申请', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
false
)
assert.equal(
shouldUseLocalApplicationPreview('去上海出差,支撑国网仿生产环境部署', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
true
)
const preview = buildLocalApplicationPreview(prompt, {
name: '李文静',
@@ -161,7 +217,7 @@ test('application intent uses local preview instead of immediate orchestrator ca
assert.equal(preview.fields.applicationType, '差旅费用申请')
assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.fields.days, '4天')
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.fields.amount, '2358元')
assert.equal(preview.fields.applicant, '李文静')
@@ -175,6 +231,33 @@ test('application intent uses local preview instead of immediate orchestrator ca
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
})
test('assistant scope guard blocks unsupported non-financial intent', () => {
const guard = resolveAssistantScopeGuard('帮我写一首诗,主题是春天', ASSISTANT_SCOPE_SESSION_APPLICATION)
assert.equal(guard.blocked, true)
assert.equal(guard.targetSessionType, '')
assert.match(guard.text, /此意图系统不支持/)
assert.match(guard.text, /当前系统支持的业务范围/)
assert.deepEqual(guard.suggestedActions, [])
})
test('assistant scope guard routes related business intent instead of blocking', () => {
const guard = resolveAssistantScopeGuard('帮我查一下报销单状态', ASSISTANT_SCOPE_SESSION_APPLICATION)
assert.equal(guard.blocked, undefined)
assert.equal(guard.targetSessionType, ASSISTANT_SCOPE_SESSION_EXPENSE)
assert.match(guard.text, /报销助手/)
assert.equal(guard.suggestedActions[0].payload.session_type, ASSISTANT_SCOPE_SESSION_EXPENSE)
})
test('assistant scope guard keeps current supported application intent and steward finance queries', () => {
assert.equal(
resolveAssistantScopeGuard('申请下周去上海出差,支撑服务器部署', ASSISTANT_SCOPE_SESSION_APPLICATION),
null
)
assert.equal(inferAssistantScopeTarget('查询一下预算余额'), ASSISTANT_SCOPE_SESSION_STEWARD)
})
test('travel application submit can continue with conversational planning recommendation', () => {
const preview = normalizeApplicationPreview({
fields: {
@@ -273,12 +356,12 @@ test('application estimate builds deterministic mock transport amount and total'
assert.equal(trainEstimate.amountDisplay, '1,040')
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
assert.equal(datedTrainEstimate.source, 'mock_ticket_price_query_v1')
assert.equal(datedTrainEstimate.source, 'fallback_transport_budget_estimate_v1')
assert.equal(datedTrainEstimate.basisText, '预估交通费用 1,100元')
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
assert.equal(flightEstimate.amountDisplay, '3,600')
assert.equal(flightEstimate.amountDisplay, '3,200')
assert.equal(shipEstimate.amountDisplay, '1,040')
assert.equal(totalEstimate.transportAmountDisplay, '1,040')
assert.equal(totalEstimate.totalAmountDisplay, '3,200')
@@ -323,6 +406,168 @@ test('application preview uses selected date range and business-specific time la
assert.doesNotMatch(submitText, /发生时间:/)
})
test('application preview parses same-month shorthand date range', () => {
const preview = buildLocalApplicationPreview(
'我要申请2月20日-23日去上海出差辅助国网仿生产项目部署',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
const rows = buildApplicationPreviewRows(preview)
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.days, '4天')
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产项目部署')
assert.doesNotMatch(preview.fields.reason, /小财管家继续执行/)
})
test('application preview blocks submit when date range conflicts with explicit days', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差3天辅助国网仿生产服务器部署火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
const normalized = normalizeApplicationPreview(preview)
const footer = buildApplicationPreviewFooterMessage(normalized)
assert.equal(normalized.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(normalized.fields.days, '3天')
assert.equal(normalized.readyToSubmit, false)
assert.equal(normalized.validationIssues[0].code, 'time_days_conflict')
assert.match(footer, /按自然日为 4 天/)
assert.match(footer, /填写的是 3 天/)
})
test('application preview blocks submit when location candidates conflict', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去北京出差4天地点上海辅助国网仿生产服务器部署火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
const footer = buildApplicationPreviewFooterMessage(preview)
assert.equal(preview.readyToSubmit, false)
assert.equal(preview.validationIssues[0].code, 'location_candidates_conflict')
assert.match(footer, /同时出现多个地点/)
assert.match(footer, /北京/)
assert.match(footer, /上海/)
})
test('application preview does not treat application type labels as locations', () => {
const preview = normalizeApplicationPreview({
sourceText: [
'费用申请出差',
'任务摘要:交通方式和出差预算待补充',
'申请类型:差旅费用申请',
'地点:上海',
'申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'
].join('\n'),
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '服务国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
amount: '2120元',
grade: 'P5',
applicant: '曹笑竹',
department: '技术部',
position: '产品经理',
managerName: '向万红'
}
})
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
assert.doesNotMatch(buildApplicationPreviewFooterMessage(preview), /多个地点|费用申请/)
})
test('application preview trusts model-refined fields over noisy source candidates', () => {
const preview = normalizeApplicationPreview({
sourceText: [
'任务摘要:交通方式和出差预算待补充',
'申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'
].join('\n'),
modelRefined: true,
modelReviewStatus: 'completed',
parseStrategy: 'llm_primary',
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '服务国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
amount: '2120元',
grade: 'P5',
applicant: '曹笑竹',
department: '技术部',
position: '产品经理',
managerName: '向万红'
}
})
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
})
test('application preview blocks submit when transport candidates conflict', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差4天辅助国网仿生产服务器部署出行方式飞机坐火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
assert.equal(preview.readyToSubmit, false)
assert.equal(preview.validationIssues[0].code, 'transport_candidates_conflict')
assert.match(buildApplicationPreviewFooterMessage(preview), /同时出现多个出行方式/)
})
test('application preview normalizes compact amount candidates', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差4天辅助国网仿生产服务器部署火车预计费用1.8k',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
assert.equal(preview.fields.amount, '1800元')
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
})
test('application preview keeps labeled reason in structured travel form', () => {
const preview = buildLocalApplicationPreview([
'发生时间2026-02-20 至 2026-02-23',
@@ -392,7 +637,80 @@ test('application preview can be refined by ontology model extraction', () => {
assert.equal(refinedPreview.fields.transportMode, '火车')
})
test('application preview ignores model-only transport mode guesses', () => {
test('application preview preserves ontology amount roles for travel estimates', () => {
const rawText = '申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'
const localPreview = buildLocalApplicationPreview(rawText, { name: '曹笑竹', grade: 'P5' }, { today: '2026-06-13' })
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '上海', normalized_value: '上海' },
{ type: 'reason', value: '服务国网仿生产服务器部署', normalized_value: '服务国网仿生产服务器部署' },
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
{ type: 'transport_estimated_amount', value: '720元', normalized_value: '720' },
{ type: 'hotel_amount', value: '1000元', normalized_value: '1000' },
{ type: 'allowance_amount', value: '400元', normalized_value: '400' },
{ type: 'policy_total_amount', value: '2120元', normalized_value: '2120' }
],
time_range: {
start_date: '2026-02-20',
end_date: '2026-02-23'
},
missing_slots: []
},
rawText,
{ name: '曹笑竹', grade: 'P5' }
)
assert.equal(refinedPreview.fields.amount, '2120元')
assert.equal(refinedPreview.fields.transportEstimatedAmount, '720元')
assert.equal(refinedPreview.fields.hotelAmount, '1000元')
assert.equal(refinedPreview.fields.allowanceAmount, '400元')
assert.equal(refinedPreview.fields.policyTotalAmount, '2120元')
})
test('application preview ignores model reason polluted by application type', () => {
const rawText = '我申请2月20日至23日去上海出差辅助国网方法生产服务器上线部署'
const localPreview = buildLocalApplicationPreview(rawText, {
name: '曹笑竹',
grade: 'P5'
}, {
today: '2026-06-13'
})
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '上海', normalized_value: '上海' },
{ type: 'reason', value: '类型:差旅费用申请', normalized_value: '类型:差旅费用申请' }
],
missing_slots: []
},
rawText,
{ name: '曹笑竹', grade: 'P5' }
)
assert.equal(localPreview.fields.reason, '辅助国网方法生产服务器上线部署')
assert.equal(refinedPreview.fields.reason, '辅助国网方法生产服务器上线部署')
assert.doesNotMatch(refinedPreview.fields.reason, /类型|差旅费用申请/)
})
test('application preview strips internal steward instruction from reason', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差事由辅助国网仿生产服务器部署请直接生成申请单核对结果信息足够时生成申请单但在入库或提交审批前仍需让我确认',
{ name: '曹笑竹', grade: 'P5' },
{ today: '2026-06-09' }
)
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.doesNotMatch(preview.fields.reason, /请直接生成|入库|提交审批/)
})
test('application preview requires explicit transport mode before submit', () => {
const rawText = '\u7533\u8bf7 2026-05-25 \u81f3 2026-05-27 \u53bb\u4e0a\u6d77\u51fa\u5dee3\u5929\uff0c\u670d\u52a1\u9879\u76ee\u90e8\u7f72\uff0c\u9884\u8ba1\u8d39\u75281800\u5143'
const localPreview = buildLocalApplicationPreview(rawText, {
name: '\u674e\u6587\u9759',
@@ -421,10 +739,25 @@ test('application preview ignores model-only transport mode guesses', () => {
assert.equal(localPreview.fields.transportMode, '')
assert.equal(refinedPreview.fields.transportMode, '')
assert.ok(refinedPreview.missingFields.includes('\u51fa\u884c\u65b9\u5f0f'))
assert.equal(refinedPreview.missingFields.includes('\u51fa\u884c\u65b9\u5f0f'), true)
assert.equal(refinedPreview.readyToSubmit, false)
})
test('application preview does not treat transport prompt options as selected mode', () => {
const preview = buildLocalApplicationPreview(
'当前还需要补充:出行方式。请先补充出行方式,可以选择火车、飞机或轮船。',
{ name: '李文静', grade: 'P5' }
)
const mixedPreview = buildLocalApplicationPreview(
'任务摘要:交通方式和出差预算待补充\n申请2月20日-23日火车去上海出差',
{ name: '李文静', grade: 'P5' },
{ today: '2026-06-09' }
)
assert.equal(preview.fields.transportMode, '')
assert.equal(mixedPreview.fields.transportMode, '火车')
})
test('application preview precomputes a date range from today when only days are provided', () => {
const preview = buildLocalApplicationPreview(
'去北京出差3天支撑国网仿生产环境部署飞机预计费用12000元',
@@ -438,7 +771,7 @@ test('application preview precomputes a date range from today when only days are
})
test('application preview keeps rule fallback distinct from model reviewed result', () => {
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差3火车预计费用1800元'
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差4火车预计费用1800元'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
const fallbackPreview = buildModelRefinedApplicationPreview(
localPreview,
@@ -545,11 +878,11 @@ test('application session shows intent flow, persists preview, and supports inli
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/)
assert.match(createViewScript, /useApplicationPreviewEditor/)
assert.match(createViewScript, /message-bubble-application-preview/)
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
assert.match(createViewScript, /function buildApplicationPreviewFooterText\(message\)/)
assert.match(createViewScript, /buildApplicationPreviewSubmitText/)
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
assert.match(messageActionsScript, /message-bubble-application-preview/)
assert.match(messageActionsScript, /buildApplicationPreviewFooterMessage/)
assert.match(messageActionsScript, /function buildApplicationPreviewFooterText\(message\)/)
assert.match(stewardRuntimeScript, /buildApplicationPreviewSubmitText/)
assert.match(stewardRuntimeScript, /user_input_text: applicationSubmitText/)
assert.match(conversationModelScript, /applicationPreview: null/)
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
assert.match(conversationModelScript, /\|\| message\.applicationPreview/)
@@ -564,7 +897,7 @@ test('application session shows intent flow, persists preview, and supports inli
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.doesNotMatch(messageItemTemplate, /application-preview-date-chip/)
assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
assert.match(messageItemTemplate, /报销草稿已生成/)
@@ -575,9 +908,9 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /查看详情/)
assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/)
assert.match(messageItemTemplate, /保存后可查看详情/)
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
assert.match(messageActionsScript, /function canOpenDraftDetail\(message\)/)
assert.match(createViewScript, /canOpenDraftDetail,/)
assert.match(createViewScript, /保存后生成/)
assert.match(messageActionsScript, /保存后生成/)
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
assert.ok(
@@ -619,12 +952,12 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
assert.match(createViewScript, /function shouldShowAssistantMessageActions/)
assert.match(createViewScript, /function buildMessageOperationFeedbackContext/)
assert.match(createViewScript, /function isMessageFeedbackSelected/)
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
assert.match(createViewScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
assert.match(createViewScript, /stewardContinuation:\s*stewardSubmitContinuation/)
assert.match(messageActionsScript, /function shouldShowAssistantMessageActions/)
assert.match(messageActionsScript, /function buildMessageOperationFeedbackContext/)
assert.match(messageActionsScript, /function isMessageFeedbackSelected/)
assert.match(messageActionsScript, /function submitOperationFeedbackForMessage/)
assert.match(stewardRuntimeScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
assert.match(stewardRuntimeScript, /stewardContinuation:\s*stewardSubmitContinuation/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
@@ -675,26 +1008,26 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(flowScript, /refreshCompleted/)
})
test('steward application missing transport asks before rendering preview table', () => {
test('steward application missing transport blocks preview table', () => {
assert.match(submitComposerScript, /function shouldPauseStewardApplicationPreview/)
assert.match(submitComposerScript, /function sanitizeStewardDelegatedTaskSummary/)
assert.match(submitComposerScript, /交通方式和\(\?:预算\|预计\)\?金额待补充/)
assert.match(submitComposerScript, /出差费用预算/)
assert.match(submitComposerScript, /预估\|预计\|预算\)\?费用/)
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
assert.match(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.doesNotMatch(submitComposerScript, /缺少“出行方式”[\s\S]{0,500}更新下方核对表/)
assert.doesNotMatch(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.match(createViewScript, /payload\.applicationPreview/)
assert.match(createViewScript, /function continueStewardApplicationFieldCompletion/)
assert.match(createViewScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
assert.match(createViewScript, /skipUserMessage:\s*true/)
assert.match(createViewScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
assert.match(createViewScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
assert.match(createViewScript, /commitApplicationPreviewEditor\(targetMessage\)/)
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)
assert.match(suggestedActionsScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
assert.match(suggestedActionsScript, /skipUserMessage:\s*true/)
assert.match(suggestedActionsScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
assert.match(suggestedActionsScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
assert.match(suggestedActionsScript, /commitApplicationPreviewEditor\(targetMessage\)/)
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
assert.match(stewardFieldCompletionScript, /模拟查询交通票据/)
assert.match(stewardFieldCompletionScript, /基础规则交通费用预估表/)
})
test('steward field completion reruns application preview instead of directly rendering table', () => {
@@ -739,7 +1072,7 @@ test('steward field completion reruns application preview instead of directly re
assert.match(carryText, /用户已补充:出行方式:火车/)
assert.match(carryText, /地点:北京/)
assert.match(carryText, /天数3天/)
assert.match(carryText, /请先根据已补齐字段模拟查询交通票据/)
assert.match(carryText, /请先根据已补齐字段按基础规则交通费用预估表/)
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
assert.equal(rebuiltPreview.fields.location, '北京')
@@ -758,7 +1091,7 @@ test('budget compile report does not steal steward delegated application rerun',
'用户已补充:出行方式:火车。',
'地点:北京',
'天数3天',
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。'
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。'
].join('\n')
assert.equal(shouldUseBudgetCompileReport(stewardApplicationText, {
@@ -777,66 +1110,109 @@ test('budget compile report does not steal steward delegated application rerun',
test('text confirmation submits pending application preview before replanning steward task', () => {
assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/)
assert.match(stewardServiceScript, /\/steward\/runtime-decisions/)
assert.match(createViewScript, /function buildStewardRuntimeState/)
assert.match(createViewScript, /function buildStewardRuntimeFastPathDecision/)
assert.match(createViewScript, /function shouldUseStewardRuntimeLlmDecision/)
assert.match(createViewScript, /function findPendingSlotSuggestedActionContextByInput/)
assert.match(createViewScript, /function shouldPlanNewStewardTasksLocally/)
assert.match(createViewScript, /function resolveStewardRuntimeTransportAlias/)
assert.match(createViewScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
assert.match(createViewScript, /actionTransportAlias === transportAlias/)
assert.match(createViewScript, /next_action:\s*'continue_next_task'/)
assert.match(createViewScript, /next_action:\s*'submit_current_application'/)
assert.match(createViewScript, /next_action:\s*'fill_current_slot'/)
assert.match(createViewScript, /next_action:\s*'plan_new_tasks'/)
assert.match(createViewScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
assert.match(createViewScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
assert.match(createViewScript, /skipApplicationModelReview:\s*true/)
assert.match(createViewScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(createViewScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(stewardRuntimeScript, /function buildStewardRuntimeState/)
assert.match(stewardRuntimeScript, /function buildStewardRuntimeFastPathDecision/)
assert.match(stewardRuntimeScript, /function shouldUseStewardRuntimeLlmDecision/)
assert.match(stewardRuntimeScript, /function findPendingSlotSuggestedActionContextByInput/)
assert.match(stewardRuntimeTextModelScript, /function shouldPlanNewStewardTasksLocally/)
assert.match(stewardRuntimeTextModelScript, /function resolveStewardRuntimeTransportAlias/)
assert.match(stewardRuntimeScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
assert.match(stewardRuntimeScript, /actionTransportAlias === transportAlias/)
assert.match(stewardRuntimeScript, /next_action:\s*'continue_next_task'/)
assert.match(stewardRuntimeScript, /next_action:\s*'submit_current_application'/)
assert.match(stewardRuntimeScript, /next_action:\s*'fill_current_slot'/)
assert.match(stewardRuntimeScript, /next_action:\s*'plan_new_tasks'/)
assert.match(stewardRuntimeScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
assert.match(suggestedActionsScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*true/)
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(suggestedActionsScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\s\S]*结构化快路径/)
assert.match(submitComposerScript, /const requireModelReview = shouldRequireApplicationModelReview\(rawText\)/)
assert.match(submitComposerScript, /if \(options\.skipModelReview && !requireModelReview\) \{[\s\S]*结构化快路径/)
assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
assert.match(submitComposerScript, /const pendingSuggestedActions = Array\.isArray\(finalExtras\.suggestedActions\)/)
assert.match(submitComposerScript, /message\.suggestedActions = pendingSuggestedActions[\s\S]*message\.stewardPlan = buildStewardDelegatedPlan\(continuation, \[\.\.\.typedEvents\], 'typing'\)/)
assert.match(createViewScript, /async function handleStewardRuntimeDecision/)
assert.match(createViewScript, /const runtimeState = buildStewardRuntimeState\(\)/)
assert.match(createViewScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
assert.match(createViewScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
assert.match(createViewScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
assert.match(createViewScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(createViewScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
assert.match(createViewScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
assert.match(createViewScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
assert.match(createViewScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(createViewScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
assert.match(stewardRuntimeScript, /async function handleStewardRuntimeDecision/)
assert.match(stewardRuntimeScript, /const runtimeState = buildStewardRuntimeState\(\)/)
assert.match(stewardRuntimeScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
assert.match(stewardRuntimeScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
assert.match(stewardRuntimeScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
assert.match(stewardRuntimeScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
assert.match(stewardRuntimeScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
assert.match(stewardRuntimeScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(stewardRuntimeScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
assert.match(createViewScript, /function isApplicationSubmitConfirmationText/)
assert.match(createViewScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
assert.match(createViewScript, /function findPendingApplicationSubmitMessage/)
assert.match(createViewScript, /normalizedPreview\.readyToSubmit/)
assert.match(createViewScript, /async function handleApplicationSubmitConfirmationText/)
assert.match(createViewScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
assert.match(stewardRuntimeTextModelScript, /function isApplicationSubmitConfirmationText/)
assert.match(stewardRuntimeTextModelScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
assert.match(stewardRuntimeScript, /function findPendingApplicationSubmitMessage/)
assert.match(stewardRuntimeScript, /normalizedPreview\.readyToSubmit/)
assert.match(stewardRuntimeScript, /async function handleApplicationSubmitConfirmationText/)
assert.match(stewardRuntimeScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/)
assert.match(createViewScript, /message\.applicationSubmitConfirmed = true/)
assert.match(createViewScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed = true/)
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
})
test('application submit result does not render reimbursement review followup', () => {
assert.match(submitComposerScript, /function shouldExposeReviewPayloadForMessage\(payload, options = \{\}\)/)
assert.match(submitComposerScript, /options\.isApplicationSubmitOperation \|\| isApplicationDraftPayload\(result\.draft_payload\)/)
assert.match(submitComposerScript, /function buildPresentationPayload\(payload, \{ exposeReviewPayload = true \} = \{\}\)/)
assert.match(submitComposerScript, /review_payload:\s*null/)
assert.match(submitComposerScript, /const exposeReviewPayload = shouldExposeReviewPayloadForMessage\(payload, \{ isApplicationSubmitOperation \}\)/)
assert.match(submitComposerScript, /const presentationPayload = buildPresentationPayload\(payload, \{ exposeReviewPayload \}\)/)
assert.match(submitComposerScript, /const resultReviewPayload = presentationResult\.review_payload \|\| null/)
assert.match(submitComposerScript, /suggestedActions:\s*resultSuggestedActions/)
assert.match(submitComposerScript, /reviewPayload:\s*resultReviewPayload/)
assert.match(submitComposerScript, /buildAgentInsight\(\s*presentationPayload,/)
})
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
assert.match(stewardPlanFlowScript, /STEWARD_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(stewardPlanFlowScript, /STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5/)
assert.match(stewardPlanFlowScript, /index = Math\.min\(total, index \+ STEWARD_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(stewardPlanFlowScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
assert.match(stewardPlanFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/)
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
assert.match(createViewScript, /STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(createViewScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
assert.match(stewardFollowupFlowScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
assert.match(stewardFollowupFlowScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
assert.match(stewardFollowupFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
})
test('steward typewriter renders markdown table blocks at once', () => {
const tableText = '这是费用申请核对结果:\n| 字段 | 值 |\n| --- | --- |\n| 地点 | 上海 |\n下一段'
const tableChars = Array.from(tableText)
const tableIndex = tableText.indexOf('| 字段')
const nextParagraphIndex = tableText.indexOf('下一段')
const normalIndex = 0
assert.equal(resolveStewardTypewriterNextIndex(tableChars, normalIndex), 3)
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex), nextParagraphIndex)
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex - 1), nextParagraphIndex)
assert.equal(resolveStewardTypewriterNextIndex(Array.from('### 核对结果'), 0), 2)
})
test('application preview table appears as a whole card instead of row-by-row animation', () => {
assert.doesNotMatch(
messageItemStyles,
/structured-card-reveal-enter-active\s+\.application-preview-row\s*\{[\s\S]*animation:/,
)
assert.doesNotMatch(
messageItemStyles,
/application-preview-row:nth-child\([^)]*\)\s*\{[\s\S]*animation-delay:/,
)
})
test('complex travel application sentences require model review', () => {
assert.equal(
shouldRequireApplicationModelReview('申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'),
true
)
assert.equal(shouldRequireApplicationModelReview('我想发起一笔费用申请'), false)
})
test('steward initial workbench entry shows recognition state before messages arrive', () => {
@@ -883,7 +1259,9 @@ test('steward application carry text does not leak transport examples into extra
assert.match(carryText, /费用类型:差旅/)
assert.doesNotMatch(carryText, /费用类型travel/)
assert.match(carryText, /还需要补充:出行方式/)
assert.match(carryText, /请先追问上述缺失信息/)
assert.doesNotMatch(carryText, /请先追问上述缺失信息/)
assert.doesNotMatch(carryText, /请直接生成申请单核对结果/)
assert.doesNotMatch(carryText, /入库或提交审批前/)
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
assert.doesNotMatch(carryText, /预计金额|附件\/凭证|员工编号|金额/)
assert.equal(currentTask?.task_type, 'expense_application')
@@ -909,7 +1287,7 @@ test('steward application carry text does not leak transport examples into extra
assert.match(submitComposerScript, /fetchStewardApplicationSlotDecision/)
assert.match(submitComposerScript, /task_type:\s*'expense_application'/)
assert.match(submitComposerScript, /steward_continuation:\s*continuation/)
assert.match(createViewScript, /currentTask:\s*actionPayload\.steward_current_task/)
assert.match(suggestedActionsScript, /currentTask:\s*actionPayload\.steward_current_task/)
})
test('steward application slot fallback ignores non-blocking application fields', () => {
@@ -921,7 +1299,7 @@ test('steward application slot fallback ignores non-blocking application fields'
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.question/)
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/)
assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/)
assert.match(createViewScript, /normalizeTransportModeOption\(value, ''\)/)
assert.match(suggestedActionsScript, /normalizeTransportModeOption\(value, ''\)/)
assert.equal(normalizeTransportModeOption('高铁', ''), '火车')
assert.equal(normalizeTransportModeOption('自驾', ''), '')
assert.match(submitComposerScript, /function resolveBlockingApplicationMissingFieldsForSteward/)
@@ -1045,8 +1423,10 @@ test('assistant markdown tables render with component-scoped table styling', ()
assert.match(rendered, /<th/)
assert.match(rendered, /<td/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-table-wrap\) \{[\s\S]*overflow-x: auto;[\s\S]*border: 1px solid #dbe4ee;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 460px;[\s\S]*border-collapse: separate;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 560px;[\s\S]*table-layout: fixed;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;[\s\S]*overflow-wrap: break-word;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:first-child\),[\s\S]*\.message-answer-markdown :deep\(td:first-child\) \{[\s\S]*width: 88px;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:last-child\),[\s\S]*\.message-answer-markdown :deep\(td:last-child\) \{[\s\S]*width: 112px;[\s\S]*text-align: right;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
})
test('assistant reimbursement recognition copy renders structured markdown sections', () => {
@@ -1082,17 +1462,24 @@ test('application date overlap blocks steward preview before duplicate applicati
assert.match(submitComposerScript, /buildApplicationDateConflictMessage\(applicationDateConflict\)/)
assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/)
assert.match(submitComposerScript, /applicationPreview: pauseForMissingFields \? null : applicationPreview/)
assert.match(createViewScript, /actionType === 'open_application_detail'/)
assert.match(suggestedActionsScript, /actionType === 'open_application_detail'/)
})
test('application preview merges rule center travel estimate into highlighted rows', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天服务项目部署火车预计费用1800元', {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天服务项目部署火车预计费用1800元', {
name: '李文静',
grade: 'P5'
})
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' })
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, { days: 3, location: '上海', grade: 'P5' })
assert.deepEqual(request.payload, {
days: 3,
location: '上海',
grade: 'P5',
transport_mode: '火车',
origin_location: null,
travel_date: '2026-05-25'
})
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
days: 3,
@@ -1103,24 +1490,84 @@ test('application preview merges rule center travel estimate into highlighted ro
hotel_amount: 1800,
total_allowance_rate: 120,
allowance_amount: 360,
total_amount: 2160,
transport_mode: '火车',
transport_origin: '武汉',
transport_destination: '上海',
transport_estimated_amount: 720,
transport_estimate_basis: '武汉-上海火车往返二等座预估',
transport_estimate_source: 'basic_rule_transport_estimate',
transport_estimate_confidence: '基础规则',
total_amount: 2880,
rule_name: '公司差旅费报销规则',
rule_version: '2026版'
}, { grade: 'P5' })
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
assert.equal(estimatedPreview.fields.transportPolicy, '预估交通费用 1,100元')
assert.equal(estimatedPreview.fields.transportPolicy, '当前尚未接通实时票务价格查询 API无法获取当前实际票价先按《交通费用预估表》武汉-上海火车往返(二等座预估)暂估 720元用于申请阶段预算占用最终报销以实际票据金额为准')
assert.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
assert.equal(estimatedPreview.fields.transportEstimateDate, '2026-05-25')
assert.match(estimatedPreview.fields.transportQueryLatencyMs, /^\d+ms$/)
assert.equal(estimatedPreview.fields.amount, '3,260元')
assert.match(estimatedPreview.fields.policyEstimate, /交通 720元/)
assert.match(estimatedPreview.fields.policyEstimate, /2,880元/)
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '720元')
assert.equal(estimatedPreview.fields.transportEstimateSource, 'basic_rule_transport_estimate')
assert.equal(estimatedPreview.fields.transportQueryLatencyMs, '')
assert.equal(estimatedPreview.fields.amount, '2,880元')
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
})
test('application preview blocks policy estimate when transport mode is missing', () => {
const currentUser = { name: '李文静', grade: 'P5', location: '武汉' }
const preview = buildLocalApplicationPreview(
'我要申请2月20日-23日去上海出差辅助国网仿生产项目部署',
currentUser,
{ today: '2026-06-09' }
)
const request = buildApplicationPolicyEstimateRequest(preview, currentUser)
assert.equal(request.canCalculate, false)
assert.equal(request.reason, '缺少出行方式')
assert.equal(request.payload, null)
assert.equal(preview.missingFields.includes('出行方式'), true)
assert.equal(preview.readyToSubmit, false)
const staleEstimateResult = {
days: 4,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 250,
hotel_amount: 1000,
total_allowance_rate: 100,
allowance_amount: 400,
transport_mode: '火车',
transport_origin: '武汉',
transport_destination: '上海',
transport_estimated_amount: 720,
transport_estimate_basis: '武汉-上海火车往返二等座预估',
transport_estimate_source: 'basic_rule_transport_estimate',
transport_estimate_confidence: '基础规则',
total_amount: 2120,
travel_date: '2026-02-20',
rule_name: '差旅住宿报销标准',
rule_version: 'v1.0.0'
}
const blockedEstimatePreview = applyApplicationPolicyEstimateResult(preview, {
...staleEstimateResult,
transport_mode: ''
}, currentUser)
const staleEstimatePreview = applyApplicationPolicyEstimateResult(preview, staleEstimateResult, currentUser)
assert.equal(blockedEstimatePreview.fields.transportMode, '')
assert.equal(blockedEstimatePreview.fields.transportEstimatedAmount, '')
assert.equal(blockedEstimatePreview.fields.policyEstimate, '填写地点和天数后自动测算')
assert.equal(blockedEstimatePreview.missingFields.includes('出行方式'), true)
assert.equal(staleEstimatePreview.fields.reason, '辅助国网仿生产项目部署')
assert.equal(staleEstimatePreview.fields.transportMode, '火车')
assert.equal(staleEstimatePreview.missingFields.includes('出行方式'), false)
assert.equal(staleEstimatePreview.fields.transportPolicy, '当前尚未接通实时票务价格查询 API无法获取当前实际票价先按《交通费用预估表》武汉-上海火车往返(二等座预估)暂估 720元用于申请阶段预算占用最终报销以实际票据金额为准')
assert.match(staleEstimatePreview.fields.policyEstimate, /交通 720元/)
assert.equal(staleEstimatePreview.fields.amount, '2,120元')
})
test('application preview editor refreshes transport estimate after mode change', async () => {
const preview = applyApplicationPolicyEstimateResult(
buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天服务项目部署', {
@@ -1162,9 +1609,9 @@ test('application preview editor refreshes transport estimate after mode change'
assert.equal(committed, true)
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
assert.equal(message.applicationPreview.fields.amount, '4,490元')
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 2,330元')
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '1,380元')
assert.equal(message.applicationPreview.fields.amount, '3,540元')
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 1,380元')
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
assert.ok(persistCount >= 2)
@@ -1223,7 +1670,14 @@ test('application preview editor recalculates days and subsidy after date range
const committed = await editor.commitApplicationPreviewDateEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), { days: 4, location: '\u4e0a\u6d77', grade: 'P5' })
assert.deepEqual(requestedPayloads.at(-1), {
days: 4,
location: '\u4e0a\u6d77',
grade: 'P5',
transport_mode: '\u706b\u8f66',
origin_location: null,
travel_date: '2026-02-20'
})
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(message.applicationPreview.fields.days, '4\u5929')
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')

View File

@@ -13,6 +13,14 @@ const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const suggestedActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
'utf8'
)
const stewardRuntimeScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
'utf8'
)
const messageItemStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-item.css', import.meta.url)),
'utf8'
@@ -40,21 +48,21 @@ test('expense application submit uses rich text link and confirm dialog', () =>
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
)
assert.match(
createViewScript,
/async function confirmApplicationSubmit\(\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
stewardRuntimeScript,
/async function confirmApplicationSubmit\(options = \{\}\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
)
assert.match(
createViewScript,
stewardRuntimeScript,
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/
)
assert.match(
createViewScript,
stewardRuntimeScript,
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
)
assert.match(createViewScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
assert.match(createViewScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
assert.match(createViewScript, /meta:\s*\['行程规划推荐'\]/)
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
assert.match(createViewScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_SKIP/)
assert.match(stewardRuntimeScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
assert.match(stewardRuntimeScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
assert.match(stewardRuntimeScript, /meta:\s*\['行程规划推荐'\]/)
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
assert.match(suggestedActionsScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_SKIP/)
})

View File

@@ -69,6 +69,10 @@ const guidedFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementGuidedFlow.js', import.meta.url)),
'utf8'
)
const suggestedActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
'utf8'
)
const guidedModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementGuidedFlowModel.js', import.meta.url)),
'utf8'
@@ -85,7 +89,7 @@ const submitComposerScript = readFileSync(
test('assistant session modes expose independent quick actions', () => {
assert.deepEqual(
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
['申请助手', '报销助手', '审核助手', '财务知识助手', '预算编制助手']
['小财管家', '申请助手', '报销助手', '审核助手', '财务知识助手', '预算编制助手']
)
assert.deepEqual(
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
@@ -448,8 +452,8 @@ test('guided flow is local until final confirmation or collected query handoff',
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
assert.match(createViewScript, /handleSceneSelectionApplicationGate/)
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
assert.match(createViewScript, /actionPayload\.carry_text/)
assert.match(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
assert.match(suggestedActionsScript, /actionPayload\.carry_text/)
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
assert.match(submitComposerScript, /skipScopeGuard/)
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)

View File

@@ -23,6 +23,10 @@ const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const reviewPanelModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
'utf8'
)
const messageItemTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
'utf8'
@@ -173,9 +177,9 @@ test('local transport review no longer uses the travel hotel template', () => {
}
assert.equal(isTravelReviewPayload(reviewPayload, { expense_type: '交通费' }), false)
assert.match(createViewScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
assert.match(reviewPanelModelScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
assert.doesNotMatch(
createViewScript,
reviewPanelModelScript,
/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="例如:出租车\/网约车票据 \/ 火车\/高铁票"/)
@@ -279,7 +283,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
})
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/)
const riskItemsBlock = reviewPanelModelScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nexport function buildReviewRiskConversationText/)
assert.ok(riskItemsBlock, 'risk item builder should be present')
assert.doesNotMatch(createViewTemplate, /review-side-risk-score/)
@@ -288,9 +292,9 @@ test('review risk drawer lists risk briefs without score and posts details into
assert.doesNotMatch(createViewScript, /function buildReviewRiskScore/)
assert.doesNotMatch(createViewScript, /const reviewRiskScore/)
assert.doesNotMatch(riskItemsBlock[0], /\.slice\(0,\s*6\)/)
assert.match(createViewScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/)
assert.match(reviewPanelModelScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/)
assert.match(
createViewScript,
reviewPanelModelScript,
/function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/
)
@@ -300,17 +304,17 @@ 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*''\)/)
assert.match(createViewScript, /sourceLabel:\s*meta\.label/)
assert.doesNotMatch(createViewScript, /normalizedTitle\.includes\('AI预审'\)/)
assert.match(reviewPanelModelScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
assert.match(reviewPanelModelScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
assert.match(reviewPanelModelScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
assert.match(reviewPanelModelScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
assert.match(reviewPanelModelScript, /\$\{isInfo \? '提示内容' : '风险点'\}\$\{summary\}/)
assert.match(reviewPanelModelScript, /\$\{isInfo \? '处理建议' : '修改建议'\}\$\{suggestion\}/)
assert.match(reviewPanelModelScript, /function normalizeReviewRiskTitle/)
assert.match(reviewPanelModelScript, /\.replace\(\/AI\\s\*预审/)
assert.match(reviewPanelModelScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
assert.match(reviewPanelModelScript, /sourceLabel:\s*meta\.label/)
assert.doesNotMatch(reviewPanelModelScript, /normalizedTitle\.includes\('AI预审'\)/)
assert.match(createViewScript, /metaTone:\s*item\.level \|\| 'low'/)
assert.doesNotMatch(createViewTemplate, /@click="openReviewRiskDetail\(item\)"/)
assert.doesNotMatch(createViewTemplate, /review-risk-detail-modal/)
@@ -321,7 +325,7 @@ test('review risk drawer lists risk briefs without score and posts details into
createViewScript,
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
)
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
assert.match(reviewPanelModelScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-document-detail'/)
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
@@ -335,14 +339,14 @@ test('review drawer default mode is scoped by the current action and travel over
assert.match(reviewDrawerScript, /scope === 'documents' && hasDocuments[\s\S]*REVIEW_DRAWER_MODE_DOCUMENTS/)
assert.match(reviewDrawerScript, /scope === 'risk' && hasRisks[\s\S]*REVIEW_DRAWER_MODE_RISK/)
assert.match(reviewDrawerScript, /scope === 'overview'[\s\S]*REVIEW_DRAWER_MODE_REVIEW/)
assert.match(createViewScript, /function normalizeReviewPanelScope\(scope\)/)
assert.match(reviewPanelModelScript, /function normalizeReviewPanelScope\(scope\)/)
assert.match(createViewScript, /canExposeReviewPanelScope\(item\.reviewPanelScope\)/)
assert.match(createViewScript, /currentInsight\.value\.intent === 'agent' && agent[\s\S]*return null/)
assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/)
assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/)
assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
assert.match(createViewScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
assert.match(createViewScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
assert.match(reviewPanelModelScript, /function isTravelReviewPayload\(reviewPayload/)
assert.match(reviewPanelModelScript, /function resolveReviewTravelTransportType\(reviewPayload/)
assert.match(reviewPanelModelScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
assert.match(reviewPanelModelScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
assert.match(reviewPanelModelScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
assert.match(createViewTemplate, /item\.editor === 'textarea'[\s\S]*<textarea/)
assert.match(createViewTemplate, /wide: item\.wide/)
})
@@ -414,8 +418,8 @@ test('composer exposes travel calculator and posts spreadsheet-backed result int
})
test('continuing receipt upload preserves prior review form context', () => {
assert.match(createViewScript, /function buildReviewFormContextFromPayload\(reviewPayload, inlineState = null\)/)
assert.match(createViewScript, /function buildBusinessTimeContextFromReviewValues\(values = \{\}\)/)
assert.match(reviewPanelModelScript, /function buildReviewFormContextFromPayload\(reviewPayload, inlineState = null\)/)
assert.match(reviewPanelModelScript, /function buildBusinessTimeContextFromReviewValues\(values = \{\}\)/)
assert.match(
createViewScript,
/resolvedUploadDisposition === 'continue_existing'[\s\S]*buildReviewFormContextFromPayload\([\s\S]*activeReviewPayload\.value[\s\S]*reviewInlineForm\.value[\s\S]*extraContext\.review_form_values/s