Commit dd45ed90 authored by David Yang's avatar David Yang

fix(audit): refine existing checklists and confirm skill style persistence

parent f99e8f26
......@@ -13,11 +13,12 @@
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@fontsource/inter": "^5.3.0",
"@heroui-pro/react": "1.0.0-beta.8",
"@heroui-pro/react": "1.0.0-beta.9",
"@heroui/react": "^3.2.4",
"@heroui/styles": "^3.2.4",
"@modelcontextprotocol/sdk": "^1.17.5",
"@number-flow/react": "^0.6.2",
"@react-aria/interactions": "^3.28.1",
"@react-aria/utils": "^3.34.1",
"@tailwindcss/postcss": "^4.3.3",
"@tiptap/core": "^3.30.2",
......@@ -43,10 +44,12 @@
"marked-katex-extension": "^5.1.5",
"motion": "^13.1.0",
"react": "^19.2.8",
"react-aria": "^3.52.1",
"react-aria-components": "^1.20.0",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"react-stately": "^3.50.0",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "ai_chat",
"version": "1.0.4",
"version": "1.0.3",
"build": "abc",
"description": "merge master",
"description": "ai 应用",
"date": "2025-07-14 19:27:20",
"author": "yang"
}
......@@ -99,6 +99,11 @@ function AssistantMessage({
label={content ? '思考完成' : '正在思考...'}
value={item.message?.think}
/>
{isStreaming && item.message?.auditProgress ? (
<p className="text-sm text-muted" role="status">
{item.message.auditProgress}…
</p>
) : null}
{item.message?.tools?.map((tool, index) => (
<ThinkingDisclosure
key={`${tool.function?.name || 'tool'}-${index}`}
......
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
import {
DeleteOutlined,
InfoCircleOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { DropZone } from '@heroui-pro/react/drop-zone';
import { EmptyState } from '@heroui-pro/react/empty-state';
......@@ -15,6 +19,7 @@ import {
Surface,
TextArea,
TextField,
Tooltip,
toast,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
......@@ -227,7 +232,18 @@ export function AuditPromptConfigDialog({
value={prompt}
onChange={setPrompt}
>
<Label>主提示词</Label>
<Label>
<span className="inline-flex items-center gap-1">
助手定位提示词
<Tooltip delay={0}>
<InfoCircleOutlined aria-hidden="true" />
<Tooltip.Content className="max-w-72">
定义审计助手是谁、负责什么任务。具体专项的业务过滤规则请保存到公共
Skill 文件。
</Tooltip.Content>
</Tooltip>
</span>
</Label>
<TextArea maxLength={6000} rows={7} variant="secondary" />
</TextField>
<TextField
......@@ -236,7 +252,18 @@ export function AuditPromptConfigDialog({
value={context}
onChange={setContext}
>
<Label>业务规则与约束</Label>
<Label>
<span className="inline-flex items-center gap-1">
通用回答约束
<Tooltip delay={0}>
<InfoCircleOutlined aria-hidden="true" />
<Tooltip.Content className="max-w-72">
设置所有回答必须遵守的通用边界,例如只能依据知识库原文、依据不足时明确说明。具体业务过滤规则请保存到公共
Skill 文件。
</Tooltip.Content>
</Tooltip>
</span>
</Label>
<TextArea maxLength={10000} rows={9} variant="secondary" />
</TextField>
</Modal.Body>
......@@ -321,11 +348,15 @@ export function AuditChecklistResult({
aiContent = '',
allowWordExport = false,
onSaveSkillRule,
onResolveSkillRule,
onResolveSkillStyle,
}) {
const [threshold, setThreshold] = useState(
Number(result.threshold || AUDIT_MATCH_THRESHOLD),
);
const [wordLoading, setWordLoading] = useState(false);
const [ruleSaving, setRuleSaving] = useState(false);
const reportContent = result.summaryContent ?? aiContent;
const [excludedRiskIds, setExcludedRiskIds] = useState(new Set());
useEffect(() => {
......@@ -352,8 +383,8 @@ export function AuditChecklistResult({
);
const isQa = result.kind === 'qa';
const summary = useMemo(
() => parseAuditSummary(aiContent, result.project),
[aiContent, result.project],
() => parseAuditSummary(reportContent, result.project),
[reportContent, result.project],
);
const handleWordExport = async () => {
......@@ -361,7 +392,7 @@ export function AuditChecklistResult({
try {
await exportAuditWord(
effectiveResult,
aiContent,
reportContent,
threshold,
effectiveResult.reportStyle,
);
......@@ -401,6 +432,79 @@ export function AuditChecklistResult({
return (
<section aria-label="审计风险点结果" className="grid gap-6">
{result.adjustmentNote ? (
<p role="status">{result.adjustmentNote}</p>
) : null}
{result.pendingSkillStyle && onResolveSkillStyle ? (
<Surface className="grid gap-3 p-4" variant="tertiary">
<p>是否将本次 Word 样式保存到公共 Skill,作为以后报告的默认样式?</p>
<p>{result.pendingSkillStyle.changes.join(';')}</p>
<div className="flex gap-2">
{[true, false].map((save) => (
<Button
key={String(save)}
isDisabled={ruleSaving}
variant={save ? 'primary' : 'secondary'}
onPress={async () => {
setRuleSaving(true);
try {
await onResolveSkillStyle(save);
toast.success(
save
? 'Word 样式已保存到公共 Skill'
: 'Word 样式仅用于本次报告',
);
} catch (error) {
toast.danger(
`操作失败,可重试:${error.message || '未知错误'}`,
);
} finally {
setRuleSaving(false);
}
}}
>
{save ? '保存到公共 Skill' : '仅本次使用'}
</Button>
))}
</div>
</Surface>
) : null}
{result.pendingSkillRule && onResolveSkillRule ? (
<Surface className="grid gap-3 p-4" variant="tertiary">
<p>
本次已应用“{result.pendingSkillRule.label}”。是否保存到公共
Skill,供以后同类审计使用?
</p>
<div className="flex gap-2">
{[true, false].map((save) => (
<Button
key={String(save)}
isDisabled={ruleSaving}
variant={save ? 'primary' : 'secondary'}
onPress={async () => {
setRuleSaving(true);
try {
await onResolveSkillRule(save);
toast.success(
save
? '过滤规则已保存到公共 Skill'
: '过滤规则仅用于本次清单',
);
} catch (error) {
toast.danger(
`操作失败,可重试:${error.message || '未知错误'}`,
);
} finally {
setRuleSaving(false);
}
}}
>
{save ? '保存到公共 Skill' : '仅本次使用'}
</Button>
))}
</div>
</Surface>
) : null}
{!isQa ? (
<>
<div className="grid gap-1">
......@@ -560,7 +664,7 @@ export function AuditChecklistResult({
<Button
variant="secondary"
onPress={() =>
exportAuditMarkdown(effectiveResult, aiContent, threshold)
exportAuditMarkdown(effectiveResult, reportContent, threshold)
}
>
Markdown
......@@ -568,7 +672,7 @@ export function AuditChecklistResult({
<Button
variant="secondary"
onPress={() =>
exportAuditExcel(effectiveResult, aiContent, threshold)
exportAuditExcel(effectiveResult, reportContent, threshold)
}
>
Excel
......
......@@ -42,6 +42,10 @@ const QA_PATTERNS = [
const REFINE_PATTERNS = [
'去掉',
'过滤',
'移除',
'剔除',
'不纳入',
'删除',
'不要',
'排除',
......@@ -149,6 +153,26 @@ function compactAuditRecord(record) {
};
}
// 按 compact 后的字符体积打包批次:mdContent/searchText 等派生字段
// 不进入模型上下文,也不应撑大批次数量
export function packAuditRecords(records = [], charBudget = 18000) {
const batches = [];
let current = [];
let currentSize = 0;
records.forEach((record) => {
const recordSize = JSON.stringify(compactAuditRecord(record)).length + 2;
if (current.length && currentSize + recordSize > charBudget) {
batches.push(current);
current = [];
currentSize = 0;
}
current.push(record);
currentSize += recordSize;
});
if (current.length) batches.push(current);
return batches.length ? batches : [[]];
}
export function buildAuditAnalysisPrompt({
query = '',
records = [],
......@@ -158,6 +182,8 @@ export function buildAuditAnalysisPrompt({
analysisMode = 'candidate',
batchNumber = 1,
batchCount = 1,
totalRecords = 0,
candidatePoolSize = 0,
}) {
const skill = collectAuditSkillInstructions(skillObject, query);
const context = records.slice(0, maxRecords).map(compactAuditRecord);
......@@ -195,6 +221,9 @@ export function buildAuditAnalysisPrompt({
: 'Skill 业务过滤规则:无',
'',
`当前批次:${batchNumber}/${batchCount};批次记录数:${context.length};本轮总记录数:${records.length}`,
candidatePoolSize > 0 && totalRecords > candidatePoolSize
? `知识库共 ${totalRecords} 条记录,已按本地相关度预筛出 ${candidatePoolSize} 条候选进入本轮分析。`
: '',
analysisMode === 'candidate'
? '候选阶段输出要求:返回本批次可能相关的风险点 ID,selectedRiskPointIds 可以适度偏宽。'
: '收敛阶段输出要求:返回本批次最终建议保留的风险点 ID,selectedRiskPointIds 应尽量去重且严格。',
......@@ -769,12 +798,21 @@ export function classifyAuditAction(query = '', previousResult = null) {
}
export function isPersistentAuditRuleRequest(query = '') {
return /(以后|今后|长期|记住|保存到.?Skill|写入.?Skill|都不要|后续都)/i.test(
return (
!/(?:不|别|不要|无需)(?:再)?(?:保存|写入|记住)|仅本次|只.*本次/i.test(
String(query),
) &&
/(以后|今后|长期|记住|保存到.?Skill|写入.?Skill|都不要|后续都)/i.test(
String(query),
)
);
}
export function buildAuditStylePrompt(changes = [], persisted = false) {
export function buildAuditStylePrompt(
changes = [],
persisted = false,
pending = false,
) {
return [
'这是一次审计报告 Word 排版调整请求。',
changes.length
......@@ -783,6 +821,9 @@ export function buildAuditStylePrompt(changes = [], persisted = false) {
persisted
? '该样式已保存为公共 Skill 的默认样式。'
: '该调整仅作用于当前报告。',
pending
? '请询问:是否将本次 Word 样式保存到公共 Skill,作为以后报告的默认样式?用户可回复“保存到 Skill”或“仅本次使用”,也可以点击对应按钮。未经确认不得声称已保存。'
: '',
'风险点、法规依据、来源、风险等级和审计分析结果保持不变。',
'请简短确认,不要重新生成审计风险点清单。',
].join('\n');
......@@ -816,12 +857,19 @@ export function parseRefineInstruction(query = '', previousThreshold) {
);
}
[
{ pattern: /(?:去掉|删除|不要|排除)([^,,。;;]+)/g, key: 'remove' },
{
pattern:
/(?:过滤掉|过滤|移除|剔除|不纳入|去掉|删除|不要|排除)([^,,。;;]+)/g,
key: 'remove',
},
{ pattern: /(?:只保留|仅保留|保留)([^,,。;;]+)/g, key: 'keep' },
{ pattern: /(?:增加|加入|补充)([^,,。;;]+)/g, key: 'add' },
].forEach(({ pattern, key }) => {
for (const match of query.matchAll(pattern)) {
cleanInstructionValue(match[1])
if (/^(?:保存|写入|记住)/.test(match[1].trim())) continue;
cleanInstructionValue(
match[1].replace(/(?:并|然后)?(?:保存|写入)到.*$/i, ''),
)
.split(/和|与|以及|、|\s+/)
.filter((value) => value.length >= 2)
.forEach((value) => instruction[key].push(value));
......@@ -859,6 +907,26 @@ function scoreAllRecords(records, query, extraKeywords = []) {
.sort((a, b) => b.matchScore - a.matchScore);
}
// 大规模知识库先做本地相关度预筛,再交给 AI 分批语义分析;
// 候选池上限应明显大于最终复核上限,给候选阶段“宁可保留”留出召回空间
export function selectAnalysisCandidates(
records = [],
query = '',
options = {},
) {
const limit = Math.max(50, Math.min(2000, Number(options.limit) || 500));
if (records.length <= limit) return records;
const keywordInfo = extractAuditKeywords(query, options.extraKeywords || []);
const relevantCategories = getRelevantCategories(records, keywordInfo);
return records
.map((record) =>
scoreAuditRecord(record, query, keywordInfo, relevantCategories),
)
.filter(Boolean)
.sort((left, right) => right.matchScore - left.matchScore)
.slice(0, limit);
}
export function isAuditRefineRequest(query, previousResult) {
return Boolean(
previousResult &&
......@@ -866,6 +934,54 @@ export function isAuditRefineRequest(query, previousResult) {
);
}
// 缩小范围直接使用当前结果,避免重新召回此前已经排除的风险点。
export function refineCurrentAuditResult(query, previousResult) {
if (
!isAuditRefineRequest(query, previousResult) ||
isAuditStyleRequest(query)
)
return null;
const instruction = parseRefineInstruction(query, previousResult.threshold);
if (instruction.add.length || /阈值|最低分|匹配度/.test(query)) return null;
const records = (previousResult.records || []).filter(
(record) =>
(!instruction.remove.length ||
!recordMatchesTerms(record, instruction.remove)) &&
(!instruction.keep.length ||
recordMatchesTerms(record, instruction.keep)),
);
const selectedIds = new Set(records.map(getAuditRecordKey));
const removed = (previousResult.records || []).filter(
(record) => !selectedIds.has(getAuditRecordKey(record)),
);
return {
...previousResult,
request: query,
refined: true,
instruction,
records,
matchedCount: records.length,
selectedRiskPointIds: [...selectedIds],
generatedAt: new Date().toISOString(),
pendingSkillTarget: 'rule',
adjustmentNote: removed.length
? `已在当前清单中移除 ${removed.length} 条,保留 ${records.length} 条,可下载更新后的 Word。`
: '未匹配到需要调整的条目,请提供具体项目名称或风险描述。',
pendingSkillRule:
instruction.remove.length && removed.length
? {
domain:
previousResult.analysis?.auditDomain || previousResult.project,
removeTerms: instruction.remove,
riskPointIds: removed.map(getAuditRecordKey),
label: `过滤 ${instruction.remove.join('、')}`,
description: '业务人员在当前审计清单中排除的风险点。',
sourceQuery: query,
}
: null,
};
}
export function buildAuditResult(
query,
records,
......
......@@ -459,7 +459,6 @@ export function serializeAuditSkillObject(skillObject, fileDescriptor = null) {
skill_file: fileDescriptor || object.skillFile,
enabled: object.enabled !== false,
description: object.description || '',
audit_domain: object.auditDomain || '',
};
}
......
......@@ -217,12 +217,41 @@ export function isAuditStyleRequest(query = '') {
);
}
export function isTemporaryStyleRequest(query = '') {
return /(?:不|别|不要|无需)(?:再)?(?:保存|写入|记住)|(?:仅|只)(?:用于|用在|作用于)?本次|仅此次|不用保存/.test(
String(query),
);
}
export function isPersistentStyleRequest(query = '') {
return /(以后|今后|默认|保存为|记住|都这样|统一样式|长期)/.test(
return (
!isTemporaryStyleRequest(query) &&
/(以后|今后|默认|保存为|记住|都这样|统一样式|长期|(?:保存|写入)到?\s*(?:公共\s*)?skill)/i.test(
String(query),
)
);
}
// 仅接受明确的确认短句,避免把下一轮业务请求误当成保存授权。
export function parseAuditSkillSaveDecision(query = '') {
const text = String(query)
.trim()
.replace(/[。!!\s]+$/g, '');
if (
/^(?:仅本次(?:使用|生效)?|只用于本次|不用(?:保存)?|不保存|不要保存|无需保存)(?:到?\s*(?:公共\s*)?skill)?$/i.test(
text,
)
)
return 'dismiss';
if (
/^(?:是|是的|好的?|可以|确认|保存|记住|(?:请)?(?:保存|写入)(?:当前|本次)?(?:的)?(?:样式|配置)?到\s*(?:公共\s*)?skill|(?:请)?保存为默认(?:样式|配置)?)$/i.test(
text,
)
)
return 'save';
return null;
}
export function parseAuditStyleRequest(
query = '',
currentStyle = DEFAULT_WORD_STYLE,
......
......@@ -41,7 +41,10 @@ import {
getAuditRecordKey,
isAuditRefineRequest,
isPersistentAuditRuleRequest,
packAuditRecords,
recordMatchesTerms,
refineCurrentAuditResult,
selectAnalysisCandidates,
slimAuditRecord,
toPersistedAuditResult,
} from '../../features/audit/engine';
......@@ -54,7 +57,11 @@ import {
parseSkillContent,
serializeAuditSkillObject,
} from '../../features/audit/skills';
import { parseAuditStyleRequest } from '../../features/audit/style';
import {
isTemporaryStyleRequest,
parseAuditSkillSaveDecision,
parseAuditStyleRequest,
} from '../../features/audit/style';
import {
callMcpTool,
createConversation,
......@@ -213,7 +220,7 @@ function getReferenceId(record) {
}
function getConversationAgent(metadata = {}) {
const agent = metadata.agentConfig || metadata.agent;
const agent = metadata.agentConfig;
if (agent && typeof agent === 'object' && agent.prompt) {
return serializeAgent(agent);
......@@ -323,6 +330,7 @@ async function analyzeAuditRequest(
skillObject,
previousResult,
signal,
options = {},
) {
const asList = (value) =>
Array.isArray(value) ? value : value ? [value] : [];
......@@ -337,29 +345,26 @@ async function analyzeAuditRequest(
const finalCandidateLimit = 96;
const maxConsolidationRounds = 4;
const maxConcurrentBatches = 4;
const batchTimeoutMs = 45000;
const batchTimeoutMs = 90000;
const maxBatchRetries = 1;
const candidatePoolLimit = 500;
const reportProgress = (text) => {
try {
options.onProgress?.(text);
} catch {}
};
const packRecords = (input = []) => {
const batches = [];
let current = [];
let currentSize = 0;
input.forEach((record) => {
const recordSize = JSON.stringify(record).length + 2;
if (
current.length &&
currentSize + recordSize > analysisBatchCharBudget
) {
batches.push(current);
current = [];
currentSize = 0;
}
current.push(record);
currentSize += recordSize;
// 本地评分预筛:万行级知识库不再全量进入 Map-Reduce 循环,
// 候选池交给下方分批语义分析
const analysisPool = selectAnalysisCandidates(records, query, {
limit: candidatePoolLimit,
extraKeywords: options.extraKeywords || [],
});
if (current.length) batches.push(current);
return batches.length ? batches : [[]];
};
if (records.length > analysisPool.length) {
reportProgress(
`知识库共 ${records.length} 条,本地预筛后 ${analysisPool.length} 条进入 AI 语义分析`,
);
}
const mergePayloads = (payloads = [], trace = null) => {
const rationaleMap = new Map();
......@@ -433,7 +438,20 @@ async function analyzeAuditRequest(
};
const runBatchPass = async (inputRecords, analysisMode, round) => {
const batches = packRecords(inputRecords);
const batches = packAuditRecords(inputRecords, analysisBatchCharBudget);
const stageLabel =
analysisMode === 'candidate'
? '候选初筛'
: analysisMode === 'consolidate'
? `候选收敛(第 ${round} 轮)`
: '最终复核';
let completedBatches = 0;
const reportBatchProgress = () => {
completedBatches += 1;
reportProgress(
`知识库分析 · ${stageLabel}:${completedBatches}/${batches.length} 批`,
);
};
const payloads = [];
const failedBatches = [];
inputRecords.forEach((record) =>
......@@ -481,6 +499,8 @@ async function analyzeAuditRequest(
analysisMode,
batchNumber,
batchCount: batches.length,
totalRecords: records.length,
candidatePoolSize: analysisPool.length,
}),
},
{
......@@ -500,10 +520,12 @@ async function analyzeAuditRequest(
);
const payload = parseJsonObject(await readLlmText(response));
if (!payload) throw new Error('模型未返回有效 JSON');
reportBatchProgress();
return payload;
} catch (error) {
if (error?.name === 'AbortError' && signal?.aborted) throw error;
if (attempt >= maxBatchRetries) {
reportBatchProgress();
failedBatches.push({
batchNumber,
message:
......@@ -542,9 +564,11 @@ async function analyzeAuditRequest(
});
const trace = {
strategy: 'map_reduce_loop',
strategy: 'local_prefilter_map_reduce_loop',
batchCharBudget: analysisBatchCharBudget,
maxConcurrentBatches,
totalRecords: records.length,
candidatePoolSize: analysisPool.length,
rounds: [],
failedBatches: [],
recordsAnalyzed: 0,
......@@ -555,7 +579,7 @@ async function analyzeAuditRequest(
);
const getRecord = (id) => recordMap.get(String(id));
let pass = await runBatchPass(records, 'candidate', 0);
let pass = await runBatchPass(analysisPool, 'candidate', 0);
trace.rounds.push({ mode: 'candidate', ...summarizePass(pass) });
trace.failedBatches.push(...pass.failedBatches);
trace.recordsAnalyzed = analyzedRecordIds.size;
......@@ -1003,6 +1027,7 @@ export default forwardRef((props, ref) => {
if (chunk.data.includes('DONE')) {
return {
...originMessage,
auditProgress: undefined,
auditResult:
originMessage?.auditResult ||
pendingAuditResultRef.current ||
......@@ -1029,6 +1054,7 @@ export default forwardRef((props, ref) => {
...originMessage,
role: 'assistant',
content: originContent || '已停止生成。',
auditProgress: undefined,
auditResult:
originMessage?.auditResult ||
pendingAuditResultRef.current ||
......@@ -1041,6 +1067,19 @@ export default forwardRef((props, ref) => {
};
}
// 审计分析阶段进度提示(独立于模型正文,仅加载中展示)
try {
const progressData = JSON.parse(chunk.data);
if (progressData && progressData.auditProgress) {
return {
...originMessage,
role: 'assistant',
loading: true,
auditProgress: String(progressData.auditProgress),
};
}
} catch {}
try {
let messageData;
try {
......@@ -1077,6 +1116,8 @@ export default forwardRef((props, ref) => {
think: `${originThink}${currentThink}`,
content: `${originContent}${currentContent}`,
role: 'assistant',
// 模型开始输出后不再展示分析进度
auditProgress: undefined,
auditResult: originMessage?.auditResult || undefined,
auditExport: originMessage?.auditExport || undefined,
tool_calls: toolCalls,
......@@ -1151,7 +1192,7 @@ export default forwardRef((props, ref) => {
}
};
const getMessagePrompt = async (message) => {
const getMessagePrompt = async (message, { onAuditProgress } = {}) => {
const topic = conversationTopic;
const usesKnowledgeAgent = conversationAgent?.key === 'knowledge-copilot';
......@@ -1287,47 +1328,158 @@ export default forwardRef((props, ref) => {
}
if (usesStrictAuditKnowledge) {
const records = await loadAuditRecords();
const skillObject = await loadAuditSkill();
const previousResult = findPreviousAuditResult(messages);
const saveDecision = parseAuditSkillSaveDecision(message.content);
const pendingStyle = previousResult?.pendingSkillStyle;
const pendingRule = previousResult?.pendingSkillRule;
if (saveDecision && (pendingStyle || pendingRule)) {
const isStyle =
pendingStyle &&
(!pendingRule || previousResult.pendingSkillTarget === 'style');
const save = saveDecision === 'save';
let saveFailed = false;
if (save) {
try {
if (isStyle) await saveAuditStyle(pendingStyle.style);
else await saveAuditSkillRule(pendingRule);
} catch (error) {
saveFailed = true;
}
}
const label = isStyle ? 'Word 样式' : '过滤规则';
const note = saveFailed
? `${label}仍用于当前报告,但保存到公共 Skill 失败,请重试。`
: save
? `${label}已保存到公共 Skill。`
: `${label}仅用于本次清单。`;
pendingAuditResultRef.current = toPersistedAuditResult({
...previousResult,
[isStyle ? 'pendingSkillStyle' : 'pendingSkillRule']: saveFailed
? isStyle
? pendingStyle
: pendingRule
: null,
adjustmentNote: note,
generatedAt: new Date().toISOString(),
});
return `${note}请简短确认,不要重新分析或生成清单。`;
}
const refinedResult = refineCurrentAuditResult(
message.content,
previousResult,
);
if (refinedResult) {
const previousMessage = [...messages]
.reverse()
.find((item) => item.auditResult || item.message?.auditResult);
refinedResult.summaryContent =
previousResult.summaryContent ??
previousMessage?.content ??
previousMessage?.message?.content ??
'';
let saveNote = '';
if (
refinedResult.pendingSkillRule &&
isPersistentAuditRuleRequest(message.content)
) {
try {
await saveAuditSkillRule(refinedResult.pendingSkillRule);
refinedResult.pendingSkillRule = null;
saveNote = '过滤规则已保存到公共 Skill。';
} catch (error) {
saveNote =
'本次过滤已生效,但保存到公共 Skill 失败,可点击按钮重试。';
}
}
if (saveNote) refinedResult.adjustmentNote += ` ${saveNote}`;
pendingAuditResultRef.current = toPersistedAuditResult(refinedResult);
return [
'这是对现有审计清单的局部修改,系统已直接应用,无需重新分析或生成清单要素。',
`调整前 ${previousResult.records.length} 条,调整后 ${refinedResult.records.length} 条。`,
refinedResult.records.length === previousResult.records.length
? '没有匹配到需要调整的条目,请用户提供具体项目名称或风险描述。'
: '请简短确认本次调整,提示可下载更新后的 Word。',
saveNote,
refinedResult.pendingSkillRule
? '必须询问:是否将本次过滤逻辑保存到公共 Skill,供以后同类审计使用?用户可点击“保存到公共 Skill”或“仅本次使用”。未经确认不得声称已保存。'
: '',
`用户请求:${message.content}`,
]
.filter(Boolean)
.join('\n');
}
const auditAction = classifyAuditAction(message.content, previousResult);
if (auditAction === 'style_adjust' && previousResult) {
const styleRequest = parseAuditStyleRequest(
message.content,
previousResult.reportStyle || skillObject.styles,
previousResult.reportStyle || (await loadAuditSkill()).styles,
);
const styleChanges = styleRequest.changes.length
? styleRequest.changes
: previousResult.pendingSkillStyle?.changes || [];
let persisted = false;
let saveFailed = false;
if (
styleRequest.matched &&
styleRequest.changes.length &&
styleChanges.length &&
styleRequest.persist
) {
try {
await saveAuditStyle(styleRequest.style);
persisted = true;
} catch (error) {
saveFailed = true;
console.warn('审计 Word 样式保存失败,保留本次调整:', error);
toast.warning('本次样式已应用,但保存到公共 Skill 失败');
}
}
const previousMessage = [...messages]
.reverse()
.find((item) => item.auditResult || item.message?.auditResult);
const pendingSkillStyle =
styleChanges.length &&
!persisted &&
!isTemporaryStyleRequest(message.content)
? { style: styleRequest.style, changes: styleChanges }
: null;
const styledResult = {
...previousResult,
request: message.content,
reportStyle: styleRequest.style,
styleAdjusted: true,
summaryContent:
previousResult.summaryContent ??
previousMessage?.message?.content ??
previousMessage?.content ??
'',
pendingSkillStyle,
pendingSkillTarget: 'style',
adjustmentNote: styleChanges.length
? `已调整:${styleChanges.join(';')}。${
persisted
? '已保存到公共 Skill。'
: saveFailed
? '本次已生效,但保存到公共 Skill 失败,请重试。'
: '仅用于当前报告,可下载更新后的 Word。'
}`
: '未识别到样式变化,请提供不同的字体、字号或行距等参数。',
generatedAt: new Date().toISOString(),
};
pendingAuditResultRef.current = toPersistedAuditResult(styledResult);
auditKnowledgePrompt = buildAuditStylePrompt(
styleRequest.changes,
styleChanges,
persisted,
Boolean(pendingSkillStyle),
);
} else {
const records = await loadAuditRecords();
const skillObject = await loadAuditSkill();
const usesChecklistMode = auditAction !== 'qa';
if (usesChecklistMode) {
const query = message.content;
let result = null;
let queryExpansion = null;
const isRefine = isAuditRefineRequest(
message.content,
previousResult,
......@@ -1341,6 +1493,19 @@ export default forwardRef((props, ref) => {
{ skillObject },
);
} else if (!isRefine) {
throwIfAborted();
// 检索词扩展前置:既提升本地预筛召回,也供本地兜底评分复用
try {
queryExpansion = await expandAuditQuery(
message.content,
selectedModel,
abortController.current?.signal,
);
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('审计检索词 AI 扩展失败,回退本地规则:', error);
}
}
try {
const aiAnalysis = await analyzeAuditRequest(
message.content,
......@@ -1349,6 +1514,12 @@ export default forwardRef((props, ref) => {
skillObject,
previousResult,
abortController.current?.signal,
{
extraKeywords: queryExpansion?.applied
? queryExpansion.expandedKeywords
: [],
onProgress: onAuditProgress,
},
);
if (aiAnalysis) {
result = buildAuditResultFromAnalysis(
......@@ -1367,20 +1538,6 @@ export default forwardRef((props, ref) => {
}
if (!result) {
throwIfAborted();
let queryExpansion = null;
if (!isRefine) {
try {
queryExpansion = await expandAuditQuery(
message.content,
selectedModel,
abortController.current?.signal,
);
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('审计检索词 AI 扩展失败,回退本地规则:', error);
}
}
}
result = buildAuditResult(query, records, previousResult, {
extraKeywords: queryExpansion?.applied
? queryExpansion.expandedKeywords
......@@ -1423,6 +1580,7 @@ export default forwardRef((props, ref) => {
skillObject,
previousResult,
abortController.current?.signal,
{ onProgress: onAuditProgress },
);
if (aiAnalysis) {
result = buildAuditResultFromAnalysis(
......@@ -1482,7 +1640,17 @@ export default forwardRef((props, ref) => {
data: 'LOADING',
});
const prompt = await getMessagePrompt(message);
// 审计分析阶段进度:走同一消息流式通道,展示在气泡上
const emitAuditProgress = (text) => {
onUpdate({
data: JSON.stringify({
auditProgress: String(text || '').slice(0, 200),
}),
});
};
const prompt = await getMessagePrompt(message, {
onAuditProgress: emitAuditProgress,
});
const tools =
conversationTopic ||
['knowledge-copilot', 'audit-checklist'].includes(
......@@ -1879,7 +2047,6 @@ export default forwardRef((props, ref) => {
knowledgeBases: serializedKnowledgeBases || [],
};
const topicId = getReferenceId(selectedTopic);
const agentId = getReferenceId(selectedAgent);
if (selectedTopic) {
conversationData.topicConfig = selectedTopic;
......@@ -1887,9 +2054,6 @@ export default forwardRef((props, ref) => {
if (topicId) {
conversationData.topic = topicId;
}
if (agentId) {
conversationData.agent = agentId;
}
await addConversation(conversationData, userMessage);
......@@ -1969,6 +2133,38 @@ export default forwardRef((props, ref) => {
onSubmit(userMessage);
};
const resolveAuditSkillConfirmation = async (item, target, save) => {
const field = target === 'style' ? 'pendingSkillStyle' : 'pendingSkillRule';
const pending = item.message.auditResult[field];
if (!pending) return;
if (save) {
if (target === 'style') await saveAuditStyle(pending.style);
else await saveAuditSkillRule(pending);
}
const label = target === 'style' ? 'Word 样式' : '过滤规则';
const updated = messages.map((entry) =>
entry === item || entry.message === item.message
? {
...entry,
message: {
...entry.message,
auditResult: {
...entry.message.auditResult,
[field]: null,
adjustmentNote: save
? `${label}已保存到公共 Skill。`
: `${label}仅用于本次报告。`,
},
},
}
: entry,
);
await updateConversation(conversation.name, {
messages: updated.map((entry) => sanitizePersistedMessage(entry.message)),
});
setMessages(updated);
};
const renderAssistantExtras = (item) => {
if (item.message?.role !== 'assistant') return null;
const allowWordExport =
......@@ -1984,6 +2180,12 @@ export default forwardRef((props, ref) => {
aiContent={item.message.content}
allowWordExport={allowWordExport}
onSaveSkillRule={saveAuditSkillRule}
onResolveSkillRule={(save) =>
resolveAuditSkillConfirmation(item, 'rule', save)
}
onResolveSkillStyle={(save) =>
resolveAuditSkillConfirmation(item, 'style', save)
}
result={item.message.auditResult}
/>
) : null}
......
......@@ -547,7 +547,7 @@ export async function queryConversations(query) {
page_size: 20,
mode: 'basic',
select:
'metadata.label metadata.pinned metadata.agent metadata.agentConfig metadata.avatar metadata.topic metadata.topicConfig metadata.knowledgeBases',
'metadata.label metadata.pinned metadata.agentConfig metadata.topic metadata.topicConfig metadata.knowledgeBases',
sort: '-metadata.pinned -created_at',
filter: {
'metadata.user': userInfo._id,
......
const assert = require('node:assert/strict');
require('./register-audit.cjs');
const {
classifyAuditAction,
refineCurrentAuditResult,
isPersistentAuditRuleRequest,
toPersistedAuditResult,
} = require('../src/features/audit/engine.js');
const initial = {
kind: 'checklist',
project: '采购管理专项审计',
threshold: 60,
records: [
{ id: '1', riskDescription: '采购合同审批', matchScore: 95 },
{ id: '2', riskDescription: '付款审批', matchScore: 90 },
{ id: '3', riskDescription: '供应商准入', matchScore: 85 },
],
analysis: { selectedRiskPointIds: ['1', '2', '3'], auditDomain: '采购' },
reportStyle: { bodyFont: '宋体' },
summaryContent: '原始清单摘要',
};
for (const verb of [
'过滤掉',
'过滤',
'移除',
'剔除',
'不纳入',
'去掉',
'删除',
'排除',
]) {
assert.equal(classifyAuditAction(`${verb}采购合同`, initial), 'refine_scope');
const result = refineCurrentAuditResult(`${verb}采购合同`, initial);
assert.deepEqual(
result.records.map((r) => r.id),
['2', '3'],
);
assert.deepEqual(result.pendingSkillRule.riskPointIds, ['1']);
assert.equal(result.pendingSkillRule.domain, '采购');
assert.deepEqual(result.reportStyle, initial.reportStyle);
assert.equal(result.summaryContent, initial.summaryContent);
}
const first = toPersistedAuditResult(
refineCurrentAuditResult('过滤掉采购合同', initial),
);
const second = refineCurrentAuditResult('再过滤掉付款审批', first);
assert.deepEqual(
second.records.map((r) => r.id),
['3'],
);
assert.deepEqual(
initial.records.map((r) => r.id),
['1', '2', '3'],
);
assert.equal(
refineCurrentAuditResult('过滤掉不存在的项', initial).pendingSkillRule,
null,
);
assert.equal(
refineCurrentAuditResult('过滤掉供应商准入', second).records.length,
0,
);
assert.equal(refineCurrentAuditResult('生成反洗钱清单', initial), null);
assert.equal(refineCurrentAuditResult('过滤掉采购合同', null), null);
assert.equal(refineCurrentAuditResult('增加发票核验', initial), null);
assert.equal(refineCurrentAuditResult('阈值改为50', initial), null);
assert.equal(isPersistentAuditRuleRequest('过滤掉采购合同'), false);
assert.equal(isPersistentAuditRuleRequest('过滤掉采购合同,保存到Skill'), true);
assert.equal(
isPersistentAuditRuleRequest('过滤掉采购合同,不要保存到Skill'),
false,
);
assert.equal(isPersistentAuditRuleRequest('以后再说,仅本次使用'), false);
console.log('audit refine regression checks passed');
const assert = require('node:assert/strict');
require('./register-audit.cjs');
const {
DEFAULT_WORD_STYLE,
isPersistentStyleRequest,
isTemporaryStyleRequest,
parseAuditSkillSaveDecision,
parseAuditStyleRequest,
} = require('../src/features/audit/style.js');
const {
appendAuditWordStyle,
DEFAULT_AUDIT_SKILL_OBJECT,
parseSkillContent,
} = require('../src/features/audit/skills.js');
const {
buildAuditStylePrompt,
toPersistedAuditResult,
} = require('../src/features/audit/engine.js');
for (const query of [
'保存到 Skill',
'保存到公共 Skill',
'写入到skill',
'保存为默认样式',
'以后都这样',
]) {
assert.equal(isPersistentStyleRequest(query), true, query);
}
for (const query of [
'不要保存到 Skill',
'不保存到公共 Skill',
'不用保存到 Skill',
'字体设置为仿宋,仅本次使用',
]) {
assert.equal(isPersistentStyleRequest(query), false, query);
assert.equal(isTemporaryStyleRequest(query), true, query);
}
for (const query of [
'保存到 Skill',
'保存到公共 Skill。',
'是的',
'好的',
'保存',
'请保存本次样式到 Skill',
]) {
assert.equal(parseAuditSkillSaveDecision(query), 'save', query);
}
for (const query of [
'仅本次使用',
'不用',
'不要保存到 Skill',
'不保存',
'只用于本次',
]) {
assert.equal(parseAuditSkillSaveDecision(query), 'dismiss', query);
}
for (const query of [
'是否保存到 Skill?',
'保存到 Skill 会影响什么?',
'生成采购清单',
'正文大小设置为小四,保存到 Skill',
]) {
assert.equal(parseAuditSkillSaveDecision(query), null, query);
}
const request = parseAuditStyleRequest(
'正文字体设置为仿宋,正文字号设置为小四,行间距设置为1.5倍',
);
assert.equal(request.persist, false);
assert.equal(request.changes.length, 3);
assert.equal(request.style.fontFamily, '仿宋');
assert.equal(request.style.bodyFontSizePt, 12);
assert.equal(request.style.lineSpacing, 1.5);
assert.equal(DEFAULT_WORD_STYLE.fontFamily, '宋体');
assert.equal(
parseAuditStyleRequest('行间距设置为1.5倍,保存到 Skill').persist,
true,
);
assert.equal(
parseAuditStyleRequest('行间距设置为1.5倍,不要保存到 Skill').persist,
false,
);
assert.match(
buildAuditStylePrompt(request.changes, false, true),
/是否将本次 Word 样式保存/,
);
assert.doesNotMatch(
buildAuditStylePrompt(request.changes, true, false),
/是否将本次 Word 样式保存/,
);
const result = {
records: [{ id: 'risk-1', riskDescription: '原文' }],
summaryContent: '原清单摘要',
reportStyle: request.style,
pendingSkillTarget: 'style',
pendingSkillStyle: { style: request.style, changes: request.changes },
};
const restored = JSON.parse(JSON.stringify(toPersistedAuditResult(result)));
assert.deepEqual(restored.pendingSkillStyle, result.pendingSkillStyle);
assert.equal(restored.summaryContent, '原清单摘要');
assert.deepEqual(restored.records, result.records);
const skill = appendAuditWordStyle(
DEFAULT_AUDIT_SKILL_OBJECT,
restored.pendingSkillStyle.style,
);
const parsed = parseSkillContent(skill.content);
assert.equal(parsed.styles.fontFamily, '仿宋');
assert.equal(parsed.styles.bodyFontSizePt, 12);
assert.equal(parsed.styles.lineSpacing, 1.5);
console.log('audit style save regression checks passed');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const originalLoader = require.extensions['.js'];
require.extensions['.js'] = (module, filename) => {
if (
!filename.startsWith(
path.resolve(__dirname, '../src/features/audit') + path.sep,
)
) {
return originalLoader(module, filename);
}
module._compile(
ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
}).outputText,
filename,
);
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment