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 @@ ...@@ -13,11 +13,12 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@fontsource/inter": "^5.3.0", "@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/react": "^3.2.4",
"@heroui/styles": "^3.2.4", "@heroui/styles": "^3.2.4",
"@modelcontextprotocol/sdk": "^1.17.5", "@modelcontextprotocol/sdk": "^1.17.5",
"@number-flow/react": "^0.6.2", "@number-flow/react": "^0.6.2",
"@react-aria/interactions": "^3.28.1",
"@react-aria/utils": "^3.34.1", "@react-aria/utils": "^3.34.1",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@tiptap/core": "^3.30.2", "@tiptap/core": "^3.30.2",
...@@ -43,10 +44,12 @@ ...@@ -43,10 +44,12 @@
"marked-katex-extension": "^5.1.5", "marked-katex-extension": "^5.1.5",
"motion": "^13.1.0", "motion": "^13.1.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-aria": "^3.52.1",
"react-aria-components": "^1.20.0", "react-aria-components": "^1.20.0",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3", "react-resizable-panels": "^4.12.3",
"react-stately": "^3.50.0",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "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", "name": "ai_chat",
"version": "1.0.4", "version": "1.0.3",
"build": "abc", "build": "abc",
"description": "merge master", "description": "ai 应用",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
"author": "yang" "author": "yang"
} }
...@@ -99,6 +99,11 @@ function AssistantMessage({ ...@@ -99,6 +99,11 @@ function AssistantMessage({
label={content ? '思考完成' : '正在思考...'} label={content ? '思考完成' : '正在思考...'}
value={item.message?.think} 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) => ( {item.message?.tools?.map((tool, index) => (
<ThinkingDisclosure <ThinkingDisclosure
key={`${tool.function?.name || 'tool'}-${index}`} 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 { DataGrid } from '@heroui-pro/react/data-grid';
import { DropZone } from '@heroui-pro/react/drop-zone'; import { DropZone } from '@heroui-pro/react/drop-zone';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
...@@ -15,6 +19,7 @@ import { ...@@ -15,6 +19,7 @@ import {
Surface, Surface,
TextArea, TextArea,
TextField, TextField,
Tooltip,
toast, toast,
} from '@heroui/react'; } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
...@@ -227,7 +232,18 @@ export function AuditPromptConfigDialog({ ...@@ -227,7 +232,18 @@ export function AuditPromptConfigDialog({
value={prompt} value={prompt}
onChange={setPrompt} 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" /> <TextArea maxLength={6000} rows={7} variant="secondary" />
</TextField> </TextField>
<TextField <TextField
...@@ -236,7 +252,18 @@ export function AuditPromptConfigDialog({ ...@@ -236,7 +252,18 @@ export function AuditPromptConfigDialog({
value={context} value={context}
onChange={setContext} 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" /> <TextArea maxLength={10000} rows={9} variant="secondary" />
</TextField> </TextField>
</Modal.Body> </Modal.Body>
...@@ -321,11 +348,15 @@ export function AuditChecklistResult({ ...@@ -321,11 +348,15 @@ export function AuditChecklistResult({
aiContent = '', aiContent = '',
allowWordExport = false, allowWordExport = false,
onSaveSkillRule, onSaveSkillRule,
onResolveSkillRule,
onResolveSkillStyle,
}) { }) {
const [threshold, setThreshold] = useState( const [threshold, setThreshold] = useState(
Number(result.threshold || AUDIT_MATCH_THRESHOLD), Number(result.threshold || AUDIT_MATCH_THRESHOLD),
); );
const [wordLoading, setWordLoading] = useState(false); const [wordLoading, setWordLoading] = useState(false);
const [ruleSaving, setRuleSaving] = useState(false);
const reportContent = result.summaryContent ?? aiContent;
const [excludedRiskIds, setExcludedRiskIds] = useState(new Set()); const [excludedRiskIds, setExcludedRiskIds] = useState(new Set());
useEffect(() => { useEffect(() => {
...@@ -352,8 +383,8 @@ export function AuditChecklistResult({ ...@@ -352,8 +383,8 @@ export function AuditChecklistResult({
); );
const isQa = result.kind === 'qa'; const isQa = result.kind === 'qa';
const summary = useMemo( const summary = useMemo(
() => parseAuditSummary(aiContent, result.project), () => parseAuditSummary(reportContent, result.project),
[aiContent, result.project], [reportContent, result.project],
); );
const handleWordExport = async () => { const handleWordExport = async () => {
...@@ -361,7 +392,7 @@ export function AuditChecklistResult({ ...@@ -361,7 +392,7 @@ export function AuditChecklistResult({
try { try {
await exportAuditWord( await exportAuditWord(
effectiveResult, effectiveResult,
aiContent, reportContent,
threshold, threshold,
effectiveResult.reportStyle, effectiveResult.reportStyle,
); );
...@@ -401,6 +432,79 @@ export function AuditChecklistResult({ ...@@ -401,6 +432,79 @@ export function AuditChecklistResult({
return ( return (
<section aria-label="审计风险点结果" className="grid gap-6"> <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 ? ( {!isQa ? (
<> <>
<div className="grid gap-1"> <div className="grid gap-1">
...@@ -560,7 +664,7 @@ export function AuditChecklistResult({ ...@@ -560,7 +664,7 @@ export function AuditChecklistResult({
<Button <Button
variant="secondary" variant="secondary"
onPress={() => onPress={() =>
exportAuditMarkdown(effectiveResult, aiContent, threshold) exportAuditMarkdown(effectiveResult, reportContent, threshold)
} }
> >
Markdown Markdown
...@@ -568,7 +672,7 @@ export function AuditChecklistResult({ ...@@ -568,7 +672,7 @@ export function AuditChecklistResult({
<Button <Button
variant="secondary" variant="secondary"
onPress={() => onPress={() =>
exportAuditExcel(effectiveResult, aiContent, threshold) exportAuditExcel(effectiveResult, reportContent, threshold)
} }
> >
Excel Excel
......
...@@ -42,6 +42,10 @@ const QA_PATTERNS = [ ...@@ -42,6 +42,10 @@ const QA_PATTERNS = [
const REFINE_PATTERNS = [ const REFINE_PATTERNS = [
'去掉', '去掉',
'过滤',
'移除',
'剔除',
'不纳入',
'删除', '删除',
'不要', '不要',
'排除', '排除',
...@@ -149,6 +153,26 @@ function compactAuditRecord(record) { ...@@ -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({ export function buildAuditAnalysisPrompt({
query = '', query = '',
records = [], records = [],
...@@ -158,6 +182,8 @@ export function buildAuditAnalysisPrompt({ ...@@ -158,6 +182,8 @@ export function buildAuditAnalysisPrompt({
analysisMode = 'candidate', analysisMode = 'candidate',
batchNumber = 1, batchNumber = 1,
batchCount = 1, batchCount = 1,
totalRecords = 0,
candidatePoolSize = 0,
}) { }) {
const skill = collectAuditSkillInstructions(skillObject, query); const skill = collectAuditSkillInstructions(skillObject, query);
const context = records.slice(0, maxRecords).map(compactAuditRecord); const context = records.slice(0, maxRecords).map(compactAuditRecord);
...@@ -195,6 +221,9 @@ export function buildAuditAnalysisPrompt({ ...@@ -195,6 +221,9 @@ export function buildAuditAnalysisPrompt({
: 'Skill 业务过滤规则:无', : 'Skill 业务过滤规则:无',
'', '',
`当前批次:${batchNumber}/${batchCount};批次记录数:${context.length};本轮总记录数:${records.length}`, `当前批次:${batchNumber}/${batchCount};批次记录数:${context.length};本轮总记录数:${records.length}`,
candidatePoolSize > 0 && totalRecords > candidatePoolSize
? `知识库共 ${totalRecords} 条记录,已按本地相关度预筛出 ${candidatePoolSize} 条候选进入本轮分析。`
: '',
analysisMode === 'candidate' analysisMode === 'candidate'
? '候选阶段输出要求:返回本批次可能相关的风险点 ID,selectedRiskPointIds 可以适度偏宽。' ? '候选阶段输出要求:返回本批次可能相关的风险点 ID,selectedRiskPointIds 可以适度偏宽。'
: '收敛阶段输出要求:返回本批次最终建议保留的风险点 ID,selectedRiskPointIds 应尽量去重且严格。', : '收敛阶段输出要求:返回本批次最终建议保留的风险点 ID,selectedRiskPointIds 应尽量去重且严格。',
...@@ -769,12 +798,21 @@ export function classifyAuditAction(query = '', previousResult = null) { ...@@ -769,12 +798,21 @@ export function classifyAuditAction(query = '', previousResult = null) {
} }
export function isPersistentAuditRuleRequest(query = '') { export function isPersistentAuditRuleRequest(query = '') {
return /(以后|今后|长期|记住|保存到.?Skill|写入.?Skill|都不要|后续都)/i.test( return (
String(query), !/(?:不|别|不要|无需)(?:再)?(?:保存|写入|记住)|仅本次|只.*本次/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 [ return [
'这是一次审计报告 Word 排版调整请求。', '这是一次审计报告 Word 排版调整请求。',
changes.length changes.length
...@@ -783,6 +821,9 @@ export function buildAuditStylePrompt(changes = [], persisted = false) { ...@@ -783,6 +821,9 @@ export function buildAuditStylePrompt(changes = [], persisted = false) {
persisted persisted
? '该样式已保存为公共 Skill 的默认样式。' ? '该样式已保存为公共 Skill 的默认样式。'
: '该调整仅作用于当前报告。', : '该调整仅作用于当前报告。',
pending
? '请询问:是否将本次 Word 样式保存到公共 Skill,作为以后报告的默认样式?用户可回复“保存到 Skill”或“仅本次使用”,也可以点击对应按钮。未经确认不得声称已保存。'
: '',
'风险点、法规依据、来源、风险等级和审计分析结果保持不变。', '风险点、法规依据、来源、风险等级和审计分析结果保持不变。',
'请简短确认,不要重新生成审计风险点清单。', '请简短确认,不要重新生成审计风险点清单。',
].join('\n'); ].join('\n');
...@@ -816,12 +857,19 @@ export function parseRefineInstruction(query = '', previousThreshold) { ...@@ -816,12 +857,19 @@ export function parseRefineInstruction(query = '', previousThreshold) {
); );
} }
[ [
{ pattern: /(?:去掉|删除|不要|排除)([^,,。;;]+)/g, key: 'remove' }, {
pattern:
/(?:过滤掉|过滤|移除|剔除|不纳入|去掉|删除|不要|排除)([^,,。;;]+)/g,
key: 'remove',
},
{ pattern: /(?:只保留|仅保留|保留)([^,,。;;]+)/g, key: 'keep' }, { pattern: /(?:只保留|仅保留|保留)([^,,。;;]+)/g, key: 'keep' },
{ pattern: /(?:增加|加入|补充)([^,,。;;]+)/g, key: 'add' }, { pattern: /(?:增加|加入|补充)([^,,。;;]+)/g, key: 'add' },
].forEach(({ pattern, key }) => { ].forEach(({ pattern, key }) => {
for (const match of query.matchAll(pattern)) { for (const match of query.matchAll(pattern)) {
cleanInstructionValue(match[1]) if (/^(?:保存|写入|记住)/.test(match[1].trim())) continue;
cleanInstructionValue(
match[1].replace(/(?:并|然后)?(?:保存|写入)到.*$/i, ''),
)
.split(/和|与|以及|、|\s+/) .split(/和|与|以及|、|\s+/)
.filter((value) => value.length >= 2) .filter((value) => value.length >= 2)
.forEach((value) => instruction[key].push(value)); .forEach((value) => instruction[key].push(value));
...@@ -859,6 +907,26 @@ function scoreAllRecords(records, query, extraKeywords = []) { ...@@ -859,6 +907,26 @@ function scoreAllRecords(records, query, extraKeywords = []) {
.sort((a, b) => b.matchScore - a.matchScore); .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) { export function isAuditRefineRequest(query, previousResult) {
return Boolean( return Boolean(
previousResult && previousResult &&
...@@ -866,6 +934,54 @@ export function isAuditRefineRequest(query, 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( export function buildAuditResult(
query, query,
records, records,
......
...@@ -459,7 +459,6 @@ export function serializeAuditSkillObject(skillObject, fileDescriptor = null) { ...@@ -459,7 +459,6 @@ export function serializeAuditSkillObject(skillObject, fileDescriptor = null) {
skill_file: fileDescriptor || object.skillFile, skill_file: fileDescriptor || object.skillFile,
enabled: object.enabled !== false, enabled: object.enabled !== false,
description: object.description || '', description: object.description || '',
audit_domain: object.auditDomain || '',
}; };
} }
......
...@@ -217,12 +217,41 @@ export function isAuditStyleRequest(query = '') { ...@@ -217,12 +217,41 @@ export function isAuditStyleRequest(query = '') {
); );
} }
export function isPersistentStyleRequest(query = '') { export function isTemporaryStyleRequest(query = '') {
return /(以后|今后|默认|保存为|记住|都这样|统一样式|长期)/.test( return /(?:不|别|不要|无需)(?:再)?(?:保存|写入|记住)|(?:仅|只)(?:用于|用在|作用于)?本次|仅此次|不用保存/.test(
String(query), String(query),
); );
} }
export function isPersistentStyleRequest(query = '') {
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( export function parseAuditStyleRequest(
query = '', query = '',
currentStyle = DEFAULT_WORD_STYLE, currentStyle = DEFAULT_WORD_STYLE,
......
...@@ -41,7 +41,10 @@ import { ...@@ -41,7 +41,10 @@ import {
getAuditRecordKey, getAuditRecordKey,
isAuditRefineRequest, isAuditRefineRequest,
isPersistentAuditRuleRequest, isPersistentAuditRuleRequest,
packAuditRecords,
recordMatchesTerms, recordMatchesTerms,
refineCurrentAuditResult,
selectAnalysisCandidates,
slimAuditRecord, slimAuditRecord,
toPersistedAuditResult, toPersistedAuditResult,
} from '../../features/audit/engine'; } from '../../features/audit/engine';
...@@ -54,7 +57,11 @@ import { ...@@ -54,7 +57,11 @@ import {
parseSkillContent, parseSkillContent,
serializeAuditSkillObject, serializeAuditSkillObject,
} from '../../features/audit/skills'; } from '../../features/audit/skills';
import { parseAuditStyleRequest } from '../../features/audit/style'; import {
isTemporaryStyleRequest,
parseAuditSkillSaveDecision,
parseAuditStyleRequest,
} from '../../features/audit/style';
import { import {
callMcpTool, callMcpTool,
createConversation, createConversation,
...@@ -213,7 +220,7 @@ function getReferenceId(record) { ...@@ -213,7 +220,7 @@ function getReferenceId(record) {
} }
function getConversationAgent(metadata = {}) { function getConversationAgent(metadata = {}) {
const agent = metadata.agentConfig || metadata.agent; const agent = metadata.agentConfig;
if (agent && typeof agent === 'object' && agent.prompt) { if (agent && typeof agent === 'object' && agent.prompt) {
return serializeAgent(agent); return serializeAgent(agent);
...@@ -323,6 +330,7 @@ async function analyzeAuditRequest( ...@@ -323,6 +330,7 @@ async function analyzeAuditRequest(
skillObject, skillObject,
previousResult, previousResult,
signal, signal,
options = {},
) { ) {
const asList = (value) => const asList = (value) =>
Array.isArray(value) ? value : value ? [value] : []; Array.isArray(value) ? value : value ? [value] : [];
...@@ -337,30 +345,27 @@ async function analyzeAuditRequest( ...@@ -337,30 +345,27 @@ async function analyzeAuditRequest(
const finalCandidateLimit = 96; const finalCandidateLimit = 96;
const maxConsolidationRounds = 4; const maxConsolidationRounds = 4;
const maxConcurrentBatches = 4; const maxConcurrentBatches = 4;
const batchTimeoutMs = 45000; const batchTimeoutMs = 90000;
const maxBatchRetries = 1; const maxBatchRetries = 1;
const candidatePoolLimit = 500;
const packRecords = (input = []) => { const reportProgress = (text) => {
const batches = []; try {
let current = []; options.onProgress?.(text);
let currentSize = 0; } catch {}
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;
});
if (current.length) batches.push(current);
return batches.length ? batches : [[]];
}; };
// 本地评分预筛:万行级知识库不再全量进入 Map-Reduce 循环,
// 候选池交给下方分批语义分析
const analysisPool = selectAnalysisCandidates(records, query, {
limit: candidatePoolLimit,
extraKeywords: options.extraKeywords || [],
});
if (records.length > analysisPool.length) {
reportProgress(
`知识库共 ${records.length} 条,本地预筛后 ${analysisPool.length} 条进入 AI 语义分析`,
);
}
const mergePayloads = (payloads = [], trace = null) => { const mergePayloads = (payloads = [], trace = null) => {
const rationaleMap = new Map(); const rationaleMap = new Map();
const merged = { const merged = {
...@@ -433,7 +438,20 @@ async function analyzeAuditRequest( ...@@ -433,7 +438,20 @@ async function analyzeAuditRequest(
}; };
const runBatchPass = async (inputRecords, analysisMode, round) => { 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 payloads = [];
const failedBatches = []; const failedBatches = [];
inputRecords.forEach((record) => inputRecords.forEach((record) =>
...@@ -481,6 +499,8 @@ async function analyzeAuditRequest( ...@@ -481,6 +499,8 @@ async function analyzeAuditRequest(
analysisMode, analysisMode,
batchNumber, batchNumber,
batchCount: batches.length, batchCount: batches.length,
totalRecords: records.length,
candidatePoolSize: analysisPool.length,
}), }),
}, },
{ {
...@@ -500,10 +520,12 @@ async function analyzeAuditRequest( ...@@ -500,10 +520,12 @@ async function analyzeAuditRequest(
); );
const payload = parseJsonObject(await readLlmText(response)); const payload = parseJsonObject(await readLlmText(response));
if (!payload) throw new Error('模型未返回有效 JSON'); if (!payload) throw new Error('模型未返回有效 JSON');
reportBatchProgress();
return payload; return payload;
} catch (error) { } catch (error) {
if (error?.name === 'AbortError' && signal?.aborted) throw error; if (error?.name === 'AbortError' && signal?.aborted) throw error;
if (attempt >= maxBatchRetries) { if (attempt >= maxBatchRetries) {
reportBatchProgress();
failedBatches.push({ failedBatches.push({
batchNumber, batchNumber,
message: message:
...@@ -542,9 +564,11 @@ async function analyzeAuditRequest( ...@@ -542,9 +564,11 @@ async function analyzeAuditRequest(
}); });
const trace = { const trace = {
strategy: 'map_reduce_loop', strategy: 'local_prefilter_map_reduce_loop',
batchCharBudget: analysisBatchCharBudget, batchCharBudget: analysisBatchCharBudget,
maxConcurrentBatches, maxConcurrentBatches,
totalRecords: records.length,
candidatePoolSize: analysisPool.length,
rounds: [], rounds: [],
failedBatches: [], failedBatches: [],
recordsAnalyzed: 0, recordsAnalyzed: 0,
...@@ -555,7 +579,7 @@ async function analyzeAuditRequest( ...@@ -555,7 +579,7 @@ async function analyzeAuditRequest(
); );
const getRecord = (id) => recordMap.get(String(id)); 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.rounds.push({ mode: 'candidate', ...summarizePass(pass) });
trace.failedBatches.push(...pass.failedBatches); trace.failedBatches.push(...pass.failedBatches);
trace.recordsAnalyzed = analyzedRecordIds.size; trace.recordsAnalyzed = analyzedRecordIds.size;
...@@ -1003,6 +1027,7 @@ export default forwardRef((props, ref) => { ...@@ -1003,6 +1027,7 @@ export default forwardRef((props, ref) => {
if (chunk.data.includes('DONE')) { if (chunk.data.includes('DONE')) {
return { return {
...originMessage, ...originMessage,
auditProgress: undefined,
auditResult: auditResult:
originMessage?.auditResult || originMessage?.auditResult ||
pendingAuditResultRef.current || pendingAuditResultRef.current ||
...@@ -1029,6 +1054,7 @@ export default forwardRef((props, ref) => { ...@@ -1029,6 +1054,7 @@ export default forwardRef((props, ref) => {
...originMessage, ...originMessage,
role: 'assistant', role: 'assistant',
content: originContent || '已停止生成。', content: originContent || '已停止生成。',
auditProgress: undefined,
auditResult: auditResult:
originMessage?.auditResult || originMessage?.auditResult ||
pendingAuditResultRef.current || pendingAuditResultRef.current ||
...@@ -1041,6 +1067,19 @@ export default forwardRef((props, ref) => { ...@@ -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 { try {
let messageData; let messageData;
try { try {
...@@ -1077,6 +1116,8 @@ export default forwardRef((props, ref) => { ...@@ -1077,6 +1116,8 @@ export default forwardRef((props, ref) => {
think: `${originThink}${currentThink}`, think: `${originThink}${currentThink}`,
content: `${originContent}${currentContent}`, content: `${originContent}${currentContent}`,
role: 'assistant', role: 'assistant',
// 模型开始输出后不再展示分析进度
auditProgress: undefined,
auditResult: originMessage?.auditResult || undefined, auditResult: originMessage?.auditResult || undefined,
auditExport: originMessage?.auditExport || undefined, auditExport: originMessage?.auditExport || undefined,
tool_calls: toolCalls, tool_calls: toolCalls,
...@@ -1151,7 +1192,7 @@ export default forwardRef((props, ref) => { ...@@ -1151,7 +1192,7 @@ export default forwardRef((props, ref) => {
} }
}; };
const getMessagePrompt = async (message) => { const getMessagePrompt = async (message, { onAuditProgress } = {}) => {
const topic = conversationTopic; const topic = conversationTopic;
const usesKnowledgeAgent = conversationAgent?.key === 'knowledge-copilot'; const usesKnowledgeAgent = conversationAgent?.key === 'knowledge-copilot';
...@@ -1287,47 +1328,158 @@ export default forwardRef((props, ref) => { ...@@ -1287,47 +1328,158 @@ export default forwardRef((props, ref) => {
} }
if (usesStrictAuditKnowledge) { if (usesStrictAuditKnowledge) {
const records = await loadAuditRecords();
const skillObject = await loadAuditSkill();
const previousResult = findPreviousAuditResult(messages); 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); const auditAction = classifyAuditAction(message.content, previousResult);
if (auditAction === 'style_adjust' && previousResult) { if (auditAction === 'style_adjust' && previousResult) {
const styleRequest = parseAuditStyleRequest( const styleRequest = parseAuditStyleRequest(
message.content, 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 persisted = false;
let saveFailed = false;
if ( if (
styleRequest.matched && styleRequest.matched &&
styleRequest.changes.length && styleChanges.length &&
styleRequest.persist styleRequest.persist
) { ) {
try { try {
await saveAuditStyle(styleRequest.style); await saveAuditStyle(styleRequest.style);
persisted = true; persisted = true;
} catch (error) { } catch (error) {
saveFailed = true;
console.warn('审计 Word 样式保存失败,保留本次调整:', error); console.warn('审计 Word 样式保存失败,保留本次调整:', error);
toast.warning('本次样式已应用,但保存到公共 Skill 失败'); 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 = { const styledResult = {
...previousResult, ...previousResult,
request: message.content, request: message.content,
reportStyle: styleRequest.style, reportStyle: styleRequest.style,
styleAdjusted: true, 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(), generatedAt: new Date().toISOString(),
}; };
pendingAuditResultRef.current = toPersistedAuditResult(styledResult); pendingAuditResultRef.current = toPersistedAuditResult(styledResult);
auditKnowledgePrompt = buildAuditStylePrompt( auditKnowledgePrompt = buildAuditStylePrompt(
styleRequest.changes, styleChanges,
persisted, persisted,
Boolean(pendingSkillStyle),
); );
} else { } else {
const records = await loadAuditRecords();
const skillObject = await loadAuditSkill();
const usesChecklistMode = auditAction !== 'qa'; const usesChecklistMode = auditAction !== 'qa';
if (usesChecklistMode) { if (usesChecklistMode) {
const query = message.content; const query = message.content;
let result = null; let result = null;
let queryExpansion = null;
const isRefine = isAuditRefineRequest( const isRefine = isAuditRefineRequest(
message.content, message.content,
previousResult, previousResult,
...@@ -1341,6 +1493,19 @@ export default forwardRef((props, ref) => { ...@@ -1341,6 +1493,19 @@ export default forwardRef((props, ref) => {
{ skillObject }, { skillObject },
); );
} else if (!isRefine) { } 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 { try {
const aiAnalysis = await analyzeAuditRequest( const aiAnalysis = await analyzeAuditRequest(
message.content, message.content,
...@@ -1349,6 +1514,12 @@ export default forwardRef((props, ref) => { ...@@ -1349,6 +1514,12 @@ export default forwardRef((props, ref) => {
skillObject, skillObject,
previousResult, previousResult,
abortController.current?.signal, abortController.current?.signal,
{
extraKeywords: queryExpansion?.applied
? queryExpansion.expandedKeywords
: [],
onProgress: onAuditProgress,
},
); );
if (aiAnalysis) { if (aiAnalysis) {
result = buildAuditResultFromAnalysis( result = buildAuditResultFromAnalysis(
...@@ -1367,20 +1538,6 @@ export default forwardRef((props, ref) => { ...@@ -1367,20 +1538,6 @@ export default forwardRef((props, ref) => {
} }
if (!result) { if (!result) {
throwIfAborted(); 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, { result = buildAuditResult(query, records, previousResult, {
extraKeywords: queryExpansion?.applied extraKeywords: queryExpansion?.applied
? queryExpansion.expandedKeywords ? queryExpansion.expandedKeywords
...@@ -1423,6 +1580,7 @@ export default forwardRef((props, ref) => { ...@@ -1423,6 +1580,7 @@ export default forwardRef((props, ref) => {
skillObject, skillObject,
previousResult, previousResult,
abortController.current?.signal, abortController.current?.signal,
{ onProgress: onAuditProgress },
); );
if (aiAnalysis) { if (aiAnalysis) {
result = buildAuditResultFromAnalysis( result = buildAuditResultFromAnalysis(
...@@ -1482,7 +1640,17 @@ export default forwardRef((props, ref) => { ...@@ -1482,7 +1640,17 @@ export default forwardRef((props, ref) => {
data: 'LOADING', 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 = const tools =
conversationTopic || conversationTopic ||
['knowledge-copilot', 'audit-checklist'].includes( ['knowledge-copilot', 'audit-checklist'].includes(
...@@ -1879,7 +2047,6 @@ export default forwardRef((props, ref) => { ...@@ -1879,7 +2047,6 @@ export default forwardRef((props, ref) => {
knowledgeBases: serializedKnowledgeBases || [], knowledgeBases: serializedKnowledgeBases || [],
}; };
const topicId = getReferenceId(selectedTopic); const topicId = getReferenceId(selectedTopic);
const agentId = getReferenceId(selectedAgent);
if (selectedTopic) { if (selectedTopic) {
conversationData.topicConfig = selectedTopic; conversationData.topicConfig = selectedTopic;
...@@ -1887,9 +2054,6 @@ export default forwardRef((props, ref) => { ...@@ -1887,9 +2054,6 @@ export default forwardRef((props, ref) => {
if (topicId) { if (topicId) {
conversationData.topic = topicId; conversationData.topic = topicId;
} }
if (agentId) {
conversationData.agent = agentId;
}
await addConversation(conversationData, userMessage); await addConversation(conversationData, userMessage);
...@@ -1969,6 +2133,38 @@ export default forwardRef((props, ref) => { ...@@ -1969,6 +2133,38 @@ export default forwardRef((props, ref) => {
onSubmit(userMessage); 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) => { const renderAssistantExtras = (item) => {
if (item.message?.role !== 'assistant') return null; if (item.message?.role !== 'assistant') return null;
const allowWordExport = const allowWordExport =
...@@ -1984,6 +2180,12 @@ export default forwardRef((props, ref) => { ...@@ -1984,6 +2180,12 @@ export default forwardRef((props, ref) => {
aiContent={item.message.content} aiContent={item.message.content}
allowWordExport={allowWordExport} allowWordExport={allowWordExport}
onSaveSkillRule={saveAuditSkillRule} onSaveSkillRule={saveAuditSkillRule}
onResolveSkillRule={(save) =>
resolveAuditSkillConfirmation(item, 'rule', save)
}
onResolveSkillStyle={(save) =>
resolveAuditSkillConfirmation(item, 'style', save)
}
result={item.message.auditResult} result={item.message.auditResult}
/> />
) : null} ) : null}
......
...@@ -547,7 +547,7 @@ export async function queryConversations(query) { ...@@ -547,7 +547,7 @@ export async function queryConversations(query) {
page_size: 20, page_size: 20,
mode: 'basic', mode: 'basic',
select: 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', sort: '-metadata.pinned -created_at',
filter: { filter: {
'metadata.user': userInfo._id, '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