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,
......
This diff is collapsed.
......@@ -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