feat(web): 文档查询意图补充风险过滤与 X 天前范围
- aiDocumentQueryIntent 新增风险等级过滤(无/高/中/低/有风险)与 N 天前日期范围解析 - aiDocumentQueryModel/aiConversationHtmlRenderer 渲染适配风险过滤标签 - useWorkbenchAiDocumentQueryFlow/aiWorkbenchConversationStore 会话流转适配命令意图 - 更新 ai-document-query-model/ai-conversation-html-renderer/assistant-session-draft-delete 测试
This commit is contained in:
@@ -2,6 +2,7 @@ import { nextTick } from 'vue'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildAiDocumentQueryConditionSummary,
|
buildAiDocumentQueryConditionSummary,
|
||||||
|
buildAiDocumentQueryThinkingEvents,
|
||||||
buildAiDocumentQueryMessage,
|
buildAiDocumentQueryMessage,
|
||||||
filterAiDocumentQueryRecords,
|
filterAiDocumentQueryRecords,
|
||||||
mergeAiDocumentQueryPayloads,
|
mergeAiDocumentQueryPayloads,
|
||||||
@@ -100,33 +101,20 @@ export function useWorkbenchAiDocumentQueryFlow({
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAiDocumentQueryIntent(prompt, pendingMessage) {
|
async function handleAiDocumentQueryIntent(prompt, pendingMessage, options = {}) {
|
||||||
const intent = resolveAiDocumentQueryIntent(prompt)
|
const intent = resolveAiDocumentQueryIntent(prompt)
|
||||||
if (!intent) {
|
if (!intent) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
|
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
|
||||||
let thinkingEvents = [
|
let thinkingEvents = buildAiDocumentQueryThinkingEvents(intent, {
|
||||||
{
|
commandFrame: options.commandFrame
|
||||||
eventId: 'document-query-parse',
|
}).map((event) => (
|
||||||
title: '解析自然语言筛选条件',
|
event.eventId === 'document-query-fetch'
|
||||||
content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
|
? { ...event, content: resolveAiDocumentQueryFetchPendingText(intent) }
|
||||||
status: 'running'
|
: event
|
||||||
},
|
))
|
||||||
{
|
|
||||||
eventId: 'document-query-fetch',
|
|
||||||
title: '查询业务单据接口',
|
|
||||||
content: resolveAiDocumentQueryFetchPendingText(intent),
|
|
||||||
status: 'pending'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
eventId: 'document-query-filter',
|
|
||||||
title: '组合筛选单据',
|
|
||||||
content: '等待接口返回后,再按已识别条件做二次筛选。',
|
|
||||||
status: 'pending'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||||
await waitForAiDocumentQueryStep()
|
await waitForAiDocumentQueryStep()
|
||||||
|
|
||||||
@@ -163,7 +151,9 @@ export function useWorkbenchAiDocumentQueryFlow({
|
|||||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||||
await waitForAiDocumentQueryStep()
|
await waitForAiDocumentQueryStep()
|
||||||
|
|
||||||
const finalMessageText = buildAiDocumentQueryMessage(intent, payload)
|
const finalMessageText = buildAiDocumentQueryMessage(intent, payload, {
|
||||||
|
commandFrame: options.commandFrame
|
||||||
|
})
|
||||||
thinkingEvents = completeAiDocumentQueryEvent(
|
thinkingEvents = completeAiDocumentQueryEvent(
|
||||||
thinkingEvents,
|
thinkingEvents,
|
||||||
'document-query-filter',
|
'document-query-filter',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
|
|
||||||
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
||||||
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
||||||
|
const DELETED_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-deleted-document-detail:'
|
||||||
|
|
||||||
function escapeHtml(value = '') {
|
function escapeHtml(value = '') {
|
||||||
return String(value)
|
return String(value)
|
||||||
@@ -39,6 +40,10 @@ function isDocumentDetailHref(href = '') {
|
|||||||
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
|
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDeletedDocumentDetailHref(href = '') {
|
||||||
|
return String(href || '').trim().startsWith(DELETED_DOCUMENT_DETAIL_HREF_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeImageSrc(src = '') {
|
function sanitizeImageSrc(src = '') {
|
||||||
const value = String(src || '').trim()
|
const value = String(src || '').trim()
|
||||||
if (/^(https?:\/\/|blob:|\/)/i.test(value)) {
|
if (/^(https?:\/\/|blob:|\/)/i.test(value)) {
|
||||||
@@ -63,6 +68,17 @@ function renderLinkHtml(label = '', href = '') {
|
|||||||
'</span>'
|
'</span>'
|
||||||
].join('')
|
].join('')
|
||||||
}
|
}
|
||||||
|
if (isDeletedDocumentDetailHref(href)) {
|
||||||
|
return [
|
||||||
|
'<span',
|
||||||
|
' class="ai-html-action-link ai-html-action-link-document is-disabled"',
|
||||||
|
' data-ai-action="deleted-document-detail"',
|
||||||
|
' aria-disabled="true"',
|
||||||
|
'>',
|
||||||
|
label,
|
||||||
|
'</span>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
if (isApplicationDetailHref(href)) {
|
if (isApplicationDetailHref(href)) {
|
||||||
return [
|
return [
|
||||||
`<a href="${sanitizedHref}"`,
|
`<a href="${sanitizedHref}"`,
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ const EXPENSE_TYPE_FILTERS = [
|
|||||||
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
|
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const RISK_FILTERS = [
|
||||||
|
{ label: '无风险', level: 'none', pattern: /无风险|没有风险|暂无风险|无异常|合规/ },
|
||||||
|
{ label: '高风险', level: 'high', pattern: /高风险/ },
|
||||||
|
{ label: '中风险', level: 'medium', pattern: /中风险/ },
|
||||||
|
{ label: '低风险', level: 'low', pattern: /低风险/ },
|
||||||
|
{ label: '有风险', level: 'has', pattern: /风险|异常|超标/ }
|
||||||
|
]
|
||||||
|
|
||||||
function resolveToday(options = {}) {
|
function resolveToday(options = {}) {
|
||||||
return parseDate(options.today) || new Date()
|
return parseDate(options.today) || new Date()
|
||||||
}
|
}
|
||||||
@@ -82,6 +90,15 @@ function resolveTimeRange(prompt, options = {}) {
|
|||||||
return { start: value, end: value, label: '昨天' }
|
return { start: value, end: value, label: '昨天' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const daysAgo = text.match(/(?<days>\d{1,3})天前/)
|
||||||
|
if (daysAgo?.groups?.days) {
|
||||||
|
const days = Math.max(0, Number(daysAgo.groups.days))
|
||||||
|
const date = new Date(today.getTime())
|
||||||
|
date.setUTCDate(date.getUTCDate() - days)
|
||||||
|
const value = formatDate(date)
|
||||||
|
return { start: value, end: value, label: `${days}天前` }
|
||||||
|
}
|
||||||
|
|
||||||
if (/本月|这个月|当月/.test(text)) {
|
if (/本月|这个月|当月/.test(text)) {
|
||||||
return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1)
|
return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1)
|
||||||
}
|
}
|
||||||
@@ -129,6 +146,11 @@ function resolveExpenseTypeFilter(prompt) {
|
|||||||
return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null
|
return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRiskFilter(prompt) {
|
||||||
|
const text = compactText(prompt)
|
||||||
|
return RISK_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAmountText(value = '') {
|
function normalizeAmountText(value = '') {
|
||||||
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
|
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
@@ -191,13 +213,13 @@ function resolveKeywordFilter(prompt) {
|
|||||||
|
|
||||||
function resolveSource(prompt) {
|
function resolveSource(prompt) {
|
||||||
const text = compactText(prompt)
|
const text = compactText(prompt)
|
||||||
if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) {
|
if (/审核单|审批单|待办|待审|待审核|待审批|我审批|我审核|我要审批|我要审核|去审批|去审核|处理审批|处理审核/.test(text)) {
|
||||||
return {
|
return {
|
||||||
source: 'approval',
|
source: 'approval',
|
||||||
sourceLabel: '待我审核的单据'
|
sourceLabel: '待我审核的单据'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) {
|
if (/我名下|我发起|我提交|我创建|我的/.test(text)) {
|
||||||
return {
|
return {
|
||||||
source: 'mine',
|
source: 'mine',
|
||||||
sourceLabel: '我的单据'
|
sourceLabel: '我的单据'
|
||||||
@@ -211,7 +233,13 @@ function resolveSource(prompt) {
|
|||||||
|
|
||||||
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
||||||
const text = compactText(prompt)
|
const text = compactText(prompt)
|
||||||
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) {
|
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待办|待审|待审批|待审核|我要审核|我要审批|去审核|去审批|处理审核|处理审批|草稿)/.test(text)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/(标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗)/.test(text) &&
|
||||||
|
!/(单据|单子|申请单|报销单|审核单|审批单|待办|待审|待审批|待审核)/.test(text)
|
||||||
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
|
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
|
||||||
@@ -223,6 +251,7 @@ export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
|||||||
const expenseTypeFilter = resolveExpenseTypeFilter(text)
|
const expenseTypeFilter = resolveExpenseTypeFilter(text)
|
||||||
const keywordFilter = resolveKeywordFilter(prompt)
|
const keywordFilter = resolveKeywordFilter(prompt)
|
||||||
const amountFilter = resolveAmountFilter(text)
|
const amountFilter = resolveAmountFilter(text)
|
||||||
|
const riskFilter = resolveRiskFilter(text)
|
||||||
return {
|
return {
|
||||||
...source,
|
...source,
|
||||||
documentType,
|
documentType,
|
||||||
@@ -235,6 +264,7 @@ export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
expenseTypeFilter,
|
expenseTypeFilter,
|
||||||
keywordFilter,
|
keywordFilter,
|
||||||
amountFilter
|
amountFilter,
|
||||||
|
riskFilter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { extractExpenseClaimItems } from '../services/reimbursements.js'
|
|||||||
import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js'
|
import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js'
|
||||||
import { isApplicationDocumentNo } from './documentClassification.js'
|
import { isApplicationDocumentNo } from './documentClassification.js'
|
||||||
import { compactText, normalizeDateText, normalizeText, parseDate } from './aiDocumentQueryText.js'
|
import { compactText, normalizeDateText, normalizeText, parseDate } from './aiDocumentQueryText.js'
|
||||||
|
import { filterActionableRiskFlags, isRiskSummaryWithRisk, normalizeRiskFlagTone } from './riskFlags.js'
|
||||||
|
|
||||||
export { resolveAiDocumentQueryIntent } from './aiDocumentQueryIntent.js'
|
export { resolveAiDocumentQueryIntent } from './aiDocumentQueryIntent.js'
|
||||||
|
|
||||||
@@ -36,6 +37,26 @@ const TYPE_LABELS = {
|
|||||||
other: '其他费用'
|
other: '其他费用'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMMAND_ACTION_LABELS = {
|
||||||
|
delete: '删除',
|
||||||
|
approve: '审核通过',
|
||||||
|
reject: '驳回/退回'
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_ACTION_VERBS = {
|
||||||
|
delete: '删除',
|
||||||
|
approve: '审核',
|
||||||
|
reject: '驳回或退回'
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_OBJECT_LABELS = {
|
||||||
|
draft: '草稿',
|
||||||
|
application: '申请单',
|
||||||
|
reimbursement: '报销单',
|
||||||
|
approval_task: '待审任务',
|
||||||
|
document: '单据'
|
||||||
|
}
|
||||||
|
|
||||||
const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'CNY',
|
currency: 'CNY',
|
||||||
@@ -155,6 +176,54 @@ function resolveRecordQuerySource(claim = {}, intent = {}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRiskFlags(claim = {}) {
|
||||||
|
if (Array.isArray(claim.risk_flags_json)) {
|
||||||
|
return claim.risk_flags_json
|
||||||
|
}
|
||||||
|
if (Array.isArray(claim.riskFlags)) {
|
||||||
|
return claim.riskFlags
|
||||||
|
}
|
||||||
|
if (Array.isArray(claim.review_flags)) {
|
||||||
|
return claim.review_flags
|
||||||
|
}
|
||||||
|
if (Array.isArray(claim.reviewFlags)) {
|
||||||
|
return claim.reviewFlags
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRiskSummary(claim = {}) {
|
||||||
|
return normalizeText(
|
||||||
|
claim.risk_summary ||
|
||||||
|
claim.riskSummary ||
|
||||||
|
claim.risk ||
|
||||||
|
claim.risk_label ||
|
||||||
|
claim.riskLabel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRecordRiskMeta(claim = {}) {
|
||||||
|
const flags = filterActionableRiskFlags(resolveRiskFlags(claim))
|
||||||
|
const summary = resolveRiskSummary(claim)
|
||||||
|
if (!flags.length && !isRiskSummaryWithRisk(summary)) {
|
||||||
|
return { hasRisk: false, riskTone: 'none', riskLabel: '无风险' }
|
||||||
|
}
|
||||||
|
const tones = flags.map((flag) => normalizeRiskFlagTone(flag))
|
||||||
|
const riskTone = tones.includes('high')
|
||||||
|
? 'high'
|
||||||
|
: tones.includes('medium')
|
||||||
|
? 'medium'
|
||||||
|
: tones.includes('low')
|
||||||
|
? 'low'
|
||||||
|
: 'medium'
|
||||||
|
const riskLabel = riskTone === 'high'
|
||||||
|
? '高风险'
|
||||||
|
: riskTone === 'medium'
|
||||||
|
? '中风险'
|
||||||
|
: '低风险'
|
||||||
|
return { hasRisk: true, riskTone, riskLabel }
|
||||||
|
}
|
||||||
|
|
||||||
function isApprovalTaskClaim(claim = {}, intent = {}) {
|
function isApprovalTaskClaim(claim = {}, intent = {}) {
|
||||||
return resolveRecordQuerySource(claim, intent) === 'approval'
|
return resolveRecordQuerySource(claim, intent) === 'approval'
|
||||||
}
|
}
|
||||||
@@ -381,6 +450,7 @@ function normalizeRecord(claim = {}, intent = {}) {
|
|||||||
const rawStatusLabel = resolveStatusLabel(claim)
|
const rawStatusLabel = resolveStatusLabel(claim)
|
||||||
const querySource = resolveRecordQuerySource(claim, intent)
|
const querySource = resolveRecordQuerySource(claim, intent)
|
||||||
const isApprovalTask = isApprovalTaskClaim(claim, intent)
|
const isApprovalTask = isApprovalTaskClaim(claim, intent)
|
||||||
|
const riskMeta = resolveRecordRiskMeta(claim)
|
||||||
const statusLabel = isApprovalTask && isPendingApprovalStatus(statusKey, rawStatusLabel)
|
const statusLabel = isApprovalTask && isPendingApprovalStatus(statusKey, rawStatusLabel)
|
||||||
? '待审批'
|
? '待审批'
|
||||||
: rawStatusLabel
|
: rawStatusLabel
|
||||||
@@ -402,6 +472,9 @@ function normalizeRecord(claim = {}, intent = {}) {
|
|||||||
statusKey,
|
statusKey,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
statusTone: resolveStatusTone(statusLabel),
|
statusTone: resolveStatusTone(statusLabel),
|
||||||
|
hasRisk: riskMeta.hasRisk,
|
||||||
|
riskTone: riskMeta.riskTone,
|
||||||
|
riskLabel: riskMeta.riskLabel,
|
||||||
querySource,
|
querySource,
|
||||||
isApprovalTask,
|
isApprovalTask,
|
||||||
reason,
|
reason,
|
||||||
@@ -417,7 +490,8 @@ function normalizeRecord(claim = {}, intent = {}) {
|
|||||||
departmentLabel,
|
departmentLabel,
|
||||||
locationLabel,
|
locationLabel,
|
||||||
typeLabel,
|
typeLabel,
|
||||||
statusLabel
|
statusLabel,
|
||||||
|
riskMeta.riskLabel
|
||||||
].join(' '))
|
].join(' '))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,6 +533,19 @@ function matchesAmountFilter(record = {}, amountFilter = null) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesRiskFilter(record = {}, riskFilter = null) {
|
||||||
|
if (!riskFilter?.level) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (riskFilter.level === 'none') {
|
||||||
|
return !record.hasRisk
|
||||||
|
}
|
||||||
|
if (riskFilter.level === 'has') {
|
||||||
|
return record.hasRisk
|
||||||
|
}
|
||||||
|
return record.hasRisk && record.riskTone === riskFilter.level
|
||||||
|
}
|
||||||
|
|
||||||
export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) {
|
export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) {
|
||||||
const rows = extractExpenseClaimItems(claimsPayload)
|
const rows = extractExpenseClaimItems(claimsPayload)
|
||||||
.map((claim) => normalizeRecord(claim, intent))
|
.map((claim) => normalizeRecord(claim, intent))
|
||||||
@@ -472,6 +559,7 @@ export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) {
|
|||||||
.filter((record) => matchesExpenseTypeFilter(record, intent?.expenseTypeFilter))
|
.filter((record) => matchesExpenseTypeFilter(record, intent?.expenseTypeFilter))
|
||||||
.filter((record) => matchesKeywordFilter(record, intent?.keywordFilter))
|
.filter((record) => matchesKeywordFilter(record, intent?.keywordFilter))
|
||||||
.filter((record) => matchesAmountFilter(record, intent?.amountFilter))
|
.filter((record) => matchesAmountFilter(record, intent?.amountFilter))
|
||||||
|
.filter((record) => matchesRiskFilter(record, intent?.riskFilter))
|
||||||
.sort((left, right) => toTimestamp(right.dateKey) - toTimestamp(left.dateKey))
|
.sort((left, right) => toTimestamp(right.dateKey) - toTimestamp(left.dateKey))
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
@@ -492,7 +580,40 @@ function buildDocumentCardFieldHtml(label = '', value = '', options = {}) {
|
|||||||
].join('')
|
].join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDocumentCardHtml(record = {}) {
|
function resolveCommandDetailActionLabel(commandFrame = null) {
|
||||||
|
if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') {
|
||||||
|
return '查看详情'
|
||||||
|
}
|
||||||
|
if (commandFrame.action === 'delete') {
|
||||||
|
return '进入详情确认删除'
|
||||||
|
}
|
||||||
|
if (commandFrame.action === 'approve') {
|
||||||
|
return '进入详情确认审核'
|
||||||
|
}
|
||||||
|
if (commandFrame.action === 'reject') {
|
||||||
|
return '进入详情确认驳回'
|
||||||
|
}
|
||||||
|
return '进入详情确认'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCommandConfirmationGuidanceHtml(commandFrame = null) {
|
||||||
|
if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const actionVerb = COMMAND_ACTION_VERBS[commandFrame.action] || '操作'
|
||||||
|
const ctaLabel = resolveCommandDetailActionLabel(commandFrame)
|
||||||
|
return [
|
||||||
|
'<section class="ai-document-command-guidance" aria-label="高风险操作提示">',
|
||||||
|
'<strong class="ai-document-command-guidance__title">需要先确认目标单据</strong>',
|
||||||
|
'<div class="ai-document-command-guidance__body">',
|
||||||
|
`系统不会直接${escapeHtml(actionVerb)}相关单据。`,
|
||||||
|
`如果您希望继续${escapeHtml(actionVerb)}单据,请点击下方候选单据里的快捷按钮“${escapeHtml(ctaLabel)}”,进入单据详情核对后再操作。`,
|
||||||
|
'</div>',
|
||||||
|
'</section>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentCardHtml(record = {}, options = {}) {
|
||||||
const href = buildAiDocumentDetailHref(record)
|
const href = buildAiDocumentDetailHref(record)
|
||||||
const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement'
|
const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement'
|
||||||
const approvalTaskClass = record.isApprovalTask ? ' ai-document-card--approval-task' : ''
|
const approvalTaskClass = record.isApprovalTask ? ' ai-document-card--approval-task' : ''
|
||||||
@@ -503,12 +624,14 @@ function buildDocumentCardHtml(record = {}) {
|
|||||||
const ownerText = [record.ownerLabel, record.departmentLabel]
|
const ownerText = [record.ownerLabel, record.departmentLabel]
|
||||||
.filter((item) => item && item !== '未显示')
|
.filter((item) => item && item !== '未显示')
|
||||||
.join(' · ') || '未显示'
|
.join(' · ') || '未显示'
|
||||||
|
const detailActionLabel = resolveCommandDetailActionLabel(options.commandFrame)
|
||||||
const summaryHtml = [
|
const summaryHtml = [
|
||||||
buildDocumentCardFieldHtml('日期', record.time || '待补充'),
|
buildDocumentCardFieldHtml('日期', record.time || '待补充'),
|
||||||
buildDocumentCardFieldHtml(amountLabel, record.amountLabel, { valueClass: 'ai-document-card__amount' })
|
buildDocumentCardFieldHtml(amountLabel, record.amountLabel, { valueClass: 'ai-document-card__amount' })
|
||||||
].join('')
|
].join('')
|
||||||
const detailsHtml = [
|
const detailsHtml = [
|
||||||
buildDocumentCardFieldHtml('地点', record.locationLabel || '待补充'),
|
buildDocumentCardFieldHtml('地点', record.locationLabel || '待补充'),
|
||||||
|
buildDocumentCardFieldHtml('风险', record.riskLabel || '无风险'),
|
||||||
buildDocumentCardFieldHtml('单据编号', record.documentNo || '未编号单据', { valueClass: 'ai-document-card__number' }),
|
buildDocumentCardFieldHtml('单据编号', record.documentNo || '未编号单据', { valueClass: 'ai-document-card__number' }),
|
||||||
buildDocumentCardFieldHtml('事由', record.reason || '待补充'),
|
buildDocumentCardFieldHtml('事由', record.reason || '待补充'),
|
||||||
buildDocumentCardFieldHtml('申请人', ownerText),
|
buildDocumentCardFieldHtml('申请人', ownerText),
|
||||||
@@ -516,7 +639,7 @@ function buildDocumentCardHtml(record = {}) {
|
|||||||
'<div class="ai-document-card__field ai-document-card__field--action">',
|
'<div class="ai-document-card__field ai-document-card__field--action">',
|
||||||
'<span class="ai-document-card__label">操作</span>',
|
'<span class="ai-document-card__label">操作</span>',
|
||||||
href
|
href
|
||||||
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">查看详情</a>`
|
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">${escapeHtml(detailActionLabel)}</a>`
|
||||||
: '<span class="ai-document-card__value">暂无详情</span>',
|
: '<span class="ai-document-card__value">暂无详情</span>',
|
||||||
'</div>'
|
'</div>'
|
||||||
].join(''),
|
].join(''),
|
||||||
@@ -556,11 +679,13 @@ function buildDocumentQuerySummaryHtml(scopeText = '', totalCount = 0, visibleCo
|
|||||||
|
|
||||||
function buildDocumentCardsHtml(records = [], options = {}) {
|
function buildDocumentCardsHtml(records = [], options = {}) {
|
||||||
const querySummaryHtml = options.querySummaryHtml || ''
|
const querySummaryHtml = options.querySummaryHtml || ''
|
||||||
|
const commandGuidanceHtml = buildCommandConfirmationGuidanceHtml(options.commandFrame)
|
||||||
return [
|
return [
|
||||||
'<!-- ai-trusted-html:start -->',
|
'<!-- ai-trusted-html:start -->',
|
||||||
|
commandGuidanceHtml,
|
||||||
querySummaryHtml,
|
querySummaryHtml,
|
||||||
'<section class="ai-document-card-list" aria-label="单据查询结果">',
|
'<section class="ai-document-card-list" aria-label="单据查询结果">',
|
||||||
...records.map((record) => buildDocumentCardHtml(record)),
|
...records.map((record) => buildDocumentCardHtml(record, options)),
|
||||||
'</section>',
|
'</section>',
|
||||||
'<!-- ai-trusted-html:end -->'
|
'<!-- ai-trusted-html:end -->'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
@@ -574,7 +699,8 @@ function buildQueryScopeText(intent = {}) {
|
|||||||
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
|
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
|
||||||
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
|
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
|
||||||
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
|
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
|
||||||
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : ''
|
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '',
|
||||||
|
intent.riskFilter?.label ? `风险:${intent.riskFilter.label}` : ''
|
||||||
].filter(Boolean).join(' / ')
|
].filter(Boolean).join(' / ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,12 +712,52 @@ export function buildAiDocumentQueryConditionSummary(intent = {}) {
|
|||||||
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
|
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
|
||||||
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
|
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
|
||||||
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
|
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
|
||||||
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : ''
|
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '',
|
||||||
|
intent.riskFilter?.label ? `风险:${intent.riskFilter.label}` : ''
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
return conditions.join(';')
|
return conditions.join(';')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
|
function buildAiDocumentCommandPolicyEvent(commandFrame = null) {
|
||||||
|
if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const actionLabel = COMMAND_ACTION_LABELS[commandFrame.action] || '高风险操作'
|
||||||
|
const objectLabel = COMMAND_OBJECT_LABELS[commandFrame.objectType] || '单据'
|
||||||
|
return {
|
||||||
|
eventId: 'document-command-policy',
|
||||||
|
title: '识别高风险操作意图',
|
||||||
|
content: `识别到用户想执行“${actionLabel}”动作,对象是“${objectLabel}”。这类动作不会直接执行,我会先筛选候选单据,再让用户进入详情页确认。`,
|
||||||
|
status: 'running'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAiDocumentQueryThinkingEvents(intent = {}, options = {}) {
|
||||||
|
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
|
||||||
|
return [
|
||||||
|
buildAiDocumentCommandPolicyEvent(options.commandFrame),
|
||||||
|
{
|
||||||
|
eventId: 'document-query-parse',
|
||||||
|
title: '解析自然语言筛选条件',
|
||||||
|
content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、风险、关键词和金额条件。当前识别:${conditionSummary}。`,
|
||||||
|
status: 'running'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'document-query-fetch',
|
||||||
|
title: '查询业务单据接口',
|
||||||
|
content: '等待调用业务单据接口。',
|
||||||
|
status: 'pending'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'document-query-filter',
|
||||||
|
title: '组合筛选单据',
|
||||||
|
content: '等待接口返回后,再按已识别条件做二次筛选。',
|
||||||
|
status: 'pending'
|
||||||
|
}
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = [], options = {}) {
|
||||||
const records = filterAiDocumentQueryRecords(claimsPayload, intent)
|
const records = filterAiDocumentQueryRecords(claimsPayload, intent)
|
||||||
const visibleRecords = records.slice(0, DOCUMENT_QUERY_LIMIT)
|
const visibleRecords = records.slice(0, DOCUMENT_QUERY_LIMIT)
|
||||||
const scopeText = buildQueryScopeText(intent)
|
const scopeText = buildQueryScopeText(intent)
|
||||||
@@ -610,7 +776,8 @@ export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
|
|||||||
'### 已查询到相关单据',
|
'### 已查询到相关单据',
|
||||||
'',
|
'',
|
||||||
buildDocumentCardsHtml(visibleRecords, {
|
buildDocumentCardsHtml(visibleRecords, {
|
||||||
querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length)
|
querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length),
|
||||||
|
commandFrame: options.commandFrame
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ const MAX_CONVERSATION_HISTORY = 30
|
|||||||
const MAX_STORED_MESSAGES = 80
|
const MAX_STORED_MESSAGES = 80
|
||||||
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
||||||
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
||||||
|
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
||||||
|
const DELETED_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-deleted-document-detail:'
|
||||||
const APPLICATION_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-application-detail:[^)]+)\)/g
|
const APPLICATION_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-application-detail:[^)]+)\)/g
|
||||||
|
const DOCUMENT_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-document-detail:[^)]+)\)/g
|
||||||
|
|
||||||
function safeString(value) {
|
function safeString(value) {
|
||||||
return String(value || '').trim()
|
return String(value || '').trim()
|
||||||
@@ -25,12 +28,12 @@ function collectDeletedDraftIdentifiers(payload = {}) {
|
|||||||
].map((item) => normalizeIdentifier(item)).filter(Boolean))
|
].map((item) => normalizeIdentifier(item)).filter(Boolean))
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeApplicationDetailHref(href = '') {
|
function decodeDetailHref(href = '', prefix = '') {
|
||||||
const value = safeString(href)
|
const value = safeString(href)
|
||||||
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
|
if (!value.startsWith(prefix)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const encodedReference = value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)
|
const encodedReference = value.slice(prefix.length)
|
||||||
if (!encodedReference) {
|
if (!encodedReference) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -54,10 +57,22 @@ function decodeApplicationDetailHref(href = '') {
|
|||||||
return [...identifiers]
|
return [...identifiers]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeApplicationDetailHref(href = '') {
|
||||||
|
return decodeDetailHref(href, APPLICATION_DETAIL_HREF_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeDocumentDetailHref(href = '') {
|
||||||
|
return decodeDetailHref(href, DOCUMENT_DETAIL_HREF_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
function applicationDetailHrefMatchesDeletedDraft(href = '', identifiers = new Set()) {
|
function applicationDetailHrefMatchesDeletedDraft(href = '', identifiers = new Set()) {
|
||||||
return decodeApplicationDetailHref(href).some((item) => identifiers.has(item))
|
return decodeApplicationDetailHref(href).some((item) => identifiers.has(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function documentDetailHrefMatchesDeletedDocument(href = '', identifiers = new Set()) {
|
||||||
|
return decodeDocumentDetailHref(href).some((item) => identifiers.has(item))
|
||||||
|
}
|
||||||
|
|
||||||
function buildDeletedApplicationDetailHref(href = '') {
|
function buildDeletedApplicationDetailHref(href = '') {
|
||||||
const value = safeString(href)
|
const value = safeString(href)
|
||||||
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
|
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
|
||||||
@@ -66,6 +81,14 @@ function buildDeletedApplicationDetailHref(href = '') {
|
|||||||
return `${DELETED_APPLICATION_DETAIL_HREF_PREFIX}${value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)}`
|
return `${DELETED_APPLICATION_DETAIL_HREF_PREFIX}${value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDeletedDocumentDetailHref(href = '') {
|
||||||
|
const value = safeString(href)
|
||||||
|
if (!value.startsWith(DOCUMENT_DETAIL_HREF_PREFIX)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `${DELETED_DOCUMENT_DETAIL_HREF_PREFIX}${value.slice(DOCUMENT_DETAIL_HREF_PREFIX.length)}`
|
||||||
|
}
|
||||||
|
|
||||||
function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()) {
|
function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()) {
|
||||||
let changed = false
|
let changed = false
|
||||||
const nextContent = String(content || '').replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
|
const nextContent = String(content || '').replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
|
||||||
@@ -82,6 +105,33 @@ function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()
|
|||||||
return { content: nextContent, changed }
|
return { content: nextContent, changed }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markDocumentDetailLinksDeleted(content = '', identifiers = new Set()) {
|
||||||
|
let changed = false
|
||||||
|
const afterDocumentLinks = String(content || '').replace(DOCUMENT_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
|
||||||
|
if (!documentDetailHrefMatchesDeletedDocument(href, identifiers)) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
const deletedHref = buildDeletedDocumentDetailHref(href)
|
||||||
|
if (!deletedHref) {
|
||||||
|
return '单据已删除'
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return `[单据已删除](${deletedHref})`
|
||||||
|
})
|
||||||
|
const nextContent = afterDocumentLinks.replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
|
||||||
|
if (!applicationDetailHrefMatchesDeletedDraft(href, identifiers)) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
const deletedHref = buildDeletedApplicationDetailHref(href)
|
||||||
|
if (!deletedHref) {
|
||||||
|
return '单据已删除'
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return `[单据已删除](${deletedHref})`
|
||||||
|
})
|
||||||
|
return { content: nextContent, changed }
|
||||||
|
}
|
||||||
|
|
||||||
function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
|
function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
|
||||||
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
return [
|
return [
|
||||||
@@ -98,7 +148,8 @@ function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
|
|||||||
function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
|
function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
|
||||||
let changed = false
|
let changed = false
|
||||||
const nextActions = (Array.isArray(actions) ? actions : []).map((action) => {
|
const nextActions = (Array.isArray(actions) ? actions : []).map((action) => {
|
||||||
if (String(action?.action_type || '').trim() !== 'open_application_detail') {
|
const actionType = String(action?.action_type || '').trim()
|
||||||
|
if (!['open_application_detail', 'open_document_detail'].includes(actionType)) {
|
||||||
return action
|
return action
|
||||||
}
|
}
|
||||||
if (!actionMatchesDeletedDraft(action, identifiers)) {
|
if (!actionMatchesDeletedDraft(action, identifiers)) {
|
||||||
@@ -111,7 +162,9 @@ function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
|
|||||||
description: '草稿单据已经删除,请重新再次申请。',
|
description: '草稿单据已经删除,请重新再次申请。',
|
||||||
icon: 'mdi mdi-trash-can-outline',
|
icon: 'mdi mdi-trash-can-outline',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
action_type: 'deleted_application_detail'
|
action_type: actionType === 'open_document_detail'
|
||||||
|
? 'deleted_document_detail'
|
||||||
|
: 'deleted_application_detail'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return { actions: nextActions, changed }
|
return { actions: nextActions, changed }
|
||||||
@@ -132,6 +185,21 @@ function buildDraftDeletedMessage(payload = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDocumentDeletedMessage(payload = {}) {
|
||||||
|
const claimNo = safeString(payload.claimNo || payload.claim_no || payload.documentNo || payload.document_no)
|
||||||
|
return {
|
||||||
|
id: `document-deleted-${safeString(payload.claimId || payload.claim_id || payload.id || claimNo) || Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
`单据${claimNo ? ` ${claimNo}` : ''} 已经删除或不可访问。`,
|
||||||
|
'该历史入口已失效,请返回单据列表重新查询。'
|
||||||
|
].join('\n\n'),
|
||||||
|
feedback: '',
|
||||||
|
stewardPlan: null,
|
||||||
|
suggestedActions: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function conversationHasDeletionNotice(messages = [], identifiers = new Set()) {
|
function conversationHasDeletionNotice(messages = [], identifiers = new Set()) {
|
||||||
return messages.some((message) => {
|
return messages.some((message) => {
|
||||||
const content = safeString(message?.content)
|
const content = safeString(message?.content)
|
||||||
@@ -139,6 +207,13 @@ function conversationHasDeletionNotice(messages = [], identifiers = new Set()) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function conversationHasDocumentDeletionNotice(messages = [], identifiers = new Set()) {
|
||||||
|
return messages.some((message) => {
|
||||||
|
const content = safeString(message?.content)
|
||||||
|
return content.includes('已经删除或不可访问') && [...identifiers].some((item) => content.includes(item))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function resolveUserStorageKey(user = {}) {
|
function resolveUserStorageKey(user = {}) {
|
||||||
const identity = safeString(user.username || user.email || user.name || 'anonymous')
|
const identity = safeString(user.username || user.email || user.name || 'anonymous')
|
||||||
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
|
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
|
||||||
@@ -329,3 +404,46 @@ export function markAiWorkbenchConversationDraftDeleted(user = {}, payload = {})
|
|||||||
writeStoredList(user, nextList)
|
writeStoredList(user, nextList)
|
||||||
return loadAiWorkbenchConversationHistory(user)
|
return loadAiWorkbenchConversationHistory(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function markAiWorkbenchConversationDocumentDeleted(user = {}, payload = {}) {
|
||||||
|
const identifiers = collectDeletedDraftIdentifiers(payload)
|
||||||
|
if (!identifiers.size) {
|
||||||
|
return loadAiWorkbenchConversationHistory(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextList = readStoredList(user).map((conversation) => {
|
||||||
|
const normalized = normalizeConversation(conversation)
|
||||||
|
let conversationChanged = false
|
||||||
|
const messages = normalized.messages.map((message) => {
|
||||||
|
const contentResult = markDocumentDetailLinksDeleted(message.content, identifiers)
|
||||||
|
const actionsResult = markSuggestedActionsDeleted(message.suggestedActions, identifiers)
|
||||||
|
if (!contentResult.changed && !actionsResult.changed) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
conversationChanged = true
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: contentResult.content,
|
||||||
|
suggestedActions: actionsResult.actions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!conversationChanged) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversationHasDocumentDeletionNotice(messages, identifiers)) {
|
||||||
|
messages.push(buildDocumentDeletedMessage(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
desc: '单据已经删除或不可访问。',
|
||||||
|
messages,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
writeStoredList(user, nextList)
|
||||||
|
return loadAiWorkbenchConversationHistory(user)
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ test('AI conversation renderer renders document detail action links as buttons',
|
|||||||
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-document-detail/)
|
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-document-detail/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI conversation renderer renders deleted document detail actions as disabled buttons', () => {
|
||||||
|
const rendered = renderAiConversationHtml('[单据已删除](#ai-deleted-document-detail:claim-deleted-1)')
|
||||||
|
|
||||||
|
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document is-disabled"/)
|
||||||
|
assert.match(rendered, /aria-disabled="true"/)
|
||||||
|
assert.match(rendered, /data-ai-action="deleted-document-detail"/)
|
||||||
|
assert.doesNotMatch(rendered, /href="#ai-deleted-document-detail/)
|
||||||
|
})
|
||||||
|
|
||||||
test('AI conversation renderer renders images as html and rejects unsafe image sources', () => {
|
test('AI conversation renderer renders images as html and rejects unsafe image sources', () => {
|
||||||
const rendered = renderAiConversationHtml([
|
const rendered = renderAiConversationHtml([
|
||||||
'### 图片材料',
|
'### 图片材料',
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
resolveAiDocumentQueryIntent
|
resolveAiDocumentQueryIntent
|
||||||
} from '../src/utils/aiDocumentQueryModel.js'
|
} from '../src/utils/aiDocumentQueryModel.js'
|
||||||
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
|
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
|
||||||
|
import { resolveWorkbenchIntentActionRoute } from '../src/composables/workbenchAiMode/workbenchIntentActionPolicy.js'
|
||||||
|
import { resolveWorkbenchIntentFrame } from '../src/composables/workbenchAiMode/workbenchIntentFrameModel.js'
|
||||||
|
|
||||||
const today = '2026-06-20'
|
const today = '2026-06-20'
|
||||||
|
|
||||||
@@ -68,6 +70,21 @@ test('AI document query intent detects approval document questions', () => {
|
|||||||
assert.equal(intent?.sourceLabel, '待我审核的单据')
|
assert.equal(intent?.sourceLabel, '待我审核的单据')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI document query intent detects short approval workbench commands', () => {
|
||||||
|
const reviewIntent = resolveAiDocumentQueryIntent('我要审核', { today })
|
||||||
|
const todoIntent = resolveAiDocumentQueryIntent('待办审批', { today })
|
||||||
|
|
||||||
|
assert.equal(reviewIntent?.source, 'approval')
|
||||||
|
assert.equal(reviewIntent?.documentType, 'all')
|
||||||
|
assert.equal(reviewIntent?.sourceLabel, '待我审核的单据')
|
||||||
|
assert.equal(todoIntent?.source, 'approval')
|
||||||
|
assert.equal(todoIntent?.sourceLabel, '待我审核的单据')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI document query intent keeps approval policy questions out of document query', () => {
|
||||||
|
assert.equal(resolveAiDocumentQueryIntent('审批规则怎么走', { today }), null)
|
||||||
|
})
|
||||||
|
|
||||||
test('AI document query keeps explicit own-document scope separate from accessible documents', () => {
|
test('AI document query keeps explicit own-document scope separate from accessible documents', () => {
|
||||||
const intent = resolveAiDocumentQueryIntent('我名下有哪些单据?', { today })
|
const intent = resolveAiDocumentQueryIntent('我名下有哪些单据?', { today })
|
||||||
|
|
||||||
@@ -136,6 +153,66 @@ test('AI document query combines natural-language filters', () => {
|
|||||||
assert.match(buildAiDocumentQueryConditionSummary(intent), /金额:不少于1000元/)
|
assert.match(buildAiDocumentQueryConditionSummary(intent), /金额:不少于1000元/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI document query filters draft candidates by relative day', () => {
|
||||||
|
const intent = resolveAiDocumentQueryIntent('我的 3天前 草稿单据', { today: '2026-06-24' })
|
||||||
|
const records = filterAiDocumentQueryRecords([
|
||||||
|
{
|
||||||
|
id: 'draft-3-days-ago',
|
||||||
|
claim_no: 'AP-20260621001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
status: 'draft',
|
||||||
|
reason: '三天前保存的申请草稿',
|
||||||
|
created_at: '2026-06-21T09:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'draft-yesterday',
|
||||||
|
claim_no: 'AP-20260623001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
status: 'draft',
|
||||||
|
reason: '昨天保存的申请草稿',
|
||||||
|
created_at: '2026-06-23T09:00:00Z'
|
||||||
|
}
|
||||||
|
], intent)
|
||||||
|
|
||||||
|
assert.equal(intent?.source, 'mine')
|
||||||
|
assert.equal(intent?.statusFilter?.label, '草稿')
|
||||||
|
assert.equal(intent?.timeRange?.start, '2026-06-21')
|
||||||
|
assert.equal(intent?.timeRange?.end, '2026-06-21')
|
||||||
|
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260621001'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI document query filters no-risk approval application candidates', () => {
|
||||||
|
const intent = resolveAiDocumentQueryIntent('待我审核 无风险 申请单', { today })
|
||||||
|
const payload = [{
|
||||||
|
id: 'approval-no-risk',
|
||||||
|
claim_no: 'AP-NORISK-001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
status: 'submitted',
|
||||||
|
reason: '合规差旅申请',
|
||||||
|
created_at: '2026-06-20T09:00:00Z',
|
||||||
|
risk_flags_json: [],
|
||||||
|
risk_summary: '无'
|
||||||
|
}, {
|
||||||
|
id: 'approval-high-risk',
|
||||||
|
claim_no: 'AP-RISK-001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
status: 'submitted',
|
||||||
|
reason: '住宿超标申请',
|
||||||
|
created_at: '2026-06-20T10:00:00Z',
|
||||||
|
risk_flags_json: [{ severity: 'high', summary: '住宿超标' }],
|
||||||
|
risk_summary: '住宿超标'
|
||||||
|
}]
|
||||||
|
const records = filterAiDocumentQueryRecords({ items: payload, querySource: 'approval' }, intent)
|
||||||
|
|
||||||
|
assert.equal(intent?.source, 'approval')
|
||||||
|
assert.equal(intent?.documentType, 'application')
|
||||||
|
assert.equal(intent?.riskFilter?.level, 'none')
|
||||||
|
assert.match(buildAiDocumentQueryConditionSummary(intent), /风险:无风险/)
|
||||||
|
assert.deepEqual(records.map((record) => record.documentNo), ['AP-NORISK-001'])
|
||||||
|
})
|
||||||
|
|
||||||
test('AI document query excludes undated rows when a time condition is present', () => {
|
test('AI document query excludes undated rows when a time condition is present', () => {
|
||||||
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据?', { today })
|
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据?', { today })
|
||||||
const records = filterAiDocumentQueryRecords([
|
const records = filterAiDocumentQueryRecords([
|
||||||
@@ -246,6 +323,28 @@ test('AI document query html cards render as trusted card markup', () => {
|
|||||||
assert.doesNotMatch(rendered, /<blockquote>/)
|
assert.doesNotMatch(rendered, /<blockquote>/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI document query keeps high-risk command guidance in trusted rendered markup', () => {
|
||||||
|
const frame = resolveWorkbenchIntentFrame('删除申请单草稿', { today: '2026-06-24' })
|
||||||
|
const route = resolveWorkbenchIntentActionRoute(frame)
|
||||||
|
const intent = resolveAiDocumentQueryIntent(route.queryPrompt, { today: '2026-06-24' })
|
||||||
|
const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, [{
|
||||||
|
id: 'draft-application-1',
|
||||||
|
claim_no: 'AP-DRAFT-001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
status: 'draft',
|
||||||
|
reason: '测试申请草稿',
|
||||||
|
created_at: '2026-06-24T09:00:00Z'
|
||||||
|
}], { commandFrame: frame }))
|
||||||
|
|
||||||
|
assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/)
|
||||||
|
assert.match(rendered, /<section class="ai-document-command-guidance" aria-label="高风险操作提示">/)
|
||||||
|
assert.match(rendered, /系统不会直接删除相关单据/)
|
||||||
|
assert.match(rendered, /进入单据详情核对后再操作/)
|
||||||
|
assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
|
||||||
|
assert.match(rendered, /进入详情确认删除/)
|
||||||
|
})
|
||||||
|
|
||||||
test('AI document query trusted html rejects unsafe card markup', () => {
|
test('AI document query trusted html rejects unsafe card markup', () => {
|
||||||
const rendered = renderAiConversationHtml([
|
const rendered = renderAiConversationHtml([
|
||||||
'### 查询结果',
|
'### 查询结果',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
markAiWorkbenchConversationDraftDeleted,
|
markAiWorkbenchConversationDraftDeleted,
|
||||||
|
markAiWorkbenchConversationDocumentDeleted,
|
||||||
loadAiWorkbenchConversationHistory,
|
loadAiWorkbenchConversationHistory,
|
||||||
saveAiWorkbenchConversation
|
saveAiWorkbenchConversation
|
||||||
} from '../src/utils/aiWorkbenchConversationStore.js'
|
} from '../src/utils/aiWorkbenchConversationStore.js'
|
||||||
@@ -120,6 +121,54 @@ test('deleting an application draft marks AI workbench detail links as unavailab
|
|||||||
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
|
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('deleted document detail links are disabled for reopened AI conversations', () => {
|
||||||
|
installWindowStub()
|
||||||
|
const user = { username: 'zhangsan@example.com' }
|
||||||
|
|
||||||
|
saveAiWorkbenchConversation(user, {
|
||||||
|
id: 'conversation-document-delete-candidate',
|
||||||
|
title: '删除申请单草稿',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'assistant-document-candidates',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
'### 已查询到相关单据',
|
||||||
|
'',
|
||||||
|
'[进入详情确认删除](#ai-open-document-detail:claim_id%3Dclaim-draft-2%26claim_no%3DAP-DRAFT-002)'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextHistory = markAiWorkbenchConversationDocumentDeleted(user, {
|
||||||
|
claimId: 'claim-draft-2',
|
||||||
|
claimNo: 'AP-DRAFT-002'
|
||||||
|
})
|
||||||
|
const conversation = nextHistory.find((item) => item.id === 'conversation-document-delete-candidate')
|
||||||
|
|
||||||
|
assert.ok(conversation)
|
||||||
|
assert.match(conversation.messages[0].content, /#ai-deleted-document-detail:/)
|
||||||
|
assert.doesNotMatch(conversation.messages[0].content, /#ai-open-document-detail:/)
|
||||||
|
assert.match(conversation.messages[0].content, /\[单据已删除\]/)
|
||||||
|
assert.match(conversation.messages.at(-1).content, /单据 AP-DRAFT-002 已经删除或不可访问/)
|
||||||
|
assert.match(conversation.messages.at(-1).content, /该历史入口已失效,请返回单据列表重新查询/)
|
||||||
|
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI workbench validates document detail links before opening stale history entries', () => {
|
||||||
|
const aiMode = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.match(aiMode, /fetchExpenseClaimDetail/)
|
||||||
|
assert.match(aiMode, /markAiWorkbenchConversationDocumentDeleted/)
|
||||||
|
assert.match(aiMode, /async function handleAiAnswerMarkdownClick/)
|
||||||
|
assert.match(aiMode, /await ensureAiDocumentDetailStillAvailable/)
|
||||||
|
assert.match(aiMode, /已将这条历史入口标记为不可查看/)
|
||||||
|
})
|
||||||
|
|
||||||
test('saving a draft keeps the financial assistant open for continued work', () => {
|
test('saving a draft keeps the financial assistant open for continued work', () => {
|
||||||
const appShellScript = readFileSync(
|
const appShellScript = readFileSync(
|
||||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||||
|
|||||||
Reference in New Issue
Block a user