Merge branch 'Wren-dev' into shiy-login

This commit is contained in:
2026-04-10 16:46:32 +08:00
19 changed files with 30217 additions and 302 deletions
+9 -1
View File
@@ -75,6 +75,7 @@ interface EvaluationPointGroup {
interface ReviewPointResult {
id: string | number;
title: string;
pointCode?: string;
groupName: string;
status: string;
content: string;
@@ -459,6 +460,7 @@ export async function getReviewPoints(fileId: string, request: Request) {
editAuditStatusMessage: editAuditStatus.message,
title: message,
pointName: point.name || '',
pointCode: String(point.code || ''),
groupName: group.name || '',
status: point.suggestion_message_type || '', //评查点的评查结果状态
@@ -784,7 +786,13 @@ export async function getReviewPoints(fileId: string, request: Request) {
};
// console.log("reviewInfo-------",JSON.stringify(reviewInfo,null,2));
// data->reviewPoints stats->statistics reviewInfo->reviewInfo document->document scoring_proposals->scoringProposalsData
return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument, scoring_proposals: scoringProposalsData };
// 构建 pointId -> code 映射(供 route loader 补充 pointCode,绕过 Vite tree-shake
const pointCodeMap: Record<string, string> = {};
evaluationPointsData.forEach(point => {
if (point.code) pointCodeMap[String(point.id)] = String(point.code);
});
return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument, scoring_proposals: scoringProposalsData, pointCodeMap };
}
/**
@@ -93,32 +93,6 @@ export interface CharPosition {
score: number; // OCR识别置信度
}
/**
* text_bbox -> CharPosition[] 转换
* GraphRAG 抽取结果只有 text_bbox (段落级坐标), 没有 char_positions (字符级坐标)。
* 将 text_bbox 转为单个 CharPosition 矩形框, 让 PdfPreview 的高亮逻辑复用。
*/
function resolveCharPositions(data: any): CharPosition[] | undefined {
// 优先用 char_positions
if (data?.char_positions && data.char_positions.length > 0) {
return data.char_positions;
}
// fallback: text_bbox -> CharPosition[]
if (data?.text_bbox) {
const b = data.text_bbox;
if (b.x_min != null && b.y_min != null && b.x_max != null && b.y_max != null
&& (b.x_max - b.x_min) > 0 && (b.y_max - b.y_min) > 0) {
return [{
box: [[b.x_min, b.y_min], [b.x_max, b.y_min], [b.x_max, b.y_max], [b.x_min, b.y_max]],
char: '',
score: 1
}];
}
}
return undefined;
}
/**
* 评查点类型定义
* 用于展示单个评查结果
@@ -1538,7 +1512,7 @@ export function ReviewPointsList({
for (const item of chain) {
if (item.data.page && typeof onReviewPointSelect === 'function') {
hasPage = true;
onReviewPointSelect(reviewPoint.id, Number(item.data.page), resolveCharPositions(item.data), item.data.value);
onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions, item.data.value);
break;
}
}
@@ -1552,7 +1526,7 @@ export function ReviewPointsList({
// 遍历chain找到第一个有效的page
for (const item of chain) {
if (item.data.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(item.data.page), resolveCharPositions(item.data), item.data.value);
onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions, item.data.value);
break;
}
}
@@ -1592,11 +1566,11 @@ export function ReviewPointsList({
// 假设onReviewPointSelect在作用域内可用
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, Number(item.data.page), resolveCharPositions(item.data), item.data.value);
onReviewPointSelect(reviewPointId, Number(item.data.page), item.data.char_positions, item.data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[item.field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), resolveCharPositions(item.data), item.data.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), item.data.char_positions, item.data.value);
}
else{
toastService.error(`没有找到${item.field}对应的索引内容`);
@@ -1675,11 +1649,11 @@ export function ReviewPointsList({
if (chain[0].data.page) {
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, chain[0].data.page, resolveCharPositions(chain[0].data), chain[0].data.value);
onReviewPointSelect(reviewPointId, chain[0].data.page, chain[0].data.char_positions, chain[0].data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), resolveCharPositions(chain[0].data), chain[0].data.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), chain[0].data.char_positions, chain[0].data.value);
}
else{
toastService.error(`没有找到${chain[0].field}对应的索引内容`);
@@ -1701,11 +1675,11 @@ export function ReviewPointsList({
if (chain[1].data.page) {
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, chain[1].data.page, resolveCharPositions(chain[1].data), chain[1].data.value);
onReviewPointSelect(reviewPointId, chain[1].data.page, chain[1].data.char_positions, chain[1].data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), resolveCharPositions(chain[1].data), chain[1].data.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), chain[1].data.char_positions, chain[1].data.value);
}
else{
toastService.error(`没有找到${chain[1].field}对应的索引内容`);
@@ -1841,9 +1815,9 @@ export function ReviewPointsList({
onClick={(e) => {
e.stopPropagation();
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), resolveCharPositions(mainTypeValue), mainTypeValue.value);
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), resolveCharPositions(mainTypeValue), mainTypeValue.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), mainTypeValue.char_positions, mainTypeValue.value);
}else{
toastService.error(`没有找到${fieldKey}对应的索引内容`);
}
@@ -1852,9 +1826,9 @@ export function ReviewPointsList({
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), resolveCharPositions(mainTypeValue), mainTypeValue.value);
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), resolveCharPositions(mainTypeValue), mainTypeValue.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), mainTypeValue.char_positions, mainTypeValue.value);
}else{
toastService.error(`没有找到${fieldKey}对应的索引内容`);
}
@@ -1922,7 +1896,6 @@ export function ReviewPointsList({
fields?: Record<string, {
page: number | string;
value: string;
res: boolean;
char_positions?: CharPosition[];
}>;
ai_suggestion?: {
@@ -1981,14 +1954,14 @@ export function ReviewPointsList({
<button
key={`field-${index}`}
className={`border border-gray w-full
rounded-md overflow-hidden mb-2 ${value.res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex
hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${value.res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex
hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
onClick={(e) => {
e.stopPropagation();
if (value.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(value.page), resolveCharPositions(value), value.value);
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions, value.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), resolveCharPositions(value), value.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), value.char_positions, value.value);
}else{
toastService.error(`没有找到${key}对应的索引内容`);
}
@@ -1998,9 +1971,9 @@ export function ReviewPointsList({
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (value.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(value.page), resolveCharPositions(value), value.value);
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions, value.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), resolveCharPositions(value), value.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), value.char_positions, value.value);
}else{
toastService.error(`没有找到${key}对应的索引内容`);
}
@@ -2031,7 +2004,7 @@ export function ReviewPointsList({
)}
</div>
<div className={`w-8 flex items-center justify-center rounded-r-md group relative`}>
{value.res ? (
{res ? (
<i className="ri-check-line text-success text-base hover:text-green-800" ></i>
) : (
<i className="ri-alert-line text-warning text-base hover:text-yellow-800" ></i>
@@ -2048,7 +2021,7 @@ export function ReviewPointsList({
<div className={`rounded-md flex flex-row items-center`}>
<div className="text-xs text-gray-600 pl-1 whitespace-nowrap">:</div>
<div className={`p-1 text-xs rounded-full min-w-[50px] text-center`}>
{ value.res ? '通过' : '不通过'}
{res ? '通过' : '不通过'}
</div>
</div>
</div>
@@ -2717,10 +2690,7 @@ export function ReviewPointsList({
{/* <div className='flex flex-col'> */}
<div className="flex items-center gap-2 max-w-[75%]">
<div className="review-point-title text-left text-blue-500 break-all">{reviewPoint.pointName}</div>
{ reviewPoint.pointName === '签署乙方详细信息校验' && (() => {
const firstContentKey = reviewPoint.content ? Object.keys(reviewPoint.content)[0] : undefined;
const firstContentValue = firstContentKey ? reviewPoint.content![firstContentKey]?.value : undefined;
return (
{ reviewPoint.pointName === '签署乙方详细信息校验' && (
<button
className="enterprise-info-btn"
style={{
@@ -2733,25 +2703,27 @@ export function ReviewPointsList({
flexShrink: 0,
transition: 'all 0.2s',
border: 'none',
cursor: firstContentValue ? 'pointer' : 'not-allowed',
backgroundColor: firstContentValue ? '#00684a' : '#e5e7eb',
color: firstContentValue ? '#ffffff' : '#9ca3af',
cursor: reviewPoint.content?.['合同主体信息-乙方名称']?.value ? 'pointer' : 'not-allowed',
backgroundColor: reviewPoint.content?.['合同主体信息-乙方名称']?.value ? '#00684a' : '#e5e7eb',
color: reviewPoint.content?.['合同主体信息-乙方名称']?.value ? '#ffffff' : '#9ca3af',
}}
disabled={!firstContentValue}
disabled={!reviewPoint.content?.['合同主体信息-乙方名称']?.value}
onClick={(e) => {
e.stopPropagation();
const companyName = typeof firstContentValue === 'string' ? firstContentValue : String(firstContentValue || '');
const companyNameValue = reviewPoint.content?.['合同主体信息-乙方名称']?.value;
// console.log('companyNameValue', companyNameValue);
const companyName = typeof companyNameValue === 'string' ? companyNameValue : String(companyNameValue || '');
if (companyName) {
handleCorporateInfoClick(companyName);
}
}}
onMouseEnter={(e) => {
if (firstContentValue) {
if (reviewPoint.content?.['合同主体信息-乙方名称']?.value) {
e.currentTarget.style.backgroundColor = '#005a3f';
}
}}
onMouseLeave={(e) => {
if (firstContentValue) {
if (reviewPoint.content?.['合同主体信息-乙方名称']?.value) {
e.currentTarget.style.backgroundColor = '#00684a';
}
}}
@@ -2759,12 +2731,8 @@ export function ReviewPointsList({
<i className="ri-eye-line"></i>
</button>
);
})()}
{ reviewPoint.pointName === '签署甲方详细信息校验' && (() => {
const firstContentKey = reviewPoint.content ? Object.keys(reviewPoint.content)[0] : undefined;
const firstContentValue = firstContentKey ? reviewPoint.content![firstContentKey]?.value : undefined;
return (
)}
{ reviewPoint.pointName === '签署甲方详细信息校验' && (
<button
className="enterprise-info-btn"
style={{
@@ -2777,25 +2745,26 @@ export function ReviewPointsList({
flexShrink: 0,
transition: 'all 0.2s',
border: 'none',
cursor: firstContentValue ? 'pointer' : 'not-allowed',
backgroundColor: firstContentValue ? '#00684a' : '#e5e7eb',
color: firstContentValue ? '#ffffff' : '#9ca3af',
cursor: reviewPoint.content?.['合同主体信息-甲方名称']?.value ? 'pointer' : 'not-allowed',
backgroundColor: reviewPoint.content?.['合同主体信息-甲方名称']?.value ? '#00684a' : '#e5e7eb',
color: reviewPoint.content?.['合同主体信息-甲方名称']?.value ? '#ffffff' : '#9ca3af',
}}
disabled={!firstContentValue}
disabled={!reviewPoint.content?.['合同主体信息-甲方名称']?.value}
onClick={(e) => {
e.stopPropagation();
const companyName = typeof firstContentValue === 'string' ? firstContentValue : String(firstContentValue || '');
const companyNameValue = reviewPoint.content?.['合同主体信息-甲方名称']?.value;
const companyName = typeof companyNameValue === 'string' ? companyNameValue : String(companyNameValue || '');
if (companyName) {
handleCorporateInfoClick(companyName);
}
}}
onMouseEnter={(e) => {
if (firstContentValue) {
if (reviewPoint.content?.['合同主体信息-甲方名称']?.value) {
e.currentTarget.style.backgroundColor = '#005a3f';
}
}}
onMouseLeave={(e) => {
if (firstContentValue) {
if (reviewPoint.content?.['合同主体信息-甲方名称']?.value) {
e.currentTarget.style.backgroundColor = '#00684a';
}
}}
@@ -2803,8 +2772,7 @@ export function ReviewPointsList({
<i className="ri-eye-line"></i>
</button>
);
})()}
)}
</div>
{/* <div className="review-point-header flex justify-between items-start">
<div className="flex-1 text-left min-w-[25%] font-medium text-[13px]">{reviewPoint.title}</div>
+1 -3
View File
@@ -53,7 +53,6 @@ interface FilePreviewProps {
activeReviewPointResultId: string | null;
targetPage?: number; // 新增目标页码参数
charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息(仅用于PDF
textBbox?: { x_min: number; y_min: number; x_max: number; y_max: number }; // GraphRAG段落级坐标
highlightValue?: string; // 高亮文本值(用于DOCX
isStructuredView?: boolean; // 是否显示结构化视图
userInfo?: {
@@ -75,7 +74,7 @@ export interface FilePreviewHandle {
}
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(function FilePreview({ fileContent, activeReviewPointResultId, targetPage, charPositions, textBbox, highlightValue, isStructuredView = false, userInfo, aiSuggestionReplace, isTemplate = false }, ref) {
export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(function FilePreview({ fileContent, activeReviewPointResultId, targetPage, charPositions, highlightValue, isStructuredView = false, userInfo, aiSuggestionReplace, isTemplate = false }, ref) {
// 获取文件类型
const real_path = fileContent.path || fileContent.template_contract_path || '';
const fileExtension = real_path.split('.').pop()?.toLowerCase();
@@ -237,7 +236,6 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
filePath={real_path}
targetPage={targetPage}
charPositions={charPositions}
textBbox={textBbox}
isStructuredView={isStructuredView}
activeReviewPointResultId={activeReviewPointResultId}
pageOffset={pageOffset}
+237 -95
View File
@@ -82,32 +82,6 @@ export interface CharPosition {
score: number; // OCR识别置信度
}
/**
* text_bbox -> CharPosition[] 转换
* GraphRAG 抽取结果只有 text_bbox (段落级坐标), 没有 char_positions (字符级坐标)。
* 将 text_bbox 转为单个 CharPosition 矩形框, 让 PdfPreview 的高亮逻辑复用。
*/
function resolveCharPositions(data: any): CharPosition[] | undefined {
// 优先用 char_positions
if (data?.char_positions && data.char_positions.length > 0) {
return data.char_positions;
}
// fallback: text_bbox -> CharPosition[]
if (data?.text_bbox) {
const b = data.text_bbox;
if (b.x_min != null && b.y_min != null && b.x_max != null && b.y_max != null
&& (b.x_max - b.x_min) > 0 && (b.y_max - b.y_min) > 0) {
return [{
box: [[b.x_min, b.y_min], [b.x_max, b.y_min], [b.x_max, b.y_max], [b.x_min, b.y_max]],
char: '',
score: 1
}];
}
}
return undefined;
}
/**
* 评查点类型定义
* 用于展示单个评查结果
@@ -120,6 +94,7 @@ export interface ReviewPoint {
editAuditStatus: number;
editAuditStatusMessage?: string; // 添加审核意见字段
pointName: string;
pointCode?: string;
title: string;
groupName: string;
status: string;
@@ -367,9 +342,14 @@ const ReactTableTooltip = ({ content }: { content: string }) => {
const [showTooltip, setShowTooltip] = useState(false);
const [renderedContent, setRenderedContent] = useState<React.ReactNode>(null);
const textRef = useRef<HTMLDivElement>(null);
const isTableLike = content.includes('\t') && content.includes('\n');
const isTabTable = content.includes('\t') && content.includes('\n');
// 检测 markdown 表格:有 |---| 分隔行,或 pipe 分隔 + 换行(无表头的 pipe 表格)
const isMdTable = content.includes('|') && /\|[-\s:]+\|/.test(content);
const isPipeTable = !isMdTable && content.includes('|') && content.includes('\n')
&& content.split('\n').filter(l => l.includes('|')).length >= 2;
const isTableLike = isTabTable || isMdTable || isPipeTable;
useEffect(() => {
const checkTextOverflow = () => {
const element = textRef.current;
@@ -378,33 +358,153 @@ const ReactTableTooltip = ({ content }: { content: string }) => {
setShowTooltip(isTableLike || element.scrollHeight > element.clientHeight);
}
};
// 预渲染内容并缓存
if (isTableLike) {
if (isMdTable) {
setRenderedContent(renderMarkdownTable(content));
} else if (isPipeTable) {
setRenderedContent(renderPipeTable(content));
} else if (isTabTable) {
setRenderedContent(renderReactTable(content));
} else {
setRenderedContent(content);
}
requestAnimationFrame(checkTextOverflow);
window.addEventListener('resize', checkTextOverflow);
return () => {
window.removeEventListener('resize', checkTextOverflow);
};
}, [content, isTableLike]);
// 解析表格数据
// 解析表格数据tab分隔)
const parseTableData = (text: string) => {
const rows = text.split('\n').map(row => row.split('\t'));
return rows;
};
// 渲染React表格
// 解析 markdown 表格(支持多行和单行格式)
const parseMarkdownTable = (text: string): string[][] => {
// 先尝试按换行分割
const lines = text.split('\n').filter(l => l.trim());
// 多行格式:有多行且包含分隔行
if (lines.length > 2) {
const rows: string[][] = [];
for (const line of lines) {
if (/^\s*\|[-\s:]+\|/.test(line)) continue; // 跳过分隔行
const cells = line.split('|').map(c => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length);
if (cells.length > 0) rows.push(cells);
}
if (rows.length > 1) return rows;
}
// 单行格式:整个表格在一行内,通过分隔行 |---|---| 来拆分
const sepMatch = text.match(/\|[\s-:]+(?:\|[\s-:]+)+\|/);
if (!sepMatch) return [];
const colCount = (sepMatch[0].match(/---/g) || []).length;
if (colCount === 0) return [];
const sepIdx = text.indexOf(sepMatch[0]);
const headerPart = text.substring(0, sepIdx);
const bodyPart = text.substring(sepIdx + sepMatch[0].length);
// 解析 header
const headerCells = headerPart.split('|').map(c => c.trim()).filter(c => c);
// 解析 body:按 | 分割后每 colCount 个cell为一行
const bodyCells = bodyPart.split('|').map(c => c.trim()).filter(c => c);
const rows: string[][] = [headerCells];
for (let i = 0; i < bodyCells.length; i += colCount) {
const row = bodyCells.slice(i, i + colCount);
if (row.length > 0) rows.push(row);
}
return rows;
};
// 渲染 markdown 表格
const renderMarkdownTable = (text: string) => {
try {
const tableData = parseMarkdownTable(text);
if (tableData.length === 0) return content;
return (
<div className="overflow-x-auto">
<table className="min-w-full border-collapse border border-gray-300">
<thead>
<tr>
{tableData[0].map((cell, cellIndex) => (
<th
key={`header-${cellIndex}`}
className="px-2 py-1 border border-gray-300 bg-gray-100 font-medium text-xs text-left whitespace-nowrap"
>
{cell || ' '}
</th>
))}
</tr>
</thead>
<tbody>
{tableData.slice(1).map((row, rowIndex) => (
<tr key={`row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td
key={`cell-${rowIndex}-${cellIndex}`}
className="px-2 py-1 border border-gray-300 text-xs text-left"
>
{cell || ' '}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
} catch (error) {
console.error('Markdown表格渲染错误:', error);
return <div>{content}</div>;
}
};
// 渲染 pipe 分隔表格(无表头,如 "1 | 名称 | 项 | 1\n2 | ..."
const renderPipeTable = (text: string) => {
try {
const rows = text.split('\n')
.filter(l => l.trim() && l.includes('|'))
.map(line => line.split('|').map(c => c.trim()));
if (rows.length === 0) return <div>{content}</div>;
return (
<div className="overflow-x-auto">
<table className="min-w-full border-collapse border border-gray-300">
<tbody>
{rows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td
key={`cell-${rowIndex}-${cellIndex}`}
className="px-2 py-1 border border-gray-300 text-xs text-left"
>
{cell || ' '}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
} catch (error) {
console.error('Pipe表格渲染错误:', error);
return <div>{content}</div>;
}
};
// 渲染React表格(tab分隔)
const renderReactTable = (text: string) => {
try {
const tableData = parseTableData(text);
const hasHeader = tableData.length > 0;
return (
<div>
<table className="min-w-full border-collapse border border-gray-300">
@@ -412,7 +512,7 @@ const ReactTableTooltip = ({ content }: { content: string }) => {
<thead>
<tr>
{tableData[0].map((cell, cellIndex) => (
<th
<th
key={`header-${cellIndex}`}
className="px-2 py-1 border border-gray-300 bg-gray-100 font-medium text-xs text-left"
>
@@ -426,7 +526,7 @@ const ReactTableTooltip = ({ content }: { content: string }) => {
{tableData.slice(1).map((row, rowIndex) => (
<tr key={`row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td
<td
key={`cell-${rowIndex}-${cellIndex}`}
className="px-2 py-1 border border-gray-300 text-xs text-left"
>
@@ -447,10 +547,54 @@ const ReactTableTooltip = ({ content }: { content: string }) => {
// 表格内容:主区域显示摘要,hover 悬浮显示完整表格
if (isTableLike) {
// 生成摘要文本:取第一行数据作为预览
const summaryText = (() => {
const firstLine = content.split('\n').find(l => l.trim() && !/^\s*\|[-\s:]+\|/.test(l));
return firstLine ? firstLine.trim().substring(0, 80) + (firstLine.length > 80 ? '...' : '') : content.substring(0, 80);
})();
// 根据列数动态计算宽度:每列约 120px,最小 400,最大 90vw
const colCount = (() => {
if (isMdTable) {
const sepMatch = content.match(/\|[\s-:]+(?:\|[\s-:]+)+\|/);
return sepMatch ? (sepMatch[0].match(/---/g) || []).length : 4;
}
const firstDataLine = content.split('\n').find(l => l.includes('|'));
return firstDataLine ? firstDataLine.split('|').filter(c => c.trim()).length : 4;
})();
const tableMaxWidth = Math.min(Math.max(colCount * 140, 400), window.innerWidth * 0.85);
// 高度不限制,让表格内容完整显示,最大 80vh
const tableMaxHeight = window.innerHeight * 0.8;
return (
<div className="text-xs p-1 rounded cursor-text w-full text-left">
<Tooltip
content={renderedContent}
placement="top"
theme="light"
trigger="hover"
showArrow={true}
className="tooltip-custom-offset"
scrollable={true}
maxWidth={tableMaxWidth}
maxHeight={tableMaxHeight}
>
<div className="text-gray-800 break-all overflow-hidden line-clamp-2 flex items-center gap-1">
<i className="ri-table-line text-blue-400 flex-shrink-0"></i>
<span>{summaryText}</span>
</div>
</Tooltip>
</div>
);
}
return (
<div className="text-xs p-1 rounded cursor-text w-full text-left">
{showTooltip ? (
<Tooltip
<Tooltip
content={renderedContent}
placement="top"
theme="light"
@@ -641,6 +785,7 @@ export function ReviewPointsList({
// 匹配搜索文本
const matchesSearch = searchText === '' ||
point.pointName.toLowerCase().includes(searchText.toLowerCase()) ||
(point.pointCode && point.pointCode.toLowerCase().includes(searchText.toLowerCase())) ||
point.title.toLowerCase().includes(searchText.toLowerCase()) ||
// point.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
JSON.stringify(point.content).toLowerCase().includes(searchText.toLowerCase())
@@ -808,7 +953,7 @@ export function ReviewPointsList({
type="text"
className="w-full border border-gray-200 rounded-md pl-8 pr-2 py-1 text-xs h-7
focus:outline-none focus:ring-1 focus:ring-green-800"
placeholder="搜索评查点..."
placeholder="搜索评查点名称或编码..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
@@ -1288,7 +1433,7 @@ export function ReviewPointsList({
for (const item of chain) {
if (item.data.page && typeof onReviewPointSelect === 'function') {
hasPage = true;
onReviewPointSelect(reviewPoint.id, Number(item.data.page), resolveCharPositions(item.data));
onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions);
break;
}
}
@@ -1302,7 +1447,7 @@ export function ReviewPointsList({
// 遍历chain找到第一个有效的page
for (const item of chain) {
if (item.data.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(item.data.page), resolveCharPositions(item.data));
onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions);
break;
}
}
@@ -1338,15 +1483,15 @@ export function ReviewPointsList({
onClick={(e) => {
e.stopPropagation();
if (item.data.page) {
console.log('点击了长链条评查点', resolveCharPositions(item.data), item.data);
console.log('点击了长链条评查点', item.data.char_positions, item.data);
// 假设onReviewPointSelect在作用域内可用
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, Number(item.data.page), resolveCharPositions(item.data), item.data.value);
onReviewPointSelect(reviewPointId, Number(item.data.page), item.data.char_positions, item.data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[item.field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), resolveCharPositions(item.data), item.data.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), item.data.char_positions, item.data.value);
}
else{
toastService.error(`没有找到${item.field}对应的索引内容`);
@@ -1422,16 +1567,16 @@ export function ReviewPointsList({
${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'} transition-colors flex flex-col`}
onClick={(e) => {
e.stopPropagation();
console.log('点击了短链1左', resolveCharPositions(chain[0].data), chain[0].data)
console.log('点击了短链1左', chain[0].data.char_positions, chain[0].data)
if (chain[0].data.page) {
// console.log('点击了短链1左', resolveCharPositions(chain[0].data), chain[0].data)
// console.log('点击了短链1左', chain[0].data.char_positions, chain[0].data)
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, chain[0].data.page, resolveCharPositions(chain[0].data), chain[0].data.value);
onReviewPointSelect(reviewPointId, chain[0].data.page, chain[0].data.char_positions, chain[0].data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), resolveCharPositions(chain[0].data),chain[0].data.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), chain[0].data.char_positions,chain[0].data.value);
}
else{
toastService.error(`没有找到${chain[0].field}对应的索引内容`);
@@ -1451,14 +1596,14 @@ export function ReviewPointsList({
onClick={(e) => {
e.stopPropagation();
if (chain[1].data.page) {
console.log('点击了短链2右', resolveCharPositions(chain[1].data), chain[1].data)
console.log('点击了短链2右', chain[1].data.char_positions, chain[1].data)
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, chain[1].data.page, resolveCharPositions(chain[1].data), chain[1].data.value);
onReviewPointSelect(reviewPointId, chain[1].data.page, chain[1].data.char_positions, chain[1].data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), resolveCharPositions(chain[1].data), chain[1].data.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), chain[1].data.char_positions, chain[1].data.value);
}
else{
toastService.error(`没有找到${chain[1].field}对应的索引内容`);
@@ -1595,10 +1740,10 @@ export function ReviewPointsList({
e.stopPropagation();
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
console.log("点击了其他评查点", mainTypeValue)
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), resolveCharPositions(mainTypeValue), mainTypeValue.value);
// onReviewPointSelect(reviewPoint.id, undefined, resolveCharPositions(mainTypeValue), mainTypeValue.value);
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value);
// onReviewPointSelect(reviewPoint.id, undefined, mainTypeValue.char_positions, mainTypeValue.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), resolveCharPositions(mainTypeValue), mainTypeValue.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), mainTypeValue.char_positions, mainTypeValue.value);
}else{
toastService.error(`没有找到${fieldKey}对应的索引内容`);
}
@@ -1607,7 +1752,7 @@ export function ReviewPointsList({
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), resolveCharPositions(mainTypeValue), mainTypeValue.value);
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value);
}else{
toastService.error(`没有找到${fieldKey}对应的索引内容`);
}
@@ -1675,7 +1820,6 @@ export function ReviewPointsList({
fields?: Record<string, {
page: number | string;
value: string;
res: boolean;
char_positions?: CharPosition[];
res?: boolean;
}>;
@@ -1736,15 +1880,15 @@ export function ReviewPointsList({
<button
key={`field-${index}`}
className={`border border-gray w-full
rounded-md overflow-hidden mb-2 ${value.res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex
hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${value.res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex
hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
onClick={(e) => {
e.stopPropagation();
if (value.page && typeof onReviewPointSelect === 'function') {
console.log("点击了大模型的评查点", resolveCharPositions(value), value)
onReviewPointSelect(reviewPoint.id, Number(value.page), resolveCharPositions(value), value.value);
console.log("点击了大模型的评查点", value.char_positions, value)
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions, value.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), resolveCharPositions(value),value.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), value.char_positions,value.value);
}else{
toastService.error(`没有找到${key}对应的索引内容`);
}
@@ -1754,9 +1898,9 @@ export function ReviewPointsList({
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (value.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(value.page), resolveCharPositions(value), value.value);
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions, value.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), resolveCharPositions(value),value.value);
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), value.char_positions,value.value);
}else{
toastService.error(`没有找到${key}对应的索引内容`);
}
@@ -1787,8 +1931,7 @@ export function ReviewPointsList({
)}
</div>
<div className={`w-8 flex items-center justify-center rounded-r-md group relative`}>
{/* {res ? ( */}
{ value.res ? (
{res ? (
<i className="ri-check-line text-success text-base hover:text-green-800" ></i>
) : (
<i className="ri-alert-line text-warning text-base hover:text-yellow-800" ></i>
@@ -1805,7 +1948,7 @@ export function ReviewPointsList({
<div className={`rounded-md flex flex-row items-center`}>
<div className="text-xs text-gray-600 pl-1 whitespace-nowrap">:</div>
<div className={`p-1 text-xs rounded-full min-w-[50px] text-center`}>
{ value.res ? '通过' : '不通过'}
{res ? '通过' : '不通过'}
</div>
</div>
</div>
@@ -1902,7 +2045,6 @@ export function ReviewPointsList({
if (!isReplaceDisabled && onAiSuggestionReplace && config.fields) {
// 从 config.fields[key] 中获取对应的字段信息
const fieldData = config.fields[key];
console.log("替换原始数据", config, key)
if (fieldData) {
// 调用回调函数,传递搜索文本(原文)、替换文本(AI建议)和页码
onAiSuggestionReplace(
@@ -2603,11 +2745,13 @@ export function ReviewPointsList({
<div className="flex justify-between items-center mb-2">
{/* <div className='flex flex-col'> */}
<div className="flex items-center gap-2 max-w-[75%]">
{reviewPoint.pointCode ? (
<span className="text-xs font-mono bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded border border-blue-200 flex-shrink-0">{reviewPoint.pointCode}</span>
) : (
<span className="text-xs font-mono bg-gray-50 text-gray-500 px-1.5 py-0.5 rounded border border-gray-200 flex-shrink-0">#{reviewPoint.pointId || reviewPoint.id}</span>
)}
<div className="review-point-title text-left text-blue-500 break-all">{reviewPoint.pointName}</div>
{ reviewPoint.pointName === '签署乙方详细信息校验' && (() => {
const firstContentKey = reviewPoint.content ? Object.keys(reviewPoint.content)[0] : undefined;
const firstContentValue = firstContentKey ? reviewPoint.content![firstContentKey]?.value : undefined;
return (
{ reviewPoint.pointName === '签署乙方详细信息校验' && (
<button
className="enterprise-info-btn"
style={{
@@ -2620,25 +2764,27 @@ export function ReviewPointsList({
flexShrink: 0,
transition: 'all 0.2s',
border: 'none',
cursor: firstContentValue ? 'pointer' : 'not-allowed',
backgroundColor: firstContentValue ? '#00684a' : '#e5e7eb',
color: firstContentValue ? '#ffffff' : '#9ca3af',
cursor: reviewPoint.content?.['合同主体信息-乙方名称']?.value ? 'pointer' : 'not-allowed',
backgroundColor: reviewPoint.content?.['合同主体信息-乙方名称']?.value ? '#00684a' : '#e5e7eb',
color: reviewPoint.content?.['合同主体信息-乙方名称']?.value ? '#ffffff' : '#9ca3af',
}}
disabled={!firstContentValue}
disabled={!reviewPoint.content?.['合同主体信息-乙方名称']?.value}
onClick={(e) => {
e.stopPropagation();
const companyName = typeof firstContentValue === 'string' ? firstContentValue : String(firstContentValue || '');
const companyNameValue = reviewPoint.content?.['合同主体信息-乙方名称']?.value;
// console.log('companyNameValue', companyNameValue);
const companyName = typeof companyNameValue === 'string' ? companyNameValue : String(companyNameValue || '');
if (companyName) {
handleCorporateInfoClick(companyName);
}
}}
onMouseEnter={(e) => {
if (firstContentValue) {
if (reviewPoint.content?.['合同主体信息-乙方名称']?.value) {
e.currentTarget.style.backgroundColor = '#005a3f';
}
}}
onMouseLeave={(e) => {
if (firstContentValue) {
if (reviewPoint.content?.['合同主体信息-乙方名称']?.value) {
e.currentTarget.style.backgroundColor = '#00684a';
}
}}
@@ -2646,12 +2792,8 @@ export function ReviewPointsList({
<i className="ri-eye-line"></i>
</button>
);
})()}
{ reviewPoint.pointName === '签署甲方详细信息校验' && (() => {
const firstContentKey = reviewPoint.content ? Object.keys(reviewPoint.content)[0] : undefined;
const firstContentValue = firstContentKey ? reviewPoint.content![firstContentKey]?.value : undefined;
return (
)}
{ reviewPoint.pointName === '签署甲方详细信息校验' && (
<button
className="enterprise-info-btn"
style={{
@@ -2664,25 +2806,26 @@ export function ReviewPointsList({
flexShrink: 0,
transition: 'all 0.2s',
border: 'none',
cursor: firstContentValue ? 'pointer' : 'not-allowed',
backgroundColor: firstContentValue ? '#00684a' : '#e5e7eb',
color: firstContentValue ? '#ffffff' : '#9ca3af',
cursor: reviewPoint.content?.['合同主体信息-甲方名称']?.value ? 'pointer' : 'not-allowed',
backgroundColor: reviewPoint.content?.['合同主体信息-甲方名称']?.value ? '#00684a' : '#e5e7eb',
color: reviewPoint.content?.['合同主体信息-甲方名称']?.value ? '#ffffff' : '#9ca3af',
}}
disabled={!firstContentValue}
disabled={!reviewPoint.content?.['合同主体信息-甲方名称']?.value}
onClick={(e) => {
e.stopPropagation();
const companyName = typeof firstContentValue === 'string' ? firstContentValue : String(firstContentValue || '');
const companyNameValue = reviewPoint.content?.['合同主体信息-甲方名称']?.value;
const companyName = typeof companyNameValue === 'string' ? companyNameValue : String(companyNameValue || '');
if (companyName) {
handleCorporateInfoClick(companyName);
}
}}
onMouseEnter={(e) => {
if (firstContentValue) {
if (reviewPoint.content?.['合同主体信息-甲方名称']?.value) {
e.currentTarget.style.backgroundColor = '#005a3f';
}
}}
onMouseLeave={(e) => {
if (firstContentValue) {
if (reviewPoint.content?.['合同主体信息-甲方名称']?.value) {
e.currentTarget.style.backgroundColor = '#00684a';
}
}}
@@ -2690,8 +2833,7 @@ export function ReviewPointsList({
<i className="ri-eye-line"></i>
</button>
);
})()}
)}
</div>
{/* <div className="review-point-header flex justify-between items-start">
@@ -38,7 +38,6 @@ interface PdfPreviewProps {
filePath: string; // PDF 文件路径
targetPage?: number; // 目标页码
charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息(用于高亮显示)
textBbox?: { x_min: number; y_min: number; x_max: number; y_max: number }; // GraphRAG段落级坐标
isStructuredView?: boolean; // 是否结构化视图
activeReviewPointResultId?: string | null; // 激活的评查点结果ID
pageOffset?: number; // 页码偏移量(用于调整 OCR 结果的页码)
@@ -50,7 +49,6 @@ export function PdfPreview({
filePath,
targetPage,
charPositions,
textBbox,
isStructuredView = false,
activeReviewPointResultId,
pageOffset = 0,
@@ -229,18 +227,6 @@ export function PdfPreview({
// ============ 处理字符位置数据,转换为高亮矩形 ============
const processCharPositionsToHighlights = () => {
// GraphRAG fallback: charPositions 为空但有 textBbox 时,用段落级坐标画高亮
if ((!charPositions || charPositions.length === 0) && textBbox && targetPage) {
const scale = zoomLevel / 100;
return {
x: textBbox.x_min * coordinateScale * scale,
y: textBbox.y_min * coordinateScale * scale,
width: (textBbox.x_max - textBbox.x_min) * coordinateScale * scale,
height: (textBbox.y_max - textBbox.y_min) * coordinateScale * scale,
text: ''
};
}
if (!charPositions || charPositions.length === 0 || !targetPage) {
return null;
}
+97 -28
View File
@@ -2,7 +2,7 @@ import React, {
useState,
useEffect,
} from "react";
import type { EvaluationPoint } from "~/models/evaluation_points";
import type { EvaluationPoint, LLMFieldType } from "~/models/evaluation_points";
import { EVALUATION_OPTIONS, VLMFieldType } from "~/models/evaluation_points";
/**
@@ -35,6 +35,14 @@ import { EVALUATION_OPTIONS, VLMFieldType } from "~/models/evaluation_points";
* - 'or': 规则内任一条件满足即可
*/
/** 获取 LLM 字段名称 */
const getLLMFieldName = (field: LLMFieldType): string =>
typeof field === 'string' ? field : field.name;
/** 获取 LLM 字段的 multi_entity 状态 */
const isLLMFieldMultiEntity = (field: LLMFieldType): boolean =>
typeof field === 'string' ? false : !!field.multi_entity;
interface ExtractionSettingsProps {
onChange: (data: Record<string, unknown>) => void;
initialData: EvaluationPoint;
@@ -196,10 +204,16 @@ export function ExtractionSettings({
const inputs = inputValue[type].split(/[,\s]+/).filter(Boolean);
if (type === 'llm') {
const newFields = [...fields.llm];
const newFields = [...fields.llm] as LLMFieldType[];
inputs.forEach(input => {
if (!newFields.includes(input)) {
newFields.push(input);
const exists = newFields.some(f => getLLMFieldName(f) === input);
if (!exists) {
if (multiEntityEnabled) {
// 多实体模式:新字段默认 multi_entity=true
newFields.push({ name: input, multi_entity: true });
} else {
newFields.push(input);
}
}
});
setFields({ ...fields, llm: newFields });
@@ -218,12 +232,14 @@ export function ExtractionSettings({
newFields.push({
name: input,
type: selectedVlmFieldType as VLMFieldType,
template: customVlmPrompt
template: customVlmPrompt,
multi_entity: false,
});
} else {
newFields.push({
name: input,
type: selectedVlmFieldType as VLMFieldType
type: selectedVlmFieldType as VLMFieldType,
multi_entity: false,
});
}
}
@@ -263,6 +279,30 @@ export function ExtractionSettings({
setHasPendingChanges(true);
};
// 切换 LLM 字段的多实体状态
const toggleLLMFieldMultiEntity = (index: number) => {
if (!multiEntityEnabled) return; // 多实体未开启时不允许切换
const newFields = [...fields.llm] as LLMFieldType[];
const field = newFields[index];
const name = getLLMFieldName(field);
const currentMulti = isLLMFieldMultiEntity(field);
newFields[index] = { name, multi_entity: !currentMulti };
setFields({ ...fields, llm: newFields });
setHasPendingChanges(true);
};
// 切换 VLM 字段的多实体状态
const toggleVLMFieldMultiEntity = (index: number) => {
if (!multiEntityEnabled) return; // 多实体未开启时不允许切换
const newFields = [...fields.vlm];
const field = newFields[index];
if (typeof field === 'object') {
newFields[index] = { ...field, multi_entity: !field.multi_entity };
setFields({ ...fields, vlm: newFields });
setHasPendingChanges(true);
}
};
// 获取VLM字段信息
const getFieldInfo = (field: string | { name: string, type: string, template?: string }) => {
let fieldName, fieldType, typeName, badgeClass;
@@ -523,7 +563,7 @@ export function ExtractionSettings({
// 验证字段唯一性
const allFieldNames = [
...fields.llm,
...fields.llm.map(f => getLLMFieldName(f)),
...fields.vlm.map(f => typeof f === 'string' ? f : f.name),
...validRegexFields.map(f => f.field)
];
@@ -579,6 +619,19 @@ export function ExtractionSettings({
const handleMultiEntityToggle = () => {
const newValue = !multiEntityEnabled;
setMultiEntityEnabled(newValue);
if (newValue) {
// 开启:将所有字符串字段转为 dict(默认 multi_entity=true
const converted = fields.llm.map(f =>
typeof f === 'string' ? { name: f, multi_entity: true } : f
);
setFields({ ...fields, llm: converted });
} else {
// 关闭:将所有字段转回字符串
const simplified = fields.llm.map(f => getLLMFieldName(f));
setFields({ ...fields, llm: simplified });
}
setHasPendingChanges(true);
};
@@ -595,7 +648,7 @@ export function ExtractionSettings({
<i className="ri-group-line text-lg mr-2 text-gray-600"></i>
<div>
<span className="font-medium text-gray-800"></span>
<span className="text-xs text-gray-500 ml-3">AI感知模式</span>
<span className="text-xs text-gray-500 ml-3">绿=</span>
</div>
</div>
@@ -677,25 +730,31 @@ export function ExtractionSettings({
<div className="form-tip mb-2 text-xs">
</div>
<div className="chips-container" id="fields-container">
{fields.llm.map((field, index) => (
<div className="chip" key={`llm-field-${index}`}>
{field}
<span
className="close-btn"
onClick={() => removeField("llm", index)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
removeField("llm", index);
}}
role="button"
tabIndex={0}
aria-label={`删除字段 ${field}`}
<div className="flex flex-wrap gap-2" id="fields-container">
{fields.llm.map((field, index) => {
const name = getLLMFieldName(field);
const isMulti = isLLMFieldMultiEntity(field);
return (
<button
type="button"
key={`llm-field-${index}`}
className="ant-btn ant-btn-default tag-button"
style={multiEntityEnabled && isMulti ? { backgroundColor: '#00684a', borderColor: '#00684a', color: '#fff' } : undefined}
onClick={() => multiEntityEnabled && toggleLLMFieldMultiEntity(index)}
title={multiEntityEnabled ? (isMulti ? '点击关闭多实体展开' : '点击开启多实体展开') : name}
>
×
</span>
</div>
))}
{name}
<span
className="ml-1 cursor-pointer hover:text-red-500"
onClick={(e) => { e.stopPropagation(); removeField("llm", index); }}
role="button"
tabIndex={0}
>
×
</span>
</button>
);
})}
</div>
</div>
@@ -840,18 +899,27 @@ export function ExtractionSettings({
<div className="chips-container" id="fields-container-vlm">
{fields.vlm.map((field, index) => {
const { fieldName, fieldType, typeName, badgeClass } = getFieldInfo(field);
const isMulti = typeof field === 'object' && field.multi_entity === true;
return (
<div className="chip" key={`vlm-field-${index}`}>
<div
className="chip"
key={`vlm-field-${index}`}
style={multiEntityEnabled && isMulti ? { backgroundColor: '#00684a', color: '#fff', borderColor: '#00684a' } : undefined}
onClick={() => toggleVLMFieldMultiEntity(index)}
role={multiEntityEnabled ? 'button' : undefined}
title={multiEntityEnabled ? (isMulti ? '点击关闭多实体展开' : '点击开启多实体展开') : fieldName}
>
{fieldName}
<span
className={`badge ${badgeClass} text-xs ml-1`}
data-type={fieldType}
style={multiEntityEnabled && isMulti ? { backgroundColor: 'rgba(255,255,255,0.25)', color: '#fff' } : undefined}
>
{typeName}
</span>
<span
className="close-btn"
onClick={() => removeField("vlm", index)}
onClick={(e) => { e.stopPropagation(); removeField("vlm", index); }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
removeField("vlm", index);
@@ -859,6 +927,7 @@ export function ExtractionSettings({
role="button"
tabIndex={0}
aria-label={`删除字段 ${fieldName}`}
style={multiEntityEnabled && isMulti ? { color: 'rgba(255,255,255,0.8)' } : undefined}
>
×
</span>
+30 -26
View File
@@ -387,11 +387,12 @@ export function ReviewSettings({
// 去重
const uniqueFields = [...new Set(processedFields)];
// 检查是否有字段被删除
const deletedFields = availableFields.filter(field => !uniqueFields.includes(field));
// 检查是否有字段被删除(防御性:确保availableFields是数组)
const safeAvailableFields: string[] = Array.isArray(availableFields) ? availableFields : [];
const deletedFields = safeAvailableFields.filter(field => !uniqueFields.includes(field));
// 处理新增的字段
const newFields = uniqueFields.filter((field: string) => !availableFields.includes(field));
const newFields = uniqueFields.filter((field: string) => !safeAvailableFields.includes(field));
if (newFields.length > 0 || deletedFields.length > 0) {
// console.log('Updating fields in checkAndUpdateFields - deleted:', deletedFields, 'new:', newFields);
@@ -538,13 +539,15 @@ export function ReviewSettings({
const updatedConfig = { ...rule.config };
// 对所有规则类型都更新availableFields字段
// 处理字段,只保留字段名,去掉类型后缀
const processedFields = newFields.map(field => {
if (field.includes('_')) {
return field.split('_')[0]; // 只保留类型前面的字段名
}
return field;
});
// 处理字段,只保留字段名,去掉类型后缀(防御性:确保field是字符串)
const processedFields = newFields
.filter(field => typeof field === 'string')
.map(field => {
if (field.includes('_')) {
return field.split('_')[0];
}
return field;
});
// 去重
const uniqueFields = [...new Set(processedFields)];
@@ -792,21 +795,22 @@ export function ReviewSettings({
// 渲染字段标签,确保已选择的字段即使在新的字段列表中不存在也会显示
const renderFieldTags = (ruleId: string, config: Record<string, unknown>) => {
// 获取规则的当前已选字段
// 修复:对于exists类型规则,应该使用fields而不是selectedFields
const selectedFields = Array.isArray(config.fields) ?
config.fields as string[] :
(Array.isArray(config.selectedFields) ? config.selectedFields as string[] : []);
// 获取规则的当前已选字段(防御性:确保总是数组)
const rawSelected = Array.isArray(config.fields)
? config.fields
: (Array.isArray(config.selectedFields) ? config.selectedFields : []);
const selectedFields: string[] = Array.isArray(rawSelected) ? rawSelected : [];
// 优先使用配置中存储的可用字段,如果没有则使用当前可用字段
const fieldsToRender = Array.isArray(config.availableFields) ?
config.availableFields as string[] :
availableFields;
const rawFieldsToRender = Array.isArray(config.availableFields)
? config.availableFields
: availableFields;
const fieldsToRender: string[] = Array.isArray(rawFieldsToRender) ? rawFieldsToRender : [];
return (
<div className="field-tags">
{fieldsToRender.map((field, index) => {
// 使用includes方法检查选中状态
// 使用includes方法检查选中状态selectedFields 总是数组,不会抛错)
const isSelected = selectedFields.includes(field);
return (
@@ -871,10 +875,10 @@ export function ReviewSettings({
const { id, type, config } = rule;
// 如果规则中的availableFields不是最新的,则更新它
if (type && config && (!config.availableFields ||
(Array.isArray(config.availableFields) &&
!availableFields.every((field) => (config.availableFields as string[]).includes(field)) ||
!(config.availableFields as string[]).every((field) => availableFields.includes(field))))) {
const cfgAvailableFields = Array.isArray(config.availableFields) ? config.availableFields as string[] : null;
if (type && config && (!cfgAvailableFields ||
!availableFields.every((field) => cfgAvailableFields.includes(field)) ||
!cfgAvailableFields.every((field) => availableFields.includes(field)))) {
// 延迟更新以避免在渲染过程中修改状态
setTimeout(() => {
// console.log('Updating rule config with new available fields:', availableFields);
+10 -10
View File
@@ -47,11 +47,11 @@ export const portConfigs: Record<string, Partial<ApiConfig>> = {
// 梅州
'51703': {
// baseUrl: 'http://nas.7bm.co:8073',
// documentUrl: 'http://nas.7bm.co:8073/docauditai/',
// uploadUrl: 'http://nas.7bm.co:8073/api/v2/documents',
// collaboraUrl: 'http://nas.7bm.co:9980',
// appUrl: 'http://nas.7bm.co:51703',
baseUrl: 'http://nas.7bm.co:8073',
documentUrl: 'http://nas.7bm.co:8073/docauditai/',
uploadUrl: 'http://nas.7bm.co:8073/api/v2/documents',
collaboraUrl: 'http://nas.7bm.co:9980',
appUrl: 'http://nas.7bm.co:51703',
// baseUrl: 'http://172.16.0.56:8073',
@@ -60,11 +60,11 @@ export const portConfigs: Record<string, Partial<ApiConfig>> = {
// collaboraUrl: 'http://172.16.0.81:9980',
// appUrl: 'http://172.16.0.34:51703',
baseUrl: 'http://10.79.97.17:8000',
documentUrl: 'http://10.79.97.17:8000/docauditai/',
uploadUrl: 'http://10.79.97.17:8000/api/v2/documents',
collaboraUrl: 'http://10.79.97.17:9980',
appUrl: 'http://10.79.97.17:51703',
// baseUrl: 'http://10.79.97.17:8000',
// documentUrl: 'http://10.79.97.17:8000/docauditai/',
// uploadUrl: 'http://10.79.97.17:8000/api/v2/documents',
// collaboraUrl: 'http://10.79.97.17:9980',
// appUrl: 'http://10.79.97.17:51703',
oauth: {
redirectUri: 'http://10.79.97.17:51703/callback',
+18 -2
View File
@@ -55,19 +55,33 @@ interface MultiEntityConfig {
*/
type MultiEntityExpandMode = 'awareness'; // AI感知模式
/**
* LLM 字段配置(支持字段级多实体标记)
*/
interface LLMFieldConfig {
name: string; // 字段名称(完整路径)
multi_entity?: boolean; // 是否对此字段做多实体展开(默认 false)
}
/**
* LLM 字段类型:支持旧格式字符串和新格式对象
*/
type LLMFieldType = string | LLMFieldConfig;
/**
* 抽取配置类型定义
*/
interface ExtactionConfigType {
multi_entity?: MultiEntityConfig; // 多实体抽取配置(控制抽取阶段是否按实体展开字段
multi_entity?: MultiEntityConfig; // 多实体抽取配置(EP级开关,作为旧格式str字段的fallback
llm: {
fields: string[];
fields: LLMFieldType[]; // 支持 "字段名" 或 {"name": "字段名", "multi_entity": true}
prompt_setting: LLMPromptSetting;
};
vlm: {
fields: Array<{
name: string;
type: VLMFieldType; // 多模态字段类型 默认、货币、打印、印章、骑缝章、英文、数字、手写、自定义
multi_entity?: boolean; // 是否对此字段做多实体展开
template?: string; // 自定义类型的提示词模板(仅当 type 为 custom 时使用)
}>;
prompt_setting: VLMPromptSetting;
@@ -329,6 +343,8 @@ export type {
ExtactionConfigType,
MultiEntityConfig,
MultiEntityExpandMode,
LLMFieldConfig,
LLMFieldType,
VLMPromptSetting,
VLMFieldType,
LLMPromptSetting,
-17
View File
@@ -100,23 +100,6 @@ const AREA_OPTIONS = [
{ value: "云浮", label: "云浮" },
{ value: "揭阳", label: "揭阳" },
{ value: "潮州", label: "潮州" },
{ value: "湛江", label: "湛江" },
// { value: "广州", label: "广州" },
// { value: "深圳", label: "深圳" },
// { value: "珠海", label: "珠海" },
// { value: "佛山", label: "佛山" },
// { value: "惠州", label: "惠州" },
// { value: "江门", label: "江门" },
// { value: "茂名", label: "茂名" },
// { value: "汕尾", label: "汕尾" },
// { value: "汕头", label: "汕头" },
// { value: "河源", label: "河源" },
// { value: "阳江", label: "阳江" },
// { value: "清远", label: "清远" },
// { value: "东莞", label: "东莞" },
// { value: "中山", label: "中山" },
// { value: "肇庆", label: "肇庆" },
// { value: "韶关", label: "韶关" },
{ value: "省局", label: "省局" }
];
-17
View File
@@ -76,23 +76,6 @@ const AREA_OPTIONS = [
{ value: "云浮", label: "云浮" },
{ value: "揭阳", label: "揭阳" },
{ value: "潮州", label: "潮州" },
{ value: "湛江", label: "湛江" },
// { value: "广州", label: "广州" },
// { value: "深圳", label: "深圳" },
// { value: "珠海", label: "珠海" },
// { value: "佛山", label: "佛山" },
// { value: "惠州", label: "惠州" },
// { value: "江门", label: "江门" },
// { value: "茂名", label: "茂名" },
// { value: "汕尾", label: "汕尾" },
// { value: "汕头", label: "汕头" },
// { value: "河源", label: "河源" },
// { value: "阳江", label: "阳江" },
// { value: "清远", label: "清远" },
// { value: "东莞", label: "东莞" },
// { value: "中山", label: "中山" },
// { value: "肇庆", label: "肇庆" },
// { value: "韶关", label: "韶关" },
{ value: "省局", label: "省局" }
];
+26 -7
View File
@@ -31,6 +31,7 @@ import { useNavigate, useLoaderData, useFetcher } from "@remix-run/react";
import type { FilePreviewHandle } from "~/components/reviews/FilePreview";
import reviewsStyles from "~/styles/reviews.css?url";
import { getReviewPoints, getReviewPoints_fromApi, getUnifiedEvaluationResults, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews";
import { postgrestGet } from "~/api/postgrest-client";
import { toastService } from "~/components/ui/Toast";
// 导入评查详情页面组件
@@ -190,6 +191,27 @@ export async function loader({ request }: LoaderFunctionArgs) {
return Response.json({ result: false, message: '文件ID不能为空' });
}
// 补充 pointCode 到 reviewPoints(直接查 DB,不受 Vite tree-shake 影响)
async function patchPointCodes(points: any[], jwt: string) {
try {
const pointIds = points.map((p: any) => p.pointId).filter(Boolean);
if (pointIds.length === 0) return;
const resp = await postgrestGet<any>('/api/postgrest/proxy/evaluation_points', {
select: 'id,code',
filter: { id: `in.(${[...new Set(pointIds)].join(',')})` },
token: jwt,
});
// resp.data 可能是 {code:200, data:[...]} 或直接 [...]
const raw = resp.data;
const epList = Array.isArray(raw) ? raw : (raw?.data && Array.isArray(raw.data) ? raw.data : []);
const codeMap: Record<string, string> = {};
epList.forEach((ep: any) => { if (ep.code) codeMap[String(ep.id)] = ep.code; });
points.forEach((p: any) => { p.pointCode = codeMap[String(p.pointId)] || ''; });
} catch (e) {
console.error('[Reviews Loader] patchPointCodes error:', e);
}
}
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
@@ -209,6 +231,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) {
await patchPointCodes(reviewData.data as any[], frontendJWT);
return Response.json({
previousRoute: previousRoute,
document: reviewData.document,
@@ -243,6 +266,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
editAuditStatusMessage: '',
title: '该评查点未涉及',
pointName: r.name || '',
pointCode: r.code || '',
groupName: '',
status: 'notApplicable',
content: {},
@@ -255,6 +279,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
postAction: '',
}));
const allReviewPoints = [...existingPoints, ...notApplicablePoints];
await patchPointCodes(allReviewPoints, frontendJWT);
return Response.json({
previousRoute: previousRoute,
@@ -386,7 +411,6 @@ export default function ReviewDetails() {
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
const [templateTargetPage, setTemplateTargetPage] = useState<number | undefined>(undefined);
const [charPositions, setCharPositions] = useState<Array<{ box: number[][], char: string, score: number }> | undefined>(undefined);
const [textBbox, setTextBbox] = useState<{ x_min: number; y_min: number; x_max: number; y_max: number } | undefined>(undefined);
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
const [pendingUpdate, setPendingUpdate] = useState<{
reviewPointResultId: string;
@@ -552,19 +576,17 @@ export default function ReviewDetails() {
setActiveTab(tabKey);
};
const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string, bbox?: { x_min: number; y_min: number; x_max: number; y_max: number }) => {
const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string) => {
// 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发
if (reviewPointId === activeReviewPointResultId && page) {
setTargetPage(undefined);
setCharPositions(undefined);
setTextBbox(undefined);
setHighlightValue(undefined);
// 使用setTimeout确保状态更新后再设置新的targetPage、charPositions和highlightValue
setTimeout(() => {
setActiveReviewPointResultId(reviewPointId);
setTargetPage(page);
setCharPositions(charPos);
setTextBbox(bbox);
setHighlightValue(value);
}, 0);
} else {
@@ -572,7 +594,6 @@ export default function ReviewDetails() {
setActiveReviewPointResultId(reviewPointId);
setTargetPage(page);
setCharPositions(charPos);
setTextBbox(bbox);
setHighlightValue(value);
}
};
@@ -970,7 +991,6 @@ export default function ReviewDetails() {
activeReviewPointResultId={activeReviewPointResultId}
targetPage={targetPage}
charPositions={charPositions}
textBbox={textBbox}
highlightValue={highlightValue}
userInfo={loaderData.userInfo}
aiSuggestionReplace={aiSuggestionReplace}
@@ -1024,7 +1044,6 @@ export default function ReviewDetails() {
activeReviewPointResultId={activeReviewPointResultId}
targetPage={targetPage}
charPositions={charPositions}
textBbox={textBbox}
/>
</div>
+3 -2
View File
@@ -855,10 +855,11 @@ export default function RuleNew() {
}
if (response.error) {
if (response.error.includes('evaluation_points_code_key')) {
const errorStr = typeof response.error === 'string' ? response.error : JSON.stringify(response.error);
if (errorStr.includes('evaluation_points_code_key')) {
toastService.error('在基本信息中:评查点编码已存在,请修改后保存。');
} else {
toastService.error(`系统繁忙: ${response.error}`);
toastService.error(`系统繁忙: ${errorStr}`);
}
setIsLoading(false);
} else if (response.data) {
+11
View File
@@ -95,6 +95,17 @@
@apply bg-[rgba(0,104,74,0.15)] border-[#00684a] text-[#00684a];
}
.config-new-page .ant-btn.tag-button.tag-multi-entity {
background-color: #00684a !important;
border-color: #00684a !important;
color: #fff !important;
}
.config-new-page .ant-btn.tag-button.tag-multi-entity:hover {
background-color: #005a3f !important;
border-color: #005a3f !important;
color: #fff !important;
}
/* JSON编辑器 */
.config-new-page .json-editor {
@apply w-full min-h-[400px] font-mono text-sm leading-relaxed
+9 -7
View File
@@ -10,20 +10,22 @@ dayjs.extend(utc);
*
* : "字段名_类型" -> "字段名"
*/
export function processFieldName(field: string): string {
if (field.includes('_')) {
return field.split('_')[0]; // 只保留类型前面的字段名
export function processFieldName(field: string | { name: string; [key: string]: unknown }): string {
const name = typeof field === 'string' ? field : (field?.name || '');
if (name.includes('_')) {
return name.split('_')[0]; // 只保留类型前面的字段名
}
return field;
return name;
}
/**
*
* {name, multi_entity}
*/
export function processFieldNames(fields: string[]): string[] {
export function processFieldNames(fields: (string | { name: string; [key: string]: unknown })[]): string[] {
// 处理字段,去掉类型后缀
const processedFields = fields.map(processFieldName);
const processedFields = fields.map(processFieldName).filter(Boolean);
// 去重并返回
return [...new Set(processedFields)];
}
+332
View File
@@ -0,0 +1,332 @@
# 用户自定义合同类型 - 前后端对接文档
## 概述
为了优化 GraphRAG 抽取管道的规则加载,后端新增了 `attribute_type`(合同专属类型)参数支持。当指定该参数时,系统只加载 **通用 + 指定类型** 的评查点规则,大幅减少无关字段(816 → ~197),提升检索精度和抽取稳定性。
## 后端修改内容
### 1. 文档类型自动检测(`contract_server.py`
**文件**: `services/documents/v2/contract_server.py`
**新增方法**: `_detect_attribute_type()`
```python
@staticmethod
def _detect_attribute_type(filename: str) -> Optional[str]:
"""
从文件名关键词检测合同专属子类型。
关键词表与 executor._CONTRACT_TYPE_KEYWORDS 保持一致。
返回: "技术"/"租赁"/"买卖"/... 或 None
"""
_KEYWORDS = [
("买卖", ["采购", "买卖", "购销", "供货", "购买"]),
("租赁", ["租赁", "租用", "出租"]),
("服务", ["服务", "咨询", "顾问"]),
("委托", ["委托", "代理"]),
("建设工程", ["建设工程", "施工", "装修", "装饰"]),
("培训", ["培训", "教育", "教学"]),
("技术", ["技术", "开发", "软件", "系统集成", "信息化"]),
("赠与", ["赠与", "捐赠"]),
("运输", ["运输", "物流", "配送", "搬运"]),
("仓储", ["仓储", "存储", "保管"]),
("合作", ["合作", "联营", "合资"]),
("承揽", ["承揽", "加工", "定作"]),
]
if not filename:
return None
for type_code, keywords in _KEYWORDS:
for kw in keywords:
if kw in filename:
return type_code
return None
```
**调用位置**: `extract()` 方法(约 L455
```python
# 确定合同专属子类型(用于精准加载规则)
_attribute_type = self._detect_attribute_type(self.filename)
if _attribute_type:
log.document.info(
f"[GraphRAG] doc={self.document_id} 合同子类型: {_attribute_type}"
)
rag_result = await rag_service.extract(
...
attribute_type=_attribute_type,
)
```
### 2. GraphRAG 抽取服务支持(`rag_extraction_service.py`
**文件**: `services/graph_rag/rag_extraction_service.py`
**修改点 1**: `extract()` 方法签名新增 `attribute_type` 参数(约 L175
```python
async def extract(
self,
...
attribute_type: Optional[str] = None, # 新增参数
) -> Dict[str, Any]:
"""
Args:
...
attribute_type: 合同专属子类型(如"技术""租赁"),由上游指定,
用于只加载 通用+该类型 的评查点规则,减少无关字段
"""
```
**修改点 2**: `_load_extraction_rules()` 方法新增类型过滤逻辑(约 L901
```python
# 指定了合同子类型时,只加载 通用+该类型(大幅减少无关字段)
if attribute_type:
_ep_filters["document_attribute_type"] = f"in.(通用,{attribute_type})"
```
### 3. 支持的合同类型
| 类型代码 | 类型名称 | 关键词(文件名检测) |
|---------|---------|---------------------|
| `通用` | 通用类型 | 默认类型 |
| `买卖` | 买卖合同 | 采购、买卖、购销、供货、购买 |
| `租赁` | 租赁合同 | 租赁、租用、出租 |
| `服务` | 服务合同 | 服务、咨询、顾问 |
| `委托` | 委托合同 | 委托、代理 |
| `建设工程` | 建设工程合同 | 建设工程、施工、装修、装饰 |
| `培训` | 培训合同 | 培训、教育、教学 |
| `技术` | 技术合同 | 技术、开发、软件、系统集成、信息化 |
| `赠与` | 赠与合同 | 赠与、捐赠 |
| `运输` | 运输合同 | 运输、物流、配送、搬运 |
| `仓储` | 仓储合同 | 仓储、存储、保管 |
| `合作` | 合作合同 | 合作、联营、合资 |
| `承揽` | 承揽合同 | 承揽、加工、定作 |
## 前端对接指南
### 当前自动检测机制
**现状**: 后端已实现从**文件名**自动检测合同类型的逻辑。
例如:
- `技术合同(去空格).docx` → 自动识别为 `技术` 类型
- `房屋租赁合同.pdf` → 自动识别为 `租赁` 类型
- `货物采购合同.docx` → 自动识别为 `买卖` 类型
### 前端手动指定合同类型(需后端额外支持)
**注意**: 当前后端尚未支持从 `upload_info` 中读取 `attribute_type` 参数。如需前端手动指定,需要进行以下额外修改:
#### 方案 A:修改上传接口传递 attribute_type
**1. 前端上传时传递参数**
```javascript
// 示例:使用 FormData 上传
const formData = new FormData();
formData.append('file', fileBlob);
// 在 upload_info 中指定合同类型
const uploadInfo = {
type_id: 1, // 文档类型ID(1=合同)
document_number: "HT2024001", // 合同编号
attribute_type: "技术", // 👈 手动指定合同类型
evaluation_level: "普通",
remark: "测试合同"
};
formData.append('upload_info', JSON.stringify(uploadInfo));
// 调用上传接口
const response = await fetch('/api/v2/documents/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData
});
```
**2. 后端需要额外修改的位置**
##### 2.1 修改 `contract_server.py``process_contract()` 方法
在约 L455 处,优先使用用户指定的类型:
```python
# 确定合同专属子类型(用于精准加载规则)
# 优先级:用户指定 > 文件名检测
_attribute_type = None
# 1. 尝试从 upload_info 获取用户指定类型(需要传入)
# if hasattr(self, 'upload_info') and self.upload_info:
# _attribute_type = self.upload_info.get('attribute_type')
# 2. 文件名自动检测(当前已实现)
if not _attribute_type:
_attribute_type = self._detect_attribute_type(self.filename)
if _attribute_type:
log.document.info(
f"[GraphRAG] doc={self.document_id} 合同子类型: {_attribute_type} "
f"({'用户指定' if hasattr(self, 'upload_info') and self.upload_info and self.upload_info.get('attribute_type') == _attribute_type else '文件名检测'})"
)
```
##### 2.2 修改 `tasks.py``process_contract_markdown()` 任务
在约 L1092 处,将 `attribute_type` 传递给 ContractMarkdownServer
```python
# 获取用户指定的合同类型(如果有的话)
attribute_type = upload_info.get("attribute_type")
# 创建处理服务
contract_server = ContractMarkdownServer(
document_id=document_id,
file_path=temp_path,
filename=filename,
template_id=template_id,
user_area=user_area,
user_role=user_role,
attribute_type=attribute_type, # 👈 新增参数
)
```
##### 2.3 修改 `ContractMarkdownServer`
**文件**: `services/documents/v2/contract_markdown_server.py`
```python
class ContractMarkdownServer:
def __init__(
self,
...
attribute_type: Optional[str] = None, # 新增参数
):
...
self.attribute_type = attribute_type
async def process_contract(self):
...
# 在调用 ContractServer.extract() 时传递 attribute_type
# (具体实现取决于 ContractMarkdownServer 如何调用 ContractServer)
```
### API 接口说明
#### 上传文档接口
**URL**: `POST /api/v2/documents/upload`
**Content-Type**: `multipart/form-data`
**参数**:
| 参数名 | 类型 | 必填 | 说明 |
|-------|------|------|------|
| `file` | File | 是 | 上传的文件(PDF/Word |
| `upload_info` | String(JSON) | 否 | 上传信息 JSON 字符串 |
| `attachments` | File[] | 否 | 附件文件列表 |
**upload_info 结构**:
```json
{
"type_id": 1, // 文档类型ID:1=合同,2=案卷
"document_number": "HT2024001", // 合同编号(可选)
"attribute_type": "技术", // 合同专属类型(可选,待后端支持)
"evaluation_level": "普通", // 评查级别(可选)
"is_test_document": true, // 是否测试文档(可选)
"remark": "备注信息" // 备注(可选)
}
```
**响应示例**:
```json
{
"success": true,
"result": {
"document_id": 2751,
"document_number": "HT2024001",
"file_name": "技术合同(去空格).docx",
"file_url": "documents/.../xxx.docx",
"status": "Queued",
"type_id": 1,
"doc_type": "HT",
"doc_type_description": "合同",
"api_version": "v2",
"is_test_document": true,
"remark": "",
"evaluation_level": "普通"
}
}
```
### 可用的合同类型值(attribute_type
前端下拉选项建议:
```javascript
const CONTRACT_TYPES = [
{ value: "通用", label: "通用合同" },
{ value: "技术", label: "技术合同" },
{ value: "租赁", label: "租赁合同" },
{ value: "买卖", label: "买卖合同" },
{ value: "服务", label: "服务合同" },
{ value: "委托", label: "委托合同" },
{ value: "建设工程", label: "建设工程合同" },
{ value: "培训", label: "培训合同" },
{ value: "赠与", label: "赠与合同" },
{ value: "运输", label: "运输合同" },
{ value: "仓储", label: "仓储合同" },
{ value: "合作", label: "合作合同" },
{ value: "承揽", label: "承揽合同" }
];
```
## 验证方式
### 1. 查看日志确认类型检测
上传文档后,查看日志确认合同类型识别:
```
[DOCUMENT] [GraphRAG] doc=2751 合同子类型: 技术
```
### 2. 查看规则加载情况
```
[EXTRACTION] [RAG Rules] GROUP路径加载 197 个评查点 (过滤: 通用+技术), 子类型分布: {...}
```
- **未指定类型**: 加载 816 个评查点(所有类型)
- **指定类型**: 加载 ~197 个评查点(通用+指定类型)
### 3. 评查点过滤验证
某些专属评查点会根据类型过滤:
| 评查点 | 类型 | 行为 |
|--------|------|------|
| EP-089 技术内容-技术方案 | 技术 | 仅技术合同加载 |
| EP-091 技术内容-技术指标 | 技术 | 仅技术合同加载 |
| EP-020 违约责任条款完整性 | 通用 | 所有合同都加载 |
## 待实现功能(需后端额外开发)
| 功能 | 状态 | 说明 |
|------|------|------|
| 文件名自动检测 | ✅ 已实现 | 后端从文件名关键词自动识别 |
| 前端手动指定 | ⚠️ 待实现 | 需修改 `contract_server.py``tasks.py` |
| 用户历史记录 | ❌ 未规划 | 记录用户常用合同类型,自动填充 |
| AI 智能推荐 | ❌ 未规划 | 根据文档内容推荐合同类型 |
## 联系方式
如有疑问,请联系后端开发团队。
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["superpowers@git+https://github.com/obra/superpowers.git"],
"mcp": {
"docauditai": {
"type": "local",
"command": [
"npx",
"@henkey/postgres-mcp-server",
"--connection-string"
]
},
"postgres-db": {
"type": "local",
"command": [
"npx",
"@henkey/postgres-mcp-server",
"--connection-string",
"postgresql://docauditai_admin:zhfw*123*@nas.7bm.co:54302/docauditai"
]
}
}
}
+27928
View File
File diff suppressed because it is too large Load Diff