Commit 5919a2cf authored by David Yang's avatar David Yang

feat: 添加审计功能

parent 671bc70b
......@@ -8,6 +8,7 @@ export default defineConfig({
model: {},
initialState: {},
request: {},
esbuildMinifyIIFE: true,
reactQuery: {
devtool: false,
},
......
{
"name": "ai_chat",
"version": "1.0.1",
"version": "1.0.3",
"build": "abc",
"description": "merge master",
"date": "2025-07-14 19:27:20",
......
import {
DatabaseOutlined,
DownloadOutlined,
FileExcelOutlined,
FileMarkdownOutlined,
FileWordOutlined,
InboxOutlined,
ReloadOutlined,
UploadOutlined,
} from '@ant-design/icons';
import {
Alert,
Button,
Collapse,
Descriptions,
Divider,
Empty,
Flex,
InputNumber,
message,
Modal,
Progress,
Space,
Table,
Tag,
Tooltip,
Typography,
Upload,
} from 'antd';
import { createStyles } from 'antd-style';
import { useEffect, useMemo, useState } from 'react';
import { AUDIT_MATCH_THRESHOLD } from './constants';
import { groupAuditRecords } from './engine';
import { importAuditRecords, parseAuditExcel } from './excel';
import {
exportAuditExcel,
exportAuditMarkdown,
exportAuditWord,
filterAuditRecords,
} from './export';
const useAuditStyles = createStyles(({ token, css }) => ({
knowledgeBar: css`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
padding: 8px 12px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: 8px;
background: ${token.colorBgContainer};
@media (max-width: 575px) {
align-items: flex-start;
flex-direction: column;
}
`,
knowledgeMeta: css`
min-width: 0;
color: ${token.colorTextSecondary};
strong {
color: ${token.colorText};
margin-right: 8px;
}
`,
result: css`
width: 100%;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid ${token.colorBorderSecondary};
`,
resultHeader: css`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
@media (max-width: 575px) {
flex-direction: column;
}
`,
stats: css`
color: ${token.colorTextSecondary};
font-size: 13px;
line-height: 1.6;
font-variant-numeric: tabular-nums;
`,
businessSection: css`
padding: 14px 0 4px;
border-top: 1px solid ${token.colorBorderSecondary};
&:first-of-type {
border-top: 0;
padding-top: 2px;
}
`,
businessTitle: css`
margin: 0 0 12px !important;
font-size: 16px !important;
line-height: 1.5 !important;
`,
levelOne: css`
margin: 0 0 16px 12px;
@media (max-width: 575px) {
margin-left: 0;
}
`,
levelOneTitle: css`
display: block;
margin-bottom: 8px;
color: ${token.colorText};
font-size: 14px;
`,
detailCollapse: css`
border-radius: 8px;
overflow: hidden;
background: ${token.colorFillAlter};
.ant-collapse-header {
min-height: 44px;
align-items: center !important;
padding: 10px 12px !important;
}
.ant-collapse-content-box {
padding: 0 12px 12px !important;
}
`,
risk: css`
padding: 12px 0 12px 12px;
border-left: 3px solid ${token.colorPrimaryBorder};
border-bottom: 1px solid ${token.colorBorderSecondary};
&:last-child {
border-bottom: 0;
}
`,
riskTitle: css`
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
`,
riskDescription: css`
margin: 0 0 10px !important;
color: ${token.colorText};
line-height: 1.7;
overflow-wrap: anywhere;
`,
exportBar: css`
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-top: 14px;
border-top: 1px solid ${token.colorBorderSecondary};
`,
importPreview: css`
margin-top: 16px;
`,
importErrors: css`
max-height: 120px;
overflow: auto;
margin-top: 12px;
`,
}));
function scoreColor(score) {
if (score >= 85) return 'success';
if (score >= 70) return 'processing';
if (score >= 55) return 'warning';
return 'default';
}
function downloadText(content, fileName) {
const url = URL.createObjectURL(
new Blob([content], { type: 'text/markdown;charset=utf-8' }),
);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function AuditKnowledgeBar({ count, loading, onOpen, onRefresh }) {
const { styles } = useAuditStyles();
return (
<div className={styles.knowledgeBar}>
<div className={styles.knowledgeMeta}>
<DatabaseOutlined style={{ marginRight: 8 }} />
<strong>审计风险点知识库</strong>
{loading ? '正在读取' : `共 ${count} 条有效风险点`}
</div>
<Space wrap>
<Tooltip title="刷新知识库">
<Button
aria-label="刷新知识库"
icon={<ReloadOutlined />}
loading={loading}
onClick={onRefresh}
/>
</Tooltip>
<Button icon={<UploadOutlined />} type="primary" onClick={onOpen}>
导入 Excel
</Button>
</Space>
</div>
);
}
function RiskDetail({ risk, index, styles }) {
return (
<div className={styles.risk}>
<div className={styles.riskTitle}>
<Typography.Text strong>
风险点 {index + 1} · 来源行号 {risk.sourceRow || '未知'}
</Typography.Text>
<Space size={6} wrap>
<Tag color={scoreColor(risk.matchScore)}>{risk.matchScore} 分</Tag>
{risk.riskLevel && <Tag>{risk.riskLevel}风险</Tag>}
</Space>
</div>
<Typography.Paragraph className={styles.riskDescription}>
{risk.riskDescription}
</Typography.Paragraph>
<Descriptions
size="small"
column={{ xs: 1, sm: 1, md: 2 }}
items={[
{
key: 'basis',
label: '法规依据',
children: risk.regulationBasis || '未提供',
},
{
key: 'source',
label: '风险点来源',
children: risk.riskSource || '未提供',
},
{
key: 'reason',
label: '命中依据',
children: risk.matchReasons?.join('、') || '字段词项覆盖',
},
{ key: 'level', label: '匹配等级', children: risk.matchLevel },
]}
/>
</div>
);
}
export function AuditChecklistResult({ result, aiContent = '' }) {
const { styles } = useAuditStyles();
const [threshold, setThreshold] = useState(
Number(result.threshold || AUDIT_MATCH_THRESHOLD),
);
const [wordLoading, setWordLoading] = useState(false);
useEffect(() => {
setThreshold(Number(result.threshold || AUDIT_MATCH_THRESHOLD));
}, [result.generatedAt, result.threshold]);
const visibleRecords = useMemo(
() => filterAuditRecords(result, threshold),
[result, threshold],
);
const groups = useMemo(
() => groupAuditRecords(visibleRecords),
[visibleRecords],
);
const isQa = result.kind === 'qa';
const handleWordExport = async () => {
setWordLoading(true);
try {
await exportAuditWord(result, aiContent, threshold);
} catch (error) {
message.error(`Word 导出失败:${error.message || '未知错误'}`);
} finally {
setWordLoading(false);
}
};
return (
<section className={styles.result} aria-label="审计风险点结果">
<div className={styles.resultHeader}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
{isQa ? '引用风险点' : '审计内容'}
</Typography.Title>
<div className={styles.stats}>
知识库 {result.totalCount} 条 · 全量评分 {result.scoredCount} 条 ·
当前显示 {visibleRecords.length} 条
{result.refined ? ` · 已应用调整“${result.request}”` : ''}
</div>
</div>
<Tag color={visibleRecords.length ? 'processing' : 'warning'}>
匹配度 ≥ {threshold}
</Tag>
</div>
{!visibleRecords.length ? (
<Alert
type="warning"
showIcon
message="当前阈值下未命中风险点"
description="请补充更明确的业务板块、项目类别或风险点来源,或在消息中指定更低的匹配度阈值。"
/>
) : (
groups.map((business, businessIndex) => (
<section
className={styles.businessSection}
key={business.businessSegment}
>
<Typography.Title level={5} className={styles.businessTitle}>
{businessIndex + 1}. {business.businessSegment}
</Typography.Title>
{business.level1Projects.map((level1, level1Index) => (
<div
className={styles.levelOne}
key={`${business.businessSegment}-${level1.level1Project}`}
>
<Typography.Text strong className={styles.levelOneTitle}>
{businessIndex + 1}.{level1Index + 1} {level1.level1Project}
</Typography.Text>
<Collapse
className={styles.detailCollapse}
bordered={false}
items={level1.level2Projects.map((level2, level2Index) => ({
key: `${level1.level1Project}-${level2.level2Project}`,
label: `${businessIndex + 1}.${level1Index + 1}.${
level2Index + 1
} ${level2.level2Project}`,
extra: `${level2.risks.length} 个风险点`,
children: level2.risks.map((risk, riskIndex) => (
<RiskDetail
key={`${risk.id || risk.sourceRow}-${riskIndex}`}
risk={risk}
index={riskIndex}
styles={styles}
/>
)),
}))}
/>
</div>
))}
</section>
))
)}
{!isQa && visibleRecords.length > 0 && (
<div className={styles.exportBar}>
<Space wrap>
<Typography.Text>导出最低匹配度</Typography.Text>
<InputNumber
aria-label="导出最低匹配度"
min={Number(result.threshold || 0)}
max={100}
step={5}
value={threshold}
onChange={(value) =>
setThreshold(Number(value || result.threshold))
}
/>
</Space>
<Space wrap>
<Button
icon={<FileMarkdownOutlined />}
onClick={() => exportAuditMarkdown(result, aiContent, threshold)}
>
Markdown
</Button>
<Button
icon={<FileExcelOutlined />}
onClick={() => exportAuditExcel(result, aiContent, threshold)}
>
Excel
</Button>
<Button
type="primary"
icon={<FileWordOutlined />}
loading={wordLoading}
onClick={handleWordExport}
>
Word
</Button>
</Space>
</div>
)}
</section>
);
}
export function AuditKnowledgeImportDialog({
open,
existingRecords,
createRecordFn,
updateRecordFn,
onCancel,
onImported,
}) {
const { styles } = useAuditStyles();
const [parsing, setParsing] = useState(false);
const [parsed, setParsed] = useState(null);
const [importing, setImporting] = useState(false);
const [progress, setProgress] = useState(null);
useEffect(() => {
if (!open) {
setParsed(null);
setProgress(null);
}
}, [open]);
const handleFile = async (file) => {
setParsing(true);
setProgress(null);
try {
const result = await parseAuditExcel(file);
setParsed(result);
if (!result.records.length) message.warning('Excel 中未识别到有效风险点');
} catch (error) {
setParsed(null);
message.error(`Excel 解析失败:${error.message || '文件格式异常'}`);
} finally {
setParsing(false);
}
return false;
};
const handleImport = async () => {
if (!parsed?.records.length) return;
setImporting(true);
try {
const result = await importAuditRecords({
records: parsed.records,
existingRecords: existingRecords || [],
createRecord: createRecordFn,
updateRecord: updateRecordFn,
onProgress: setProgress,
});
if (result.failed) {
message.warning(
`导入完成:新增 ${result.created},更新 ${result.updated},跳过 ${result.skipped},失败 ${result.failed}`,
);
} else {
message.success(
`导入完成:新增 ${result.created},更新 ${result.updated},跳过 ${result.skipped}`,
);
}
await onImported?.(result);
if (!result.failed) onCancel?.();
} catch (error) {
message.error(`知识库写入失败:${error.message || '未知错误'}`);
} finally {
setImporting(false);
}
};
const columns = [
{ title: '业务板块', dataIndex: 'businessSegment', width: 130 },
{ title: '一级项目', dataIndex: 'level1Project', width: 150 },
{ title: '二级项目', dataIndex: 'level2Project', width: 160 },
{
title: '具体风险点描述',
dataIndex: 'riskDescription',
width: 320,
ellipsis: true,
},
{ title: '风险等级', dataIndex: 'riskLevel', width: 100 },
{ title: '来源行号', dataIndex: 'sourceRow', width: 100 },
];
return (
<Modal
title="导入审计风险点 Excel"
open={open}
width={920}
onCancel={onCancel}
maskClosable={!importing}
closable={!importing}
footer={[
<Button key="cancel" onClick={onCancel} disabled={importing}>
取消
</Button>,
<Button
key="markdown"
icon={<DownloadOutlined />}
disabled={!parsed?.records.length || importing}
onClick={() =>
downloadText(
parsed.markdown,
`${parsed.fileName.replace(/\.[^.]+$/, '')}.md`,
)
}
>
下载 Markdown
</Button>,
<Button
key="import"
type="primary"
icon={<DatabaseOutlined />}
loading={importing}
disabled={!parsed?.records.length}
onClick={handleImport}
>
写入知识库
</Button>,
]}
>
<Upload.Dragger
accept=".xlsx,.xls"
maxCount={1}
showUploadList={false}
beforeUpload={handleFile}
disabled={parsing || importing}
>
<p className="ant-upload-drag-icon">
{parsing ? <ReloadOutlined spin /> : <InboxOutlined />}
</p>
<p className="ant-upload-text">
{parsing ? '正在解析 Excel' : '选择或拖入 Excel 文件'}
</p>
</Upload.Dragger>
{progress && (
<div className={styles.importPreview}>
<Progress
percent={Math.round((progress.completed / progress.total) * 100)}
status={progress.failed ? 'exception' : 'active'}
format={() => `${progress.completed}/${progress.total}`}
/>
{progress.errors?.length > 0 && (
<Alert
type="error"
showIcon
message={`${progress.errors.length} 条记录写入失败`}
description={progress.errors
.slice(0, 6)
.map(
(error) =>
`来源行号 ${error.sourceRow || '未知'}:${error.message}`,
)
.join(';')}
/>
)}
</div>
)}
{parsed && (
<div className={styles.importPreview}>
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
<Typography.Text strong>{parsed.fileName}</Typography.Text>
<Space wrap>
<Tag color="success">有效 {parsed.records.length}</Tag>
<Tag>重复行 {parsed.duplicateRows}</Tag>
<Tag color={parsed.errors.length ? 'warning' : 'default'}>
异常 {parsed.errors.length}
</Tag>
</Space>
</Flex>
<Divider style={{ margin: '12px 0' }} />
{parsed.records.length ? (
<Table
size="small"
rowKey={(record) => `${record.sheetName}-${record.sourceRow}`}
columns={columns}
dataSource={parsed.records.slice(0, 20)}
pagination={false}
scroll={{ x: 960, y: 280 }}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
{parsed.errors.length > 0 && (
<div className={styles.importErrors}>
<Alert
type="warning"
showIcon
message={`${parsed.errors.length} 行未导入`}
description={parsed.errors
.slice(0, 8)
.map(
(error) =>
`${error.sheetName} 第 ${error.sourceRow} 行:${error.message}`,
)
.join(';')}
/>
</div>
)}
</div>
)}
</Modal>
);
}
export const AUDIT_RISK_POINT_SUBJECT = 'audit_risk_point';
export const AUDIT_MATCH_THRESHOLD = 80;
export const AUDIT_PAGE_SIZE = 2000;
export const AUDIT_PROMPT_RECORD_LIMIT = 80;
export const AUDIT_FIELD_ALIASES = {
businessSegment: ['business_segment', '业务板块'],
level1Project: ['level1_project', '一级项目'],
level2Project: ['level2_project', '二级项目'],
riskDescription: ['risk_description', '具体风险点描述'],
regulationBasis: ['regulation_basis', '法规依据'],
riskSource: ['risk_source', '风险点来源'],
riskLevel: ['risk_level', '风险等级'],
keywords: ['keywords', '关键词'],
mdContent: ['md_content', 'Markdown 内容', 'markdown_content'],
enabled: ['enabled', '是否启用'],
sourceRow: ['source_row', '来源行号', '_source_row'],
};
export const AUDIT_EXCEL_FIELDS = [
{ key: 'businessSegment', label: '业务板块', alias: 'business_segment' },
{ key: 'level1Project', label: '一级项目', alias: 'level1_project' },
{ key: 'level2Project', label: '二级项目', alias: 'level2_project' },
{
key: 'riskDescription',
label: '具体风险点描述',
alias: 'risk_description',
required: true,
},
{ key: 'regulationBasis', label: '法规依据', alias: 'regulation_basis' },
{ key: 'riskSource', label: '风险点来源', alias: 'risk_source' },
{ key: 'riskLevel', label: '风险等级', alias: 'risk_level' },
{ key: 'keywords', label: '关键词', alias: 'keywords' },
{ key: 'mdContent', label: 'Markdown 内容', alias: 'md_content' },
{ key: 'enabled', label: '是否启用', alias: 'enabled' },
{ key: 'sourceRow', label: '来源行号', alias: 'source_row' },
];
export const AUDIT_STOP_WORDS = new Set([
'专项',
'审计',
'业务',
'风险',
'风险点',
'检查',
'检查点',
'项目',
'公司',
'基金',
'情况',
'相关',
'工作',
'制度',
'流程',
'内部',
'合规',
'生成',
'清单',
'查询',
'查找',
'内容',
'帮我',
]);
export const AUDIT_DOMAIN_KEYWORDS = {
采购: [
'采购',
'供应商',
'服务机构',
'外包',
'合同',
'招标',
'询价',
'验收',
'付款',
'廉洁',
'第三方',
'服务协议',
],
衍生品: [
'衍生品',
'衍生',
'期货',
'期权',
'互换',
'远期',
'杠杆',
'保证金',
'对手方',
],
反洗钱: [
'反洗钱',
'洗钱风险',
'可疑交易',
'大额交易',
'客户身份识别',
'身份尽职调查',
'客户风险等级',
'黑名单',
'制裁名单',
'交易监控',
'监管报送',
],
信息技术: [
'信息技术',
'系统',
'网络',
'数据',
'安全',
'灾备',
'运维',
'机房',
],
销售: ['销售', '客户', '投资者', '适当性', '宣传', '推介', '代销', '投诉'],
投研交易: [
'投资',
'研究',
'交易',
'关联交易',
'内幕',
'组合',
'授权',
'指令',
],
运营: ['运营', '估值', '核算', '清算', '注册登记', 'TA', '信息披露'],
员工行为: [
'员工行为',
'从业人员',
'投资行为',
'通讯工具',
'信息保密',
'廉洁从业',
'利益输送',
'人员资质',
'任免管理',
'薪酬管理',
'问责管理',
'员工培训',
],
公司治理: [
'公司治理',
'股东',
'董事会',
'监事',
'章程',
'高管',
'专业委员会',
],
合规管理: ['合规管理', '督察长', '监察稽核', '利益冲突'],
产品业务: ['产品', '公募', '资管', '备案', '注册', '展期', '清算'],
};
export const AUDIT_CHECKLIST_RULES = `
风险点、法规依据、风险点来源、风险等级必须来自审计风险点知识库,不得编造或改写原文。
审计内容必须按“业务板块 -> 一级项目 -> 二级项目 -> 风险点”组织,相同层级必须合并。
每条风险点必须保留匹配度、具体风险点描述、法规依据、风险点来源、风险等级。
如果知识库没有足够相关内容,必须明确说明知识库覆盖不足。
`;
import {
AUDIT_CHECKLIST_RULES,
AUDIT_DOMAIN_KEYWORDS,
AUDIT_FIELD_ALIASES,
AUDIT_MATCH_THRESHOLD,
AUDIT_PAGE_SIZE,
AUDIT_PROMPT_RECORD_LIMIT,
AUDIT_RISK_POINT_SUBJECT,
AUDIT_STOP_WORDS,
} from './constants';
const GENERATE_PATTERNS = [
'生成',
'审计清单',
'检查清单',
'检查表',
'审计方案',
'我要开展',
'开展',
'做一个',
'帮我做',
'专项审计',
'出一份',
];
const QA_PATTERNS = [
'什么',
'哪些',
'如何',
'是否',
'为什么',
'依据',
'法规',
'风险点有哪些',
'帮我查',
'查询',
'检索',
];
const REFINE_PATTERNS = [
'去掉',
'删除',
'不要',
'排除',
'只保留',
'仅保留',
'增加',
'加入',
'补充',
'阈值',
'最低分',
];
function toText(value) {
if (Array.isArray(value)) {
return value.join(' ');
}
if (value === undefined || value === null) {
return '';
}
return String(value).trim();
}
export function normalizeRecordList(res) {
if (Array.isArray(res)) return res;
if (Array.isArray(res?.records)) return res.records;
if (Array.isArray(res?.items)) return res.items;
if (Array.isArray(res?.data)) return res.data;
if (Array.isArray(res?.data?.records)) return res.data.records;
if (Array.isArray(res?.data?.items)) return res.data.items;
if (Array.isArray(res?.hits?.hits)) {
return res.hits.hits.map((item) => item._source || item);
}
return [];
}
function getResponseTotal(res) {
const values = [
res?.total,
res?.count,
res?.data?.total,
res?.data?.count,
res?.hits?.total?.value,
res?.hits?.total,
];
return values.find((value) => Number.isFinite(Number(value)));
}
function getAuditField(record, aliases = []) {
const sources = [
record?.metadata,
record?.fields,
record?.data,
record,
].filter(Boolean);
for (const source of sources) {
for (const alias of aliases) {
const value = source?.[alias];
if (value !== undefined && value !== null && value !== '') return value;
}
}
return '';
}
export function normalizeAuditRecord(record) {
const normalized = {
id: record?.name || record?._id || record?.id || '',
title: record?.title || '',
businessSegment: toText(
getAuditField(record, AUDIT_FIELD_ALIASES.businessSegment),
),
level1Project: toText(
getAuditField(record, AUDIT_FIELD_ALIASES.level1Project),
),
level2Project: toText(
getAuditField(record, AUDIT_FIELD_ALIASES.level2Project),
),
riskDescription: toText(
getAuditField(record, AUDIT_FIELD_ALIASES.riskDescription),
),
regulationBasis: toText(
getAuditField(record, AUDIT_FIELD_ALIASES.regulationBasis),
),
riskSource: toText(getAuditField(record, AUDIT_FIELD_ALIASES.riskSource)),
riskLevel: toText(getAuditField(record, AUDIT_FIELD_ALIASES.riskLevel)),
keywords: getAuditField(record, AUDIT_FIELD_ALIASES.keywords),
mdContent: toText(getAuditField(record, AUDIT_FIELD_ALIASES.mdContent)),
enabled: getAuditField(record, AUDIT_FIELD_ALIASES.enabled),
sourceRow: toText(getAuditField(record, AUDIT_FIELD_ALIASES.sourceRow)),
};
normalized.searchText = [
normalized.businessSegment,
normalized.level1Project,
normalized.level2Project,
normalized.riskDescription,
normalized.regulationBasis,
normalized.riskSource,
normalized.riskLevel,
toText(normalized.keywords),
]
.filter(Boolean)
.join(' ');
return normalized;
}
export function isAuditRecordEnabled(record) {
if (
record.enabled === '' ||
record.enabled === undefined ||
record.enabled === null
) {
return true;
}
return !['false', '0', '否', '停用', '禁用'].includes(
String(record.enabled).toLowerCase(),
);
}
export async function fetchAllAuditRiskPoints(queryRecords) {
const records = [];
const seenIds = new Set();
let page = 1;
let expectedTotal = null;
while (page <= 20) {
const res = await queryRecords({
subject: AUDIT_RISK_POINT_SUBJECT,
page,
page_size: AUDIT_PAGE_SIZE,
mode: 'basic',
});
const pageRecords = normalizeRecordList(res);
expectedTotal = Number(getResponseTotal(res)) || expectedTotal;
let added = 0;
pageRecords.forEach((record, index) => {
const normalized = normalizeAuditRecord(record);
const identity =
normalized.id ||
`${page}:${index}:${auditRecordIdentity(normalized, true)}`;
if (!seenIds.has(identity)) {
seenIds.add(identity);
records.push(normalized);
added += 1;
}
});
if (
pageRecords.length === 0 ||
pageRecords.length < AUDIT_PAGE_SIZE ||
added === 0 ||
(expectedTotal && records.length >= expectedTotal)
) {
break;
}
page += 1;
}
return records.filter(
(record) =>
isAuditRecordEnabled(record) &&
(record.riskDescription || record.mdContent) &&
(record.businessSegment || record.level1Project || record.level2Project),
);
}
export function auditRecordIdentity(record, includeContent = false) {
const values = [
record.businessSegment,
record.level1Project,
record.level2Project,
record.riskDescription,
record.riskSource,
];
if (includeContent) {
values.push(record.regulationBasis, record.riskLevel, record.mdContent);
}
return values.map((value) => toText(value)).join('|');
}
export function buildRiskPointMarkdown(record, index = null) {
if (record.mdContent) {
return index
? `### 风险点 ${index}\n${record.mdContent}`
: record.mdContent;
}
const heading = index ? `### 风险点 ${index}` : '### 风险点';
return [
`${heading}:${[
record.businessSegment || '未分类业务板块',
record.level1Project || '未分类一级项目',
record.level2Project || '未分类二级项目',
].join(' / ')}`,
`- 来源行号:${record.sourceRow || '未知'}`,
`- 业务板块:${record.businessSegment || ''}`,
`- 一级项目:${record.level1Project || ''}`,
`- 二级项目:${record.level2Project || ''}`,
`- 具体风险点描述:${record.riskDescription || ''}`,
`- 法规依据:${record.regulationBasis || ''}`,
`- 风险点来源:${record.riskSource || ''}`,
`- 风险等级:${record.riskLevel || ''}`,
].join('\n');
}
export function extractSourceConstraints(query = '') {
const constraints = [];
const patterns = [
/(?:来自|来源(?:为|是|于)?|源自|出自)([\u4e00-\u9fffA-Za-z0-9()()·\-\s]{2,40})/g,
/(?:与|和|跟)([\u4e00-\u9fffA-Za-z0-9()()·\-\s]{2,30})(?:有关|相关)/g,
];
patterns.forEach((pattern) => {
for (const match of String(query).matchAll(pattern)) {
const value = match[1]
.split(
/的|中|里|内|相关|有关|关于|检查点|检查|审计|风险点|内容|资料|记录|清单|,|,|。|;|;/,
)[0]
.trim();
if (value.length >= 2 && !constraints.includes(value)) {
constraints.push(value);
}
}
});
['安永', '普华永道', '德勤', '毕马威', '证监局', '中基协'].forEach(
(alias) => {
if (String(query).includes(alias) && !constraints.includes(alias)) {
constraints.push(alias);
}
},
);
return constraints;
}
function extractPrimaryKeywords(query = '') {
const results = new Set();
String(query)
.match(/[\u4e00-\u9fffA-Za-z0-9]{2,}/g)
?.forEach((token) => {
token
.split(/专项|审计|业务|风险|检查|项目|清单|生成|查询|检索|相关/)
.map((item) => item.trim())
.filter((item) => item.length >= 2 && !AUDIT_STOP_WORDS.has(item))
.forEach((item) => results.add(item));
});
return Array.from(results);
}
export function extractAuditKeywords(query = '', extraKeywords = []) {
const keywords = new Set(extractPrimaryKeywords(query));
const domains = [];
const cleaned = String(query).replace(/[\s,。;、,.;::()()]+/g, '');
Object.entries(AUDIT_DOMAIN_KEYWORDS).forEach(([domain, values]) => {
if (
cleaned.includes(domain) ||
values.some((value) => cleaned.includes(value))
) {
domains.push(domain);
keywords.add(domain);
values.forEach((value) => keywords.add(value));
}
});
extraKeywords
.map((keyword) => toText(keyword))
.filter((keyword) => keyword.length >= 2 && keyword.length <= 12)
.forEach((keyword) => keywords.add(keyword));
return {
domains,
primaryKeywords: extractPrimaryKeywords(query),
keywords: Array.from(keywords)
.filter((keyword) => !AUDIT_STOP_WORDS.has(keyword))
.sort((a, b) => b.length - a.length),
sourceConstraints: extractSourceConstraints(query),
};
}
function categoryMatches(value, categories) {
return categories.some(
(category) =>
category &&
value &&
(value.includes(category) || category.includes(value)),
);
}
function getRelevantCategories(records, keywordInfo) {
const candidates = new Set();
records.forEach((record) => {
[
record.businessSegment,
record.level1Project,
record.level2Project,
].forEach((category) => {
if (
category &&
keywordInfo.keywords.some((keyword) => category.includes(keyword))
) {
candidates.add(category);
}
});
});
return Array.from(candidates);
}
function ngramCoverage(query, text) {
const cleanedQuery = extractPrimaryKeywords(query).join('');
if (cleanedQuery.length < 2 || !text) return 0;
const grams = new Set();
for (let index = 0; index < cleanedQuery.length - 1; index += 1) {
grams.add(cleanedQuery.slice(index, index + 2));
}
if (!grams.size) return 0;
const matched = Array.from(grams).filter((gram) =>
text.includes(gram),
).length;
return matched / grams.size;
}
function matchScoreLevel(score) {
if (score >= 85) return '高';
if (score >= 70) return '较高';
if (score >= 55) return '中';
return '低';
}
export function scoreAuditRecord(
record,
query,
keywordInfo,
relevantCategories,
) {
if (
keywordInfo.sourceConstraints.length &&
!keywordInfo.sourceConstraints.some((source) =>
record.riskSource.includes(source),
)
) {
return null;
}
let score = 0;
const reasons = [];
const sourceMatched = keywordInfo.sourceConstraints.some((source) =>
record.riskSource.includes(source),
);
if (sourceMatched) {
score += 0.36;
reasons.push('来源约束');
}
if (categoryMatches(record.businessSegment, relevantCategories)) {
score += 0.18;
reasons.push('业务板块');
}
if (categoryMatches(record.level1Project, relevantCategories)) {
score += 0.12;
reasons.push('一级项目');
}
if (categoryMatches(record.level2Project, relevantCategories)) {
score += 0.12;
reasons.push('二级项目');
}
const matchedKeywords = [];
keywordInfo.keywords.forEach((keyword) => {
if (!keyword) return;
if (record.level2Project.includes(keyword)) score += 0.12;
else if (
record.level1Project.includes(keyword) ||
record.businessSegment.includes(keyword)
)
score += 0.09;
else if (record.riskSource.includes(keyword)) score += 0.16;
else if (record.searchText.includes(keyword)) score += 0.06;
else return;
matchedKeywords.push(keyword);
});
const primaryMatched = keywordInfo.primaryKeywords.filter((keyword) =>
record.searchText.includes(keyword),
);
if (primaryMatched.length) {
score += Math.min(0.62, 0.5 + (primaryMatched.length - 1) * 0.06);
reasons.push(`主题词:${primaryMatched.slice(0, 3).join('、')}`);
} else if (matchedKeywords.length) {
score += Math.min(0.5, 0.3 + (matchedKeywords.length - 1) * 0.05);
reasons.push(`扩展词:${matchedKeywords.slice(0, 3).join('、')}`);
}
const phraseCoverage = ngramCoverage(query, record.searchText);
if (phraseCoverage > 0) {
score += Math.min(0.22, phraseCoverage * 0.22);
}
if (sourceMatched && matchedKeywords.length) score = Math.max(score, 1.08);
const matchScore = Math.max(
0,
Math.min(100, Math.round((Number(score) / 1.3) * 100)),
);
return {
...record,
matchScore,
matchLevel: matchScoreLevel(matchScore),
matchReasons: reasons,
};
}
export function groupAuditRecords(records = []) {
const businessMap = new Map();
const seen = new Set();
records.forEach((record) => {
const identity = auditRecordIdentity(record, true);
if (seen.has(identity)) return;
seen.add(identity);
const business = record.businessSegment || '未分类';
const level1 = record.level1Project || '未分类';
const level2 = record.level2Project || '未分类';
if (!businessMap.has(business)) businessMap.set(business, new Map());
const level1Map = businessMap.get(business);
if (!level1Map.has(level1)) level1Map.set(level1, new Map());
const level2Map = level1Map.get(level1);
if (!level2Map.has(level2)) level2Map.set(level2, []);
level2Map.get(level2).push(record);
});
return Array.from(businessMap, ([businessSegment, level1Map]) => ({
businessSegment,
level1Projects: Array.from(level1Map, ([level1Project, level2Map]) => ({
level1Project,
level2Projects: Array.from(level2Map, ([level2Project, risks]) => ({
level2Project,
risks: risks.sort((a, b) => b.matchScore - a.matchScore),
})),
})),
}));
}
export function classifyAuditIntent(query = '') {
if (GENERATE_PATTERNS.some((pattern) => query.includes(pattern)))
return 'checklist';
if (QA_PATTERNS.some((pattern) => query.includes(pattern))) return 'qa';
return 'checklist';
}
function cleanInstructionValue(value = '') {
return value
.replace(
/相关的|有关的|相关|有关|风险点|检查点|内容|类别|业务|板块|项目/g,
'',
)
.replace(/[,,。;;!!??]/g, ' ')
.replace(/的$/g, '')
.trim();
}
export function parseRefineInstruction(query = '', previousThreshold) {
const instruction = {
keep: [],
remove: [],
add: [],
threshold: previousThreshold || AUDIT_MATCH_THRESHOLD,
};
const thresholdMatch = query.match(
/(?:阈值|最低分(?:值)?|匹配度)[^0-9]{0,8}(\d{1,3})/,
);
if (thresholdMatch) {
instruction.threshold = Math.max(
0,
Math.min(100, Number(thresholdMatch[1])),
);
}
[
{ 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])
.split(/和|与|以及|、|\s+/)
.filter((value) => value.length >= 2)
.forEach((value) => instruction[key].push(value));
}
});
return instruction;
}
function recordMatchesTerms(record, terms) {
return terms.some((term) => term && record.searchText.includes(term));
}
function scoreAllRecords(records, query, extraKeywords = []) {
const keywordInfo = extractAuditKeywords(query, extraKeywords);
const relevantCategories = getRelevantCategories(records, keywordInfo);
return records
.map((record) =>
scoreAuditRecord(record, query, keywordInfo, relevantCategories),
)
.filter(Boolean)
.sort((a, b) => b.matchScore - a.matchScore);
}
export function isAuditRefineRequest(query, previousResult) {
return Boolean(
previousResult &&
REFINE_PATTERNS.some((pattern) => query.includes(pattern)),
);
}
export function buildAuditResult(
query,
records,
previousResult = null,
options = {},
) {
const isRefine = isAuditRefineRequest(query, previousResult);
const baseQuery = isRefine ? previousResult.project : query;
const instruction = isRefine
? parseRefineInstruction(query, previousResult.threshold)
: { keep: [], remove: [], add: [], threshold: AUDIT_MATCH_THRESHOLD };
const scoringQuery = instruction.add.length
? `${baseQuery} ${instruction.add.join(' ')}`
: baseQuery;
let allScored = scoreAllRecords(
records,
scoringQuery,
options.extraKeywords || [],
);
if (instruction.remove.length) {
allScored = allScored.filter(
(record) => !recordMatchesTerms(record, instruction.remove),
);
}
if (instruction.keep.length) {
allScored = allScored.filter((record) =>
recordMatchesTerms(record, instruction.keep),
);
}
const selected = allScored.filter(
(record) => record.matchScore >= instruction.threshold,
);
return {
version: 1,
kind: classifyAuditIntent(isRefine ? baseQuery : query),
project: baseQuery,
request: query,
refined: Boolean(isRefine),
instruction,
expandedKeywords: options.extraKeywords || [],
threshold: instruction.threshold,
totalCount: records.length,
scoredCount: allScored.length,
matchedCount: selected.length,
records: selected,
groups: groupAuditRecords(selected),
generatedAt: new Date().toISOString(),
};
}
export function findPreviousAuditResult(messages = []) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.auditResult) return messages[index].auditResult;
if (messages[index]?.message?.auditResult)
return messages[index].message.auditResult;
}
return null;
}
export function buildAuditKnowledgePrompt(result) {
const records = result.records.slice(0, AUDIT_PROMPT_RECORD_LIMIT);
const context = records
.map(
(record, index) =>
`${buildRiskPointMarkdown(record, index + 1)}\n- 规则匹配度:${
record.matchScore
} 分(${record.matchLevel})\n- 命中依据:${
record.matchReasons.join('、') || '字段词项覆盖'
}`,
)
.join('\n\n');
const stats = `知识库共 ${result.totalCount} 条,全量评分 ${result.scoredCount} 条,匹配度 >= ${result.threshold} 分的有 ${result.matchedCount} 条。`;
if (!records.length) {
return [
'审计风险点知识库检索结果:',
stats,
'没有达到当前阈值的风险点。请明确说明知识库覆盖不足或建议用户降低匹配度阈值,不得编造风险点。',
`用户请求:${result.request}`,
'',
].join('\n');
}
if (result.kind === 'qa') {
return [
'审计风险点知识库问答上下文:',
stats,
AUDIT_CHECKLIST_RULES,
context,
'',
'请简洁回答用户问题,并引用来源行号和匹配度。不要生成完整审计清单。',
'',
].join('\n');
}
return [
'审计检查清单知识库上下文:',
stats,
AUDIT_CHECKLIST_RULES,
context,
'',
'风险点层级和完整详情由系统结构化展示,你只生成以下六个清单要素,不要重复展开风险点:',
'## 审计项目',
'使用业务人员输入的审计项目名称。',
'## 审计对象',
'结合审计项目与命中内容,参考公募基金公司组织架构列示可能涉及的部门。',
'## 审计方法',
'列示可能使用的访谈、穿行测试、抽样、数据分析、凭证核验等审计技术。',
'## 审计人员',
'给出角色配置建议,不编造真实姓名。',
'## 审计时间',
'给出阶段安排,不编造已经发生的日期。',
'## 注意事项',
'提示保密、取证、样本代表性及专业判断等事项。',
result.refined ? `本次调整指令:${result.request}` : '',
'',
]
.filter(Boolean)
.join('\n');
}
import * as XLSX from 'xlsx';
import {
AUDIT_EXCEL_FIELDS,
AUDIT_FIELD_ALIASES,
AUDIT_RISK_POINT_SUBJECT,
} from './constants';
import { auditRecordIdentity, buildRiskPointMarkdown } from './engine';
const HIERARCHY_FIELDS = ['businessSegment', 'level1Project', 'level2Project'];
function normalizeHeader(value) {
return String(value || '')
.replace(/[\s\n\r**::]/g, '')
.toLowerCase();
}
function normalizeCell(value) {
if (value === undefined || value === null) return '';
if (value instanceof Date) return value.toISOString().slice(0, 10);
return String(value).trim();
}
function fieldHeaderAliases(field) {
return [
field.label,
field.alias,
...(AUDIT_FIELD_ALIASES[field.key] || []),
].map(normalizeHeader);
}
function detectHeaderRow(rows) {
let best = { index: -1, score: 0, columns: {} };
rows.slice(0, 30).forEach((row, rowIndex) => {
const columns = {};
row.forEach((cell, columnIndex) => {
const header = normalizeHeader(cell);
AUDIT_EXCEL_FIELDS.forEach((field) => {
if (
columns[field.key] === undefined &&
fieldHeaderAliases(field).includes(header)
) {
columns[field.key] = columnIndex;
}
});
});
const score = Object.keys(columns).length;
if (score > best.score) best = { index: rowIndex, score, columns };
});
return best;
}
function parseKeywords(value, record) {
const explicit = normalizeCell(value)
.split(/[,,、;;\n\s]+/)
.map((item) => item.trim())
.filter((item) => item.length >= 2);
if (explicit.length) return Array.from(new Set(explicit));
return Array.from(
new Set(
[record.businessSegment, record.level1Project, record.level2Project]
.filter(Boolean)
.flatMap((item) => item.split(/[、/()()\s]+/))
.filter((item) => item.length >= 2),
),
);
}
function parseEnabled(value) {
const text = normalizeCell(value).toLowerCase();
if (!text) return true;
return !['false', '0', '否', '停用', '禁用'].includes(text);
}
export async function parseAuditExcel(file) {
const workbook = XLSX.read(await file.arrayBuffer(), {
type: 'array',
cellDates: true,
});
const sheetResults = [];
const allRecords = [];
const errors = [];
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: '',
raw: false,
});
const header = detectHeaderRow(rows);
if (header.score < 3 || header.columns.riskDescription === undefined) {
sheetResults.push({
sheetName,
imported: 0,
skipped: Math.max(0, rows.length - 1),
reason: '未识别到“具体风险点描述”及至少两个业务字段',
});
return;
}
const previousHierarchy = {};
let imported = 0;
let skipped = 0;
rows.slice(header.index + 1).forEach((row, offset) => {
const sourceRow = header.index + offset + 2;
const record = {};
AUDIT_EXCEL_FIELDS.forEach((field) => {
const columnIndex = header.columns[field.key];
record[field.key] =
columnIndex === undefined ? '' : normalizeCell(row[columnIndex]);
});
HIERARCHY_FIELDS.forEach((key) => {
if (record[key]) previousHierarchy[key] = record[key];
else record[key] = previousHierarchy[key] || '';
});
if (!record.riskDescription) {
const hasOtherContent = Object.values(record).some(Boolean);
if (hasOtherContent) {
errors.push({ sheetName, sourceRow, message: '缺少具体风险点描述' });
}
skipped += 1;
return;
}
record.sourceRow = record.sourceRow || sourceRow;
record.enabled = parseEnabled(record.enabled);
record.keywords = parseKeywords(record.keywords, record);
record.sheetName = sheetName;
record.mdContent = record.mdContent || buildRiskPointMarkdown(record);
record.searchText = [
record.businessSegment,
record.level1Project,
record.level2Project,
record.riskDescription,
record.regulationBasis,
record.riskSource,
record.riskLevel,
record.keywords.join(' '),
]
.filter(Boolean)
.join(' ');
allRecords.push(record);
imported += 1;
});
sheetResults.push({ sheetName, imported, skipped });
});
const uniqueRecords = [];
const identities = new Set();
allRecords.forEach((record) => {
const identity = auditRecordIdentity(record, true);
if (!identities.has(identity)) {
identities.add(identity);
uniqueRecords.push(record);
}
});
return {
fileName: file.name,
records: uniqueRecords,
duplicateRows: allRecords.length - uniqueRecords.length,
errors,
sheets: sheetResults,
markdown: uniqueRecords
.map((record, index) => buildRiskPointMarkdown(record, index + 1))
.join('\n\n'),
};
}
export function createAuditRecordModel(record) {
const hierarchy = [
record.businessSegment,
record.level1Project,
record.level2Project,
].filter(Boolean);
return {
subject: AUDIT_RISK_POINT_SUBJECT,
title: hierarchy.join(' / ') || `风险点 ${record.sourceRow || ''}`,
metadata: {
business_segment: record.businessSegment || '',
level1_project: record.level1Project || '',
level2_project: record.level2Project || '',
risk_description: record.riskDescription || '',
regulation_basis: record.regulationBasis || '',
risk_source: record.riskSource || '',
risk_level: record.riskLevel || '',
keywords: Array.isArray(record.keywords)
? record.keywords
: parseKeywords(record.keywords, record),
md_content: record.mdContent || buildRiskPointMarkdown(record),
enabled: record.enabled !== false,
source_row: Number(record.sourceRow) || record.sourceRow || '',
},
};
}
function sameRecordContent(left, right) {
return auditRecordIdentity(left, true) === auditRecordIdentity(right, true);
}
export async function importAuditRecords({
records,
existingRecords,
createRecord,
updateRecord,
onProgress,
}) {
const existingByIdentity = new Map(
existingRecords.map((record) => [auditRecordIdentity(record), record]),
);
const jobs = [];
const result = { created: 0, updated: 0, skipped: 0, failed: 0, errors: [] };
records.forEach((record) => {
const existing = existingByIdentity.get(auditRecordIdentity(record));
if (existing && sameRecordContent(existing, record)) {
result.skipped += 1;
return;
}
jobs.push({ type: existing?.id ? 'update' : 'create', record, existing });
});
let completed = result.skipped;
onProgress?.({ completed, total: records.length, ...result });
let cursor = 0;
const worker = async () => {
while (cursor < jobs.length) {
const job = jobs[cursor];
cursor += 1;
try {
const model = createAuditRecordModel(job.record);
if (job.type === 'update') {
await updateRecord(job.existing.id, {
title: model.title,
metadata: model.metadata,
});
result.updated += 1;
} else {
await createRecord(model);
result.created += 1;
}
} catch (error) {
result.failed += 1;
result.errors.push({
sourceRow: job.record.sourceRow,
message: error?.message || '写入失败',
});
} finally {
completed += 1;
onProgress?.({ completed, total: records.length, ...result });
}
}
};
await Promise.all(
Array.from({ length: Math.min(4, Math.max(1, jobs.length)) }, worker),
);
return result;
}
import JSZip from 'jszip';
import * as XLSX from 'xlsx';
import { groupAuditRecords } from './engine';
const SUMMARY_FIELDS = [
'审计项目',
'审计对象',
'审计方法',
'审计人员',
'审计时间',
'注意事项',
];
const WORD_SECTION_ORDER = [
'审计项目',
'审计对象',
'审计内容',
'审计方法',
'审计人员',
'审计时间',
'注意事项',
];
const WORD_FONT = '宋体';
function escapeXml(value = '') {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function safeFileName(value = '审计检查清单') {
return String(value)
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, '_')
.slice(0, 80);
}
function stripMarkdown(value = '') {
return String(value)
.replace(/```[\s\S]*?```/g, '')
.replace(/<[^>]+>/g, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*|__/g, '')
.replace(/^[-*+]\s+/gm, '')
.replace(/\[(.*?)\]\([^)]*\)/g, '$1')
.trim();
}
export function parseAuditSummary(content = '', project = '') {
const summary = { 审计项目: project };
SUMMARY_FIELDS.forEach((field, index) => {
const nextFields = SUMMARY_FIELDS.slice(index + 1)
.map((item) => `#{1,6}\\s*${item}`)
.join('|');
const pattern = new RegExp(
`#{1,6}\\s*${field}\\s*\\n([\\s\\S]*?)(?=${
nextFields ? `(?:${nextFields})|$` : '$'
})`,
'i',
);
const match = String(content).match(pattern);
if (match?.[1]?.trim()) summary[field] = stripMarkdown(match[1]);
});
summary.审计项目 = summary.审计项目 || project;
summary.审计对象 = summary.审计对象 || '根据审计范围确定相关业务及管理部门';
summary.审计方法 =
summary.审计方法 || '访谈、穿行测试、抽样检查、数据分析及凭证核验';
summary.审计人员 = summary.审计人员 || '由审计负责人按专业分工配置';
summary.审计时间 = summary.审计时间 || '按审计准备、现场实施和报告阶段安排';
summary.注意事项 =
summary.注意事项 || '注意资料保密、证据留痕、样本代表性和专业判断';
return summary;
}
export function filterAuditRecords(result, threshold = result.threshold) {
return (result.records || []).filter(
(record) => Number(record.matchScore) >= Number(threshold),
);
}
export function buildChecklistMarkdown(result, aiContent = '', threshold) {
const summary = parseAuditSummary(aiContent, result.project);
const records = filterAuditRecords(result, threshold);
const groups = groupAuditRecords(records);
const lines = [`# 审计检查清单:${summary.审计项目}`, ''];
SUMMARY_FIELDS.forEach((field) => {
lines.push(`## ${field}`, summary[field] || '', '');
});
lines.push('## 审计内容', '');
if (!groups.length) {
lines.push('当前匹配度阈值下无风险点。');
}
groups.forEach((business, businessIndex) => {
lines.push(`### ${businessIndex + 1}. ${business.businessSegment}`, '');
business.level1Projects.forEach((level1, level1Index) => {
lines.push(
`#### ${businessIndex + 1}.${level1Index + 1} ${level1.level1Project}`,
'',
);
level1.level2Projects.forEach((level2, level2Index) => {
lines.push(
`##### ${businessIndex + 1}.${level1Index + 1}.${level2Index + 1} ${
level2.level2Project
}`,
'',
);
level2.risks.forEach((risk, riskIndex) => {
lines.push(
`**风险点 ${riskIndex + 1}(来源行号:${
risk.sourceRow || '未知'
})**`,
'',
`- 匹配度:${risk.matchScore} 分(${risk.matchLevel})`,
`- 具体风险点描述:${risk.riskDescription || ''}`,
`- 法规依据:${risk.regulationBasis || ''}`,
`- 风险点来源:${risk.riskSource || ''}`,
`- 风险等级:${risk.riskLevel || ''}`,
'',
);
});
});
});
});
return lines.join('\n').trim();
}
function downloadBlob(blob, fileName) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function exportAuditMarkdown(result, aiContent, threshold) {
const markdown = buildChecklistMarkdown(result, aiContent, threshold);
downloadBlob(
new Blob([markdown], { type: 'text/markdown;charset=utf-8' }),
`${safeFileName(result.project)}.md`,
);
}
export function exportAuditExcel(result, aiContent, threshold) {
const summary = parseAuditSummary(aiContent, result.project);
const records = filterAuditRecords(result, threshold);
const workbook = XLSX.utils.book_new();
const summarySheet = XLSX.utils.json_to_sheet(
SUMMARY_FIELDS.map((field) => ({
清单要素: field,
内容: summary[field] || '',
})),
);
summarySheet['!cols'] = [{ wch: 14 }, { wch: 90 }];
XLSX.utils.book_append_sheet(workbook, summarySheet, '审计方案');
const riskSheet = XLSX.utils.json_to_sheet(
records.map((record, index) => ({
序号: index + 1,
业务板块: record.businessSegment,
一级项目: record.level1Project,
二级项目: record.level2Project,
匹配度: record.matchScore,
匹配等级: record.matchLevel,
具体风险点描述: record.riskDescription,
法规依据: record.regulationBasis,
风险点来源: record.riskSource,
风险等级: record.riskLevel,
来源行号: record.sourceRow,
})),
);
riskSheet['!cols'] = [
{ wch: 8 },
{ wch: 18 },
{ wch: 22 },
{ wch: 24 },
{ wch: 10 },
{ wch: 10 },
{ wch: 60 },
{ wch: 50 },
{ wch: 22 },
{ wch: 12 },
{ wch: 10 },
];
XLSX.utils.book_append_sheet(workbook, riskSheet, '风险点明细');
XLSX.writeFile(workbook, `${safeFileName(result.project)}.xlsx`);
}
function wordText(value = '') {
return String(value)
.split('\n')
.map(
(line, index) =>
`${index ? '<w:br/>' : ''}<w:t xml:space="preserve">${escapeXml(
line,
)}</w:t>`,
)
.join('');
}
function wordRun(
text,
{ bold = false, color = '', size = '', font = WORD_FONT } = {},
) {
const properties = [
font
? `<w:rFonts w:ascii="${font}" w:eastAsia="${font}" w:hAnsi="${font}"/>`
: '',
bold ? '<w:b/><w:bCs/>' : '',
color ? `<w:color w:val="${color}"/>` : '',
size ? `<w:sz w:val="${size}"/><w:szCs w:val="${size}"/>` : '',
].join('');
return `<w:r>${properties ? `<w:rPr>${properties}</w:rPr>` : ''}${wordText(
text,
)}</w:r>`;
}
function wordParagraph(
runs = [],
{
style = '',
alignment = '',
firstLine = '',
leftIndent = '',
spacingAfter = '',
} = {},
) {
const properties = [
style ? `<w:pStyle w:val="${style}"/>` : '',
alignment ? `<w:jc w:val="${alignment}"/>` : '',
firstLine ? `<w:ind w:firstLine="${firstLine}"/>` : '',
leftIndent ? `<w:ind w:left="${leftIndent}"/>` : '',
spacingAfter !== '' ? `<w:spacing w:after="${spacingAfter}"/>` : '',
].join('');
return `<w:p>${properties ? `<w:pPr>${properties}</w:pPr>` : ''}${
Array.isArray(runs) ? runs.join('') : runs
}</w:p>`;
}
function formatWordDate(value = new Date()) {
const pad = (number) => String(number).padStart(2, '0');
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(
value.getDate(),
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(
value.getSeconds(),
)}`;
}
function createWordStylesXml() {
const fonts = `<w:rFonts w:ascii="${WORD_FONT}" w:eastAsia="${WORD_FONT}" w:hAnsi="${WORD_FONT}"/>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults>
<w:rPrDefault><w:rPr>${fonts}<w:sz w:val="22"/><w:szCs w:val="22"/><w:lang w:val="zh-CN" w:eastAsia="zh-CN"/></w:rPr></w:rPrDefault>
<w:pPrDefault><w:pPr><w:spacing w:after="200" w:line="276" w:lineRule="auto"/></w:pPr></w:pPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
<w:style w:type="paragraph" w:styleId="Title">
<w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:pBdr><w:bottom w:val="single" w:sz="8" w:space="4" w:color="4F81BD"/></w:pBdr><w:spacing w:after="300" w:line="240" w:lineRule="auto"/><w:contextualSpacing/></w:pPr>
<w:rPr>${fonts}<w:color w:val="17365D"/><w:spacing w:val="5"/><w:kern w:val="28"/><w:sz w:val="52"/><w:szCs w:val="52"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="480" w:after="0"/><w:outlineLvl w:val="0"/></w:pPr>
<w:rPr>${fonts}<w:b/><w:bCs/><w:color w:val="365F91"/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="200" w:after="0"/><w:outlineLvl w:val="1"/></w:pPr>
<w:rPr>${fonts}<w:b/><w:bCs/><w:color w:val="4F81BD"/><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading3">
<w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="200" w:after="0"/><w:outlineLvl w:val="2"/></w:pPr>
<w:rPr>${fonts}<w:b/><w:bCs/><w:color w:val="4F81BD"/><w:sz w:val="22"/><w:szCs w:val="22"/></w:rPr>
</w:style>
</w:styles>`;
}
function createWordDocumentXml(result, aiContent, threshold) {
const summary = parseAuditSummary(aiContent, result.project);
const groups = groupAuditRecords(filterAuditRecords(result, threshold));
const body = [
wordParagraph(wordRun('智能审计清单'), {
style: 'Title',
alignment: 'center',
}),
wordParagraph(
wordRun(`生成时间:${formatWordDate()}`, {
size: '18',
color: '808080',
}),
{ alignment: 'right' },
),
wordParagraph([
wordRun('审计项目描述:', { bold: true }),
wordRun(result.project || summary.审计项目 || ''),
]),
wordParagraph('', { spacingAfter: '0' }),
];
WORD_SECTION_ORDER.forEach((field) => {
body.push(wordParagraph(wordRun(field), { style: 'Heading1' }));
if (field !== '审计内容') {
body.push(
wordParagraph(wordRun(stripMarkdown(summary[field] || '')), {
firstLine: '420',
}),
);
return;
}
if (!groups.length) {
body.push(
wordParagraph(wordRun('(当前匹配度阈值下无风险点)'), {
firstLine: '420',
}),
);
return;
}
groups.forEach((business, businessIndex) => {
body.push(
wordParagraph(
wordRun(
`业务板块 ${businessIndex + 1}:${business.businessSegment}`,
{ size: '24' },
),
{ style: 'Heading2' },
),
);
business.level1Projects.forEach((level1, level1Index) => {
body.push(
wordParagraph(
wordRun(`一级项目 ${level1Index + 1}:${level1.level1Project}`, {
size: '22',
}),
{ style: 'Heading3' },
),
);
level1.level2Projects.forEach((level2, level2Index) => {
body.push(
wordParagraph(
wordRun(`二级项目 ${level2Index + 1}:${level2.level2Project}`, {
bold: true,
size: '24',
}),
{ leftIndent: '227' },
),
);
level2.risks.forEach((risk, riskIndex) => {
const riskLevel = risk.riskLevel || '';
const riskLevelColor = riskLevel.includes('高')
? 'C00000'
: riskLevel.includes('中')
? 'C08000'
: '';
const detailParagraph = (label, value, color = '') =>
wordParagraph(
[
wordRun(`${label}:`, { bold: true, size: '21' }),
wordRun(value || '(无)', { color, size: '21' }),
],
{ leftIndent: '454' },
);
body.push(
wordParagraph(
wordRun(
`风险点 ${riskIndex + 1}(源文件第 ${
risk.sourceRow || '未知'
} 行)`,
{ bold: true, size: '21' },
),
{ leftIndent: '283' },
),
detailParagraph(
'匹配度',
`${risk.matchScore}分(${risk.matchLevel || ''})`,
),
detailParagraph('具体风险点描述', risk.riskDescription),
detailParagraph('法规依据', risk.regulationBasis),
detailParagraph('风险点来源', risk.riskSource),
detailParagraph('风险等级', riskLevel, riskLevelColor),
);
});
});
});
body.push(wordParagraph(''));
});
});
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body.join(
'',
)}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/><w:cols w:space="720"/><w:docGrid w:linePitch="360"/></w:sectPr></w:body></w:document>`;
}
export async function createAuditWordBlob(result, aiContent, threshold) {
const zip = new JSZip();
zip.file(
'[Content_Types].xml',
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/></Types>`,
);
zip
.folder('_rels')
.file(
'.rels',
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`,
);
const word = zip.folder('word');
word.file(
'document.xml',
createWordDocumentXml(result, aiContent, threshold),
);
word.file('styles.xml', createWordStylesXml());
word
.folder('_rels')
.file(
'document.xml.rels',
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`,
);
return zip.generateAsync({
type: 'blob',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
}
export async function exportAuditWord(result, aiContent, threshold) {
downloadBlob(
await createAuditWordBlob(result, aiContent, threshold),
`${safeFileName(result.project)}.docx`,
);
}
......@@ -39,15 +39,29 @@ import { marked } from 'marked';
import marketKatex from 'marked-katex-extension';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import {
AuditChecklistResult,
AuditKnowledgeBar,
AuditKnowledgeImportDialog,
} from '../../features/audit/components';
import {
buildAuditKnowledgePrompt,
buildAuditResult,
fetchAllAuditRiskPoints,
findPreviousAuditResult,
isAuditRefineRequest,
} from '../../features/audit/engine';
import {
callMcpTool,
createConversation,
createRecord,
documentAnalyze,
documentRerank,
extractLlmChunkContent,
......@@ -55,10 +69,12 @@ import {
imageAnalyze,
llmChat,
queryMcps,
queryRecords,
queryTopics,
resolveLlmModel,
semanticSearch,
updateConversation,
updateRecord,
} from '../../services/DataService';
// 导入发票助手组件
......@@ -94,11 +110,10 @@ const AGENT_DEFINITIONS = [
key: 'audit-checklist',
icon: <FileDoneOutlined />,
label: '审计检查清单生成',
description:
'根据当前审计项目、整改问题和责任单位上下文,生成可执行的审计检查清单。',
description: '基于审计风险点知识库,生成可执行的审计检查清单。',
prompt:
'你是审计检查清单生成智能体。你需要把审计项目、整改问题、责任单位、知识库依据拆解为现场可执行检查点,输出包含检查对象、检查资料、核验方法、风险提示和责任跟进建议。',
topicName: 'mock_audit_rectification_query',
'你是审计检查清单生成智能体。你需要把审计主题、风险点知识库依据、法规依据拆解为现场可执行检查点,输出包含检查对象、检查资料、核验方法、风险提示和责任跟进建议。',
topicName: 'audit_checklist_generator',
},
{
key: 'invoice-assistant',
......@@ -112,19 +127,79 @@ const AGENT_DEFINITIONS = [
const DEFAULT_AGENT = AGENT_DEFINITIONS[0];
async function expandAuditKeywordsWithLlm(query, records) {
if (!query || !records.length) return [];
const categories = Array.from(
new Set(
records.flatMap((record) => [
record.businessSegment,
record.level1Project,
record.level2Project,
]),
),
)
.filter(Boolean)
.slice(0, 80)
.join('、');
try {
const response = await llmChat({
model: 'qwen-plus',
stream: false,
enable_thinking: false,
messages: [
{
role: 'system',
content:
'你是基金行业审计专家,只负责生成知识库检索关键词,不输出事实、结论或法规解释。',
},
{
role: 'user',
content: `请针对以下审计主题生成 10-20 个高度相关的专业关键词、同义词、监管常用表述或风险点常见表述。每个关键词 2-8 个字,用逗号分隔,不要编号和解释。\n\n审计主题:${query}\n\n知识库分类参考:${categories}`,
},
],
});
if (!response.ok) return [];
const responseText = await response.text();
let content = responseText;
try {
const data = JSON.parse(responseText);
content =
data?.choices?.[0]?.message?.content ||
data?.choices?.[0]?.delta?.content ||
data?.output?.text ||
'';
} catch (error) {
content = responseText;
}
return Array.from(
new Set(
String(content)
.replace(/^关键词\s*[::]/, '')
.split(/[,,、;;\n\s]+/)
.map((keyword) => keyword.trim())
.filter((keyword) => keyword.length >= 2 && keyword.length <= 12),
),
).slice(0, 20);
} catch (error) {
console.warn('审计关键词扩展失败,使用本地规则继续检索', error);
return [];
}
}
const AUDIT_TOPIC = {
name: 'mock_audit_rectification_query',
title: '审计整改情况查询',
name: 'audit_checklist_generator',
title: '审计检查清单生成',
metadata: {
prompt:
'你是内网审计业务工作台助手,专注审计整改情况查询、问题跟踪、责任单位协同和检查清单生成。',
'你是内网审计业务工作台助手,专注根据审计风险点知识库生成审计检查清单。',
context:
'回答时优先使用已选择的审计对象上下文,说明数据来源于当前内网业务对象;如果上下文不足,指出需要补充的对象或字段。',
'回答时必须优先使用系统提供的审计风险点知识库 Markdown 上下文。风险点描述、法规依据、风险点来源、风险等级不得编造或改写;如果上下文不足,明确说明知识库覆盖不足。',
enable_search: false,
questions: [
'汇总当前项目的整改进度和高风险事项',
'分析该整改问题逾期的主要原因',
'生成责任单位现场复核需要关注的检查点',
'生成采购管理专项审计检查清单',
'生成反洗钱专项审计检查清单',
'查询员工行为管理相关风险点和法规依据',
],
},
};
......@@ -155,7 +230,8 @@ function buildAgentPromptGroup(topics = []) {
return {
...agent,
initialContent: agent.description,
initialContent:
agent.topicName === AUDIT_TOPIC.name ? undefined : agent.description,
topic,
};
}),
......@@ -835,8 +911,13 @@ export default forwardRef((props, ref) => {
const [attachmentsOpen, setAttachmentsOpen] = useState(false);
const [attachedFiles, setAttachedFiles] = useState([]);
const [inputValue, setInputValue] = useState('');
const [auditKnowledgeOpen, setAuditKnowledgeOpen] = useState(false);
const [auditKnowledgeLoading, setAuditKnowledgeLoading] = useState(false);
const [auditRecords, setAuditRecords] = useState([]);
const senderRef = useRef(null);
const abortController = useRef(null);
const auditRecordsRef = useRef([]);
const pendingAuditResultRef = useRef(null);
const auditContext = useMemo(
() => buildAuditContext(DEFAULT_AUDIT_SELECTION),
[],
......@@ -855,6 +936,24 @@ export default forwardRef((props, ref) => {
];
}, [configuredTopics]);
const loadAuditRecords = useCallback(async (force = false) => {
if (!force && auditRecordsRef.current.length) {
return auditRecordsRef.current;
}
setAuditKnowledgeLoading(true);
try {
const records = await fetchAllAuditRiskPoints(queryRecords);
auditRecordsRef.current = records;
setAuditRecords(records);
return records;
} catch (error) {
message.error(`审计风险点知识库读取失败:${error.message || '未知错误'}`);
throw error;
} finally {
setAuditKnowledgeLoading(false);
}
}, []);
useImperativeHandle(ref, () => {
return {
submit: async (message) => {
......@@ -885,6 +984,10 @@ export default forwardRef((props, ref) => {
if (chunk.data.includes('DONE')) {
return {
...originMessage,
auditResult:
originMessage?.auditResult ||
pendingAuditResultRef.current ||
undefined,
tool_calls: undefined,
};
}
......@@ -934,6 +1037,10 @@ export default forwardRef((props, ref) => {
think: `${originThink}${currentThink}`,
content: `${originContent}${currentContent}`,
role: 'assistant',
auditResult:
originMessage?.auditResult ||
pendingAuditResultRef.current ||
undefined,
tool_calls: toolCalls,
tools: toolCalls,
};
......@@ -980,7 +1087,7 @@ export default forwardRef((props, ref) => {
const topic = conversationTopic;
const agent = conversationAgent;
const auditContextText = formatAuditContext(
conversation.metadata.auditContext || [],
conversation?.metadata?.auditContext || [],
);
const auditPrompt = auditContextText
? `\n\n当前审计对象上下文:\n${auditContextText}`
......@@ -997,7 +1104,7 @@ export default forwardRef((props, ref) => {
}${agentPrompt}${auditPrompt}`;
};
const getMessagePrompt = async (message) => {
const getMessagePrompt = async (message, historyMessages = []) => {
const topic = conversationTopic;
const files = message.files || [];
......@@ -1009,9 +1116,12 @@ export default forwardRef((props, ref) => {
let searchPrompt = '';
let filePrompt = '';
let kbPrompt = '';
const auditContextText =
message.settings?.auditContextText ||
formatAuditContext(conversation.metadata.auditContext || []);
let auditKnowledgePrompt = '';
const auditContextText = formatAuditContext(
message.settings?.auditContext ||
conversation?.metadata?.auditContext ||
[],
);
const auditPrompt = auditContextText
? `当前审计对象上下文:\n${auditContextText}\n\n`
: '';
......@@ -1159,7 +1269,32 @@ export default forwardRef((props, ref) => {
.join('、')})和用户上传文件回答;如果依据不足,请说明缺口。\n\n`;
}
return `${auditPrompt}${searchPrompt}${filePrompt}${kbPrompt}用户提问:${message.content}`;
if (topic?.name === AUDIT_TOPIC.name) {
const records = await loadAuditRecords();
const previousResult = findPreviousAuditResult(historyMessages);
const expansionQuery = isAuditRefineRequest(
message.content,
previousResult,
)
? `${previousResult.project} ${message.content}`
: message.content;
const expandedKeywords = await expandAuditKeywordsWithLlm(
expansionQuery,
records,
);
const result = buildAuditResult(
message.content,
records,
previousResult,
{
extraKeywords: expandedKeywords,
},
);
pendingAuditResultRef.current = result;
auditKnowledgePrompt = buildAuditKnowledgePrompt(result);
}
return `${auditPrompt}${auditKnowledgePrompt}${searchPrompt}${filePrompt}${kbPrompt}用户提问:${message.content}`;
};
const [agent] = useXAgent({
......@@ -1171,23 +1306,31 @@ export default forwardRef((props, ref) => {
try {
const controller = new AbortController();
onStream(controller);
pendingAuditResultRef.current = null;
onUpdate({
data: 'LOADING',
});
const prompt = await getMessagePrompt(message);
const tools = await getTools();
const prompt = await getMessagePrompt(message, messages);
const tools = conversationTopic ? [] : await getTools();
const sanitizeLlmMessage = ({
auditResult,
files,
loading,
settings,
...llmMessage
}) => llmMessage;
const requestMessages = [
{
role: 'system',
content: await getSystemPrompt(),
},
...messages.slice(0, -1),
{
...messages.slice(0, -1).map(sanitizeLlmMessage),
sanitizeLlmMessage({
...message,
content: prompt,
},
}),
];
const content = JSON.stringify(requestMessages);
......@@ -1564,6 +1707,12 @@ export default forwardRef((props, ref) => {
__html: marked.parse(preprocessLaTeX(item.message.content)),
}}
></div>
{item.message.auditResult && (
<AuditChecklistResult
result={item.message.auditResult}
aiContent={item.message.content}
/>
)}
</Flex>
);
};
......@@ -1618,6 +1767,12 @@ export default forwardRef((props, ref) => {
senderRef.current?.focus();
}, [conversation]);
useEffect(() => {
if (!conversation && userInfo) {
loadAuditRecords().catch(() => {});
}
}, [conversation, loadAuditRecords, userInfo]);
useEffect(() => {
if (conversation) {
const oldMessage = _.last(conversation.metadata.messages);
......@@ -1649,6 +1804,7 @@ export default forwardRef((props, ref) => {
files = null,
settings = null,
agent = null,
agentLabel = null,
knowledgeBases = null,
}) => {
const selectedAgent = serializeAgent(agent || DEFAULT_AGENT);
......@@ -1675,12 +1831,14 @@ export default forwardRef((props, ref) => {
if (!conversation) {
const conversationData = {
label: agent
label:
agentLabel ||
(agent
? selectedAgent?.label
: content ||
selectedTopic?.title ||
selectedAgent?.label ||
AUDIT_TOPIC.title,
AUDIT_TOPIC.title),
agentConfig: selectedAgent || serializeAgent(DEFAULT_AGENT),
knowledgeBases: serializedKnowledgeBases || [],
auditContext: nextSettings.auditContext,
......@@ -1813,6 +1971,12 @@ export default forwardRef((props, ref) => {
</div>
) : (
<div className={`${styles.chatListInner} ${styles.placeholder}`}>
<AuditKnowledgeBar
count={auditRecords.length}
loading={auditKnowledgeLoading}
onOpen={() => setAuditKnowledgeOpen(true)}
onRefresh={() => loadAuditRecords(true)}
/>
<div className={styles.promptRow}>
{promptGroups.map((group) => (
<Prompts
......@@ -1941,6 +2105,14 @@ export default forwardRef((props, ref) => {
<div className={styles.chat}>
{chatList}
{chatSender}
<AuditKnowledgeImportDialog
open={auditKnowledgeOpen}
existingRecords={auditRecords}
createRecordFn={createRecord}
updateRecordFn={updateRecord}
onCancel={() => setAuditKnowledgeOpen(false)}
onImported={() => loadAuditRecords(true)}
/>
</div>
);
});
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