Merge branch 'Wren-dev' into shiy-login
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "省局" }
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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 智能推荐 | ❌ 未规划 | 根据文档内容推荐合同类型 |
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有疑问,请联系后端开发团队。
|
||||
+1442
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+27928
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user