@customize-agent/knowledge 4.1.2 → 4.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { FileCategory } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* 入库前文本清洗(源头治理):在「解析完成 → 分块入库」之间移除确定性的噪声数据,
|
|
4
|
+
* 使入库的即干净、真实需要的数据——下游检索、证据注入、生成链路全流程受益
|
|
5
|
+
* (上下文更瘦、prefix cache 更稳、事实提取更准),并减少生成链路的重复清洗成本。
|
|
6
|
+
*
|
|
7
|
+
* 清洗范围(三类,按文件类型与内容特征分流):
|
|
8
|
+
* A. 格式噪声(K1):页眉/页脚/图框标题栏高重复行、纯页码行、目录区段、连续空行、
|
|
9
|
+
* 投标文件格式模板段、泛化引用行、补疑零信息回复行、图纸纯坐标行;
|
|
10
|
+
* B. 内容无关数据(K2,章节级):合同通用条款(标准示范文本,不含项目数据)、
|
|
11
|
+
* 招标公告程序段(文件获取/递交/开标方式)、评标办法商务评审细则——
|
|
12
|
+
* 施工组织设计(技术标)生成用不到的商务/程序性内容,不入库、不进检索上下文。
|
|
13
|
+
* C. 图纸/清单专门噪声(K3):图纸图框标题栏信息行(图号/比例/签名行)、CAD 属性行
|
|
14
|
+
* (图层/颜色/线型)、清单纯报价表格段(费汇总/暂估/规费/税金,含金额行证据)、
|
|
15
|
+
* 清单扉页签章段——分部分项清单的名称/特征/工程量是施组核心数据,绝不删。
|
|
16
|
+
*
|
|
17
|
+
* 保守策略(宁多勿丢红线):每条规则都要求「标题模式 + 内容特征/规模」多重证据,
|
|
18
|
+
* 只删高置信噪声;清洗后文本若不足原文 30% 整体回退;KB_TEXT_CLEANING=0 关闭清洗。
|
|
19
|
+
*/
|
|
20
|
+
export interface TextCleanStats {
|
|
21
|
+
/** 页眉/页脚/图框标题栏高重复行数 */
|
|
22
|
+
headerFooterLines: number;
|
|
23
|
+
/** 纯页码行数 */
|
|
24
|
+
pageNumberLines: number;
|
|
25
|
+
/** 目录区段行数 */
|
|
26
|
+
tocRegionLines: number;
|
|
27
|
+
/** 压缩掉的连续空行数 */
|
|
28
|
+
blankLines: number;
|
|
29
|
+
/** 招标文件:投标文件格式模板段行数 */
|
|
30
|
+
tenderFormatLines: number;
|
|
31
|
+
/** 招标文件:泛化引用行数 */
|
|
32
|
+
tenderGenericLines: number;
|
|
33
|
+
/** 补疑文件:零信息回复行数 */
|
|
34
|
+
clarificationLines: number;
|
|
35
|
+
/** 图纸:无汉字纯坐标行数 */
|
|
36
|
+
cadNoiseLines: number;
|
|
37
|
+
/** 合同通用条款章节行数(K2 内容无关) */
|
|
38
|
+
contractGeneralClauseLines: number;
|
|
39
|
+
/** 招标公告程序段行数(K2 内容无关) */
|
|
40
|
+
announcementProcedureLines: number;
|
|
41
|
+
/** 评标商务评审细则段行数(K2 内容无关) */
|
|
42
|
+
businessReviewLines: number;
|
|
43
|
+
/** 图纸:图框标题栏信息行数(图号/比例/签名行,K3) */
|
|
44
|
+
cadTitleBlockLines: number;
|
|
45
|
+
/** 图纸:CAD 属性行数(图层/颜色/线型,K3) */
|
|
46
|
+
cadAttributeLines: number;
|
|
47
|
+
/** 清单:纯报价表格段行数(费汇总/暂估价/规费/税金,K3) */
|
|
48
|
+
billPricingLines: number;
|
|
49
|
+
/** 清单:扉页签章段行数(K3) */
|
|
50
|
+
billTitlePageLines: number;
|
|
51
|
+
}
|
|
52
|
+
export interface TextCleaningResult {
|
|
53
|
+
/** 清洗后的文本(30% 回退保护下可能等于原文) */
|
|
54
|
+
text: string;
|
|
55
|
+
removedLines: number;
|
|
56
|
+
removedChars: number;
|
|
57
|
+
stats: TextCleanStats;
|
|
58
|
+
}
|
|
59
|
+
export interface TextCleaningInput {
|
|
60
|
+
text: string;
|
|
61
|
+
/** 文件分类(classifier 的 FileCategory),用于选择清洗规则组 */
|
|
62
|
+
category?: FileCategory | string;
|
|
63
|
+
/** 文件名/相对路径(招标/补疑文档类型判定依据) */
|
|
64
|
+
fileName?: string;
|
|
65
|
+
/** 显式开关;缺省读环境变量 KB_TEXT_CLEANING(=0 关闭) */
|
|
66
|
+
enabled?: boolean;
|
|
67
|
+
}
|
|
68
|
+
/** 入库前文本清洗主入口:行级保守清洗 + 目录/格式段级清洗 + 30% 整体回退保护 */
|
|
69
|
+
export declare function cleanExtractedText(input: TextCleaningInput): TextCleaningResult;
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
function zeroStats() {
|
|
2
|
+
return {
|
|
3
|
+
headerFooterLines: 0, pageNumberLines: 0, tocRegionLines: 0, blankLines: 0,
|
|
4
|
+
tenderFormatLines: 0, tenderGenericLines: 0, clarificationLines: 0, cadNoiseLines: 0,
|
|
5
|
+
contractGeneralClauseLines: 0, announcementProcedureLines: 0, businessReviewLines: 0,
|
|
6
|
+
cadTitleBlockLines: 0, cadAttributeLines: 0, billPricingLines: 0, billTitlePageLines: 0,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** 文档类型判定:文件名 + 文首内容双重特征(招标文件 / 补疑答疑 / 工程量清单 / 其他) */
|
|
10
|
+
function detectDocKind(fileName, head) {
|
|
11
|
+
if (/(补疑|答疑|澄清|补遗|变更通知|招标答疑)/u.test(fileName) || /答疑纪要|澄清函|补遗文件|招标文件答疑/u.test(head))
|
|
12
|
+
return 'clarification';
|
|
13
|
+
if (/(招标文件|招标公告|招标书|邀请招标)/u.test(fileName) || /投标人须知|招标公告|投标邀请/u.test(head))
|
|
14
|
+
return 'tender';
|
|
15
|
+
if (/(清单|BQ|bill|Bill)/u.test(fileName) || /^.{0,300}工程量清单|分部分项工程量清单/u.test(head))
|
|
16
|
+
return 'bill';
|
|
17
|
+
return 'other';
|
|
18
|
+
}
|
|
19
|
+
/** 页眉/页脚/图框标题栏高重复行:全文出现 ≥3 次 + 长度 4-60 + 非表格行 + 非列表项 + 非表头 */
|
|
20
|
+
function isHeaderFooterLine(trimmed, count) {
|
|
21
|
+
if (count < 3)
|
|
22
|
+
return false;
|
|
23
|
+
if (trimmed.length < 4 || trimmed.length > 60)
|
|
24
|
+
return false;
|
|
25
|
+
if (trimmed.includes('|'))
|
|
26
|
+
return false;
|
|
27
|
+
if (/^[-*•·]\s|^\d+[、..]\s/u.test(trimmed))
|
|
28
|
+
return false;
|
|
29
|
+
// 表头保护:短行含表头关键词(纯文本格式清单/表格每页重复的表头)保留
|
|
30
|
+
if (trimmed.length < 12 && /序号|名称|单位|数量|金额|备注|编码/u.test(trimmed))
|
|
31
|
+
return false;
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
/** 纯页码行:独立成行的纯数字/页码格式(小文件保护:行数 <20 不删,可能是数据文件) */
|
|
35
|
+
function isPageNumberLine(trimmed, totalLines) {
|
|
36
|
+
if (totalLines < 20)
|
|
37
|
+
return false;
|
|
38
|
+
return /^\d{1,4}$/u.test(trimmed)
|
|
39
|
+
|| /^第\s*\d{1,4}\s*页/u.test(trimmed)
|
|
40
|
+
|| /^\d{1,4}\s*\/\s*\d{1,4}$/u.test(trimmed)
|
|
41
|
+
|| /^[-–—]\s*\d{1,4}\s*[-–—]$/u.test(trimmed);
|
|
42
|
+
}
|
|
43
|
+
const TOC_DOT_LINE_RE = /^.{2,80}[.·…]{3,}\s*\d{1,4}\s*$/u;
|
|
44
|
+
const TOC_TITLE_RE = /^目\s*录\s*$/u;
|
|
45
|
+
/**
|
|
46
|
+
* 目录区段探测:从「目录」标题行开始,连续收集点线行("标题......页码")与短标题行,
|
|
47
|
+
* 遇到正文特征行(长行/表格行)停止。返回区段结束位置与点线行占比,由调用方按
|
|
48
|
+
* 「行数 ≥4 且点线占比 ≥50%」双重条件决定是否整区删除。
|
|
49
|
+
*/
|
|
50
|
+
function collectTocRegion(lines, start) {
|
|
51
|
+
let end = start + 1;
|
|
52
|
+
let dotLines = 0;
|
|
53
|
+
const hardLimit = Math.min(lines.length, start + 300);
|
|
54
|
+
let lastWasDotLine = false;
|
|
55
|
+
for (let j = start + 1; j < hardLimit; j += 1) {
|
|
56
|
+
const trimmed = lines[j].trim();
|
|
57
|
+
if (!trimmed)
|
|
58
|
+
continue;
|
|
59
|
+
if (TOC_DOT_LINE_RE.test(trimmed)) {
|
|
60
|
+
dotLines += 1;
|
|
61
|
+
end = j + 1;
|
|
62
|
+
lastWasDotLine = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// 目录区内的章节标题行:短行、非表格、非列表、非「第X章 长正文」形态、非句末标点结尾的正文句;
|
|
66
|
+
// 仅当下一行仍是点线行时才继续收集(标题行夹在点线行之间的排版),否则视为正文标题结束区段——
|
|
67
|
+
// 目录末行与正文首行同为「第X章 XXX」文本时无法靠行内容区分,只能靠点线上下文
|
|
68
|
+
const nextTrimmed = (lines[j + 1] ?? '').trim();
|
|
69
|
+
const looksLikeTocTitle = trimmed.length < 50 && !trimmed.includes('|') && !/^[-*•·]\s/u.test(trimmed) && !/^第[一二三四五六七八九十百\d]+[章节部分][::]/u.test(trimmed) && !/[。;;!?!?]$/u.test(trimmed);
|
|
70
|
+
if (looksLikeTocTitle && lastWasDotLine && TOC_DOT_LINE_RE.test(nextTrimmed)) {
|
|
71
|
+
end = j + 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
const count = end - start;
|
|
77
|
+
return { end, dotLineRatio: count > 0 ? dotLines / count : 0 };
|
|
78
|
+
}
|
|
79
|
+
/** 招标文件:投标文件格式模板段标题(投标函/身份证明/授权书等格式模板) */
|
|
80
|
+
const TENDER_FORMAT_TITLE_RE = /投标函(格式)?$|法定代表人(身份)?证明(书)?(格式)?$|授权委托书(格式)?$|投标保证金.*格式$|资格审查资料.*格式$|近年.*情况.*格式$/u;
|
|
81
|
+
/** 招标文件:段内盖章/签字/日期填空标记(格式模板段证据) */
|
|
82
|
+
const TENDER_FORMAT_SIGN_RE = /(盖[章章]*)|(签[字名])|(公[章章]*)|(日期)|(盖章)|盖单位章|签字或盖章/u;
|
|
83
|
+
/**
|
|
84
|
+
* 投标文件格式模板段探测:标题行匹配格式模板特征后,向后收集段内容(至空行分隔段落结束),
|
|
85
|
+
* 段内含盖章/签字标记才算模板段(标题 + 盖章标记双重证据)。
|
|
86
|
+
*/
|
|
87
|
+
function collectTenderFormatBlock(lines, start) {
|
|
88
|
+
if (!TENDER_FORMAT_TITLE_RE.test(lines[start].trim()))
|
|
89
|
+
return start;
|
|
90
|
+
let end = start + 1;
|
|
91
|
+
let hasSign = false;
|
|
92
|
+
for (let j = start + 1; j < lines.length; j += 1) {
|
|
93
|
+
const trimmed = lines[j].trim();
|
|
94
|
+
if (TENDER_FORMAT_SIGN_RE.test(lines[j]))
|
|
95
|
+
hasSign = true;
|
|
96
|
+
if (!trimmed) {
|
|
97
|
+
end = j + 1;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
// 下一个章节标题行(「第X章/条」或中文序号)视为新段起点;纯数字序号("1. xxx")在
|
|
101
|
+
// 格式模板段内是常见的填空条目,不中断段收集
|
|
102
|
+
if (j > start + 1 && /^(第[一二三四五六七八九十百\d]+[章节条部分]|[一二三四五六七八九十]+[、..])/u.test(trimmed) && trimmed.length < 30)
|
|
103
|
+
break;
|
|
104
|
+
end = j + 1;
|
|
105
|
+
}
|
|
106
|
+
return hasSign ? end : start;
|
|
107
|
+
}
|
|
108
|
+
/** 招标文件:泛化引用行("详见投标人须知前附表"类短行,无实质项目信息;后缀短语可选) */
|
|
109
|
+
const TENDER_GENERIC_LINE_RE = /^(详见|具体见|以|按).{0,30}?(前附表|招标文件|招标公告|补疑|澄清|清单|图纸|合同文件)((的)?(要求|规定|为准|执行))?[。;;]?$|^投标人应(仔细|认真)(阅读|核对)(招标文件|本项目)(的全部内容)?[。;;]?$|^未(按|依照|按照)(上述|本|招标文件)要求/u;
|
|
110
|
+
/** 补疑文件:零信息回复行("答:按招标文件执行"类套话;含数字/单位的实质回复不删) */
|
|
111
|
+
const CLARIFICATION_BOILERPLATE_RE = /^(?:(?:答|答复|回复)[::]\s*)?(?:按|以)(招标文件|招标公告|合同文件|清单|图纸|补疑|答疑)?(约定|规定|要求|执行|为准)[。;;]?$|^(?:(?:答|答复|回复)[::]\s*)?不(作|做|予)(调整|修改|变更)[。;;]?$|^(?:(?:答|答复|回复)[::]\s*)?维持(不变|原状)[。;;]?$|^(?:(?:答|答复|回复)[::]\s*)?(本项|此项)(无|没有)(调整|修改|变化)[。;;]?$/u;
|
|
112
|
+
/** 图纸:无汉字纯坐标数字行(≥2 组相邻 "数字,数字" 坐标对即 ≥3 个连续坐标值,设计坐标不写入施组正文) */
|
|
113
|
+
function isCoordinateNoiseLine(trimmed) {
|
|
114
|
+
if (/[\p{Script=Han}]/u.test(trimmed))
|
|
115
|
+
return false;
|
|
116
|
+
const coordMatches = trimmed.match(/-?\d+(?:\.\d+)?\s*[,,]\s*-?\d+(?:\.\d+)?/gu) ?? [];
|
|
117
|
+
return coordMatches.length >= 2;
|
|
118
|
+
}
|
|
119
|
+
/** 图纸:图框标题栏关键词(图号/比例/日期/签名列) */
|
|
120
|
+
const CAD_TITLE_BLOCK_KEY_RE = /^(图号|图别|比例|日期|阶段|版次|设计|制图|审核|校对|审定|复核)/u;
|
|
121
|
+
/** 图框行负向词:含这些词的行是实质内容(设计说明/设计要求/审核意见等),不是图框信息 */
|
|
122
|
+
const CAD_TITLE_BLOCK_GUARD_RE = /说明|要求|依据|变更|图纸|内容|标准|规范|标高|强度|参数|意见|结论|目录|名称|负责人|设计人|审核人/u;
|
|
123
|
+
/**
|
|
124
|
+
* 图纸:图框标题栏信息行("图号 J-01"、"比例 1:100"、"设计 王某某" 类签名/标注行)。
|
|
125
|
+
* 标题 + 负向词双重判定:短行 + 图框关键词开头 + 不含实质内容词。
|
|
126
|
+
*/
|
|
127
|
+
function isCadTitleBlockLine(trimmed) {
|
|
128
|
+
if (trimmed.length > 30 || trimmed.length < 3)
|
|
129
|
+
return false;
|
|
130
|
+
if (!CAD_TITLE_BLOCK_KEY_RE.test(trimmed))
|
|
131
|
+
return false;
|
|
132
|
+
if (CAD_TITLE_BLOCK_GUARD_RE.test(trimmed))
|
|
133
|
+
return false;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
/** 图纸:CAD 转换产物的图元属性行(图层/颜色/线型/块名等,与施组编制无关) */
|
|
137
|
+
const CAD_ATTRIBUTE_LINE_RE = /^(图层|颜色|线型|线宽|块名|文字样式|标注样式|打印样式|线型比例)\s*[::=]?\s*\S{0,30}$/u;
|
|
138
|
+
/** 图纸:CAD 属性行判定(属性关键词 + 单行短值;含「说明/图例」的行是图例内容,保留) */
|
|
139
|
+
function isCadAttributeLine(trimmed) {
|
|
140
|
+
if (trimmed.length > 40)
|
|
141
|
+
return false;
|
|
142
|
+
if (!CAD_ATTRIBUTE_LINE_RE.test(trimmed))
|
|
143
|
+
return false;
|
|
144
|
+
if (/说明|图例|表/u.test(trimmed))
|
|
145
|
+
return false;
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
/** 合同通用条款章节标题(GF 系列示范文本的标准通用条款,不含项目数据) */
|
|
149
|
+
const CONTRACT_GENERAL_TITLE_RE = /通用合同条款|合同通用条款/u;
|
|
150
|
+
/** 专用合同条款标题:项目数据所在章节,必须保留,同时作为通用条款章节的边界 */
|
|
151
|
+
const CONTRACT_SPECIAL_TITLE_RE = /专用合同条款|合同专用条款/u;
|
|
152
|
+
/** 通用条款章节最小规模证据:行数 ≥50 或字符数 ≥2000(防误删同名短段) */
|
|
153
|
+
const CONTRACT_CHAPTER_MIN_LINES = 50;
|
|
154
|
+
const CONTRACT_CHAPTER_MIN_CHARS = 2000;
|
|
155
|
+
/** 招标公告程序段标题:文件获取/递交/开标等程序性小节(何时何地操作,与施组编制无关) */
|
|
156
|
+
const TENDER_PROCEDURE_TITLE_RE = /招标文件的获取|投标文件的递交|投标文件递交|开标(时间|地点|方式)|投标截止/u;
|
|
157
|
+
/** 程序段内容证据:段内含时间/日期/时分或「获取/递交/截止/开标 + 数字」才判定为程序段 */
|
|
158
|
+
const PROCEDURE_TIME_HINT_RE = /\d{4}\s*年|\d{1,2}\s*月\s*\d{1,2}\s*日|\d{1,2}[::]\d{2}|(获取|递交|截止|开标|地点).{0,20}\d/u;
|
|
159
|
+
/** 评标商务评审段标题:报价/商务评分细则(技术标不涉及报价,用不到) */
|
|
160
|
+
const BUSINESS_REVIEW_TITLE_RE = /商务(标)?(部分)?评审|报价评审|投标报价评审|商务部分|报价得分/u;
|
|
161
|
+
/** 技术评审线索:段内出现技术评审/施组相关关键词则不删(评标办法混合排版时商务与技术相连,宁多勿丢) */
|
|
162
|
+
const TECH_REVIEW_HINT_RE = /施工组织设计|技术(标)?(部分)?评审|技术部分|技术标/u;
|
|
163
|
+
/** 新「第X部分/编」标题(合同通用条款章节边界;通用条款标题本身不触发) */
|
|
164
|
+
const PART_TITLE_RE = /^第[一二三四五六七八九十百\d]+[部分编]/u;
|
|
165
|
+
/** 清单:纯报价表格段标题(费用汇总/暂估/规费/税金/计日工——纯商务数据;
|
|
166
|
+
* 分部分项工程量清单的名称/特征/工程量是施组核心数据,不在此列,绝不删) */
|
|
167
|
+
const BILL_PURE_PRICING_TITLE_RE = /(单位工程)?费汇总表|其他项目清单(与计价表)?|规费|税金(项目)?|暂估(单)?价(一览表)?|暂列金额|计日工|总价措施项目/u;
|
|
168
|
+
/** 清单:段内金额行证据(单价/合价数字 + 元;≥2 行确认是报价数据区段) */
|
|
169
|
+
const BILL_PRICE_ROW_RE = /\d[\d,]*(?:\.\d+)?\s*元/u;
|
|
170
|
+
/** 清单:扉页签章证据(造价工程师签字/执业印章/编制单位——扉页程序性信息) */
|
|
171
|
+
const BILL_TITLE_PAGE_SIGN_RE = /造价(工程师)?|执业(印章)?|签章|编制单位|审核人|法定代表人/u;
|
|
172
|
+
/**
|
|
173
|
+
* 章节级边界收集(合同通用条款):从标题行向后收集至「专用合同条款」或下一个「第X部分/编」
|
|
174
|
+
* 标题为止,返回区段结束位置与累计字符数(规模证据由调用方判定)。
|
|
175
|
+
*/
|
|
176
|
+
function collectChapterRegion(lines, start) {
|
|
177
|
+
let end = start + 1;
|
|
178
|
+
let chars = lines[start].length;
|
|
179
|
+
const hardLimit = Math.min(lines.length, start + 1500);
|
|
180
|
+
for (let j = start + 1; j < hardLimit; j += 1) {
|
|
181
|
+
const trimmed = lines[j].trim();
|
|
182
|
+
if (CONTRACT_SPECIAL_TITLE_RE.test(trimmed) && trimmed.length < 30)
|
|
183
|
+
break;
|
|
184
|
+
if (j > start + 1 && PART_TITLE_RE.test(trimmed) && trimmed.length < 40 && !CONTRACT_GENERAL_TITLE_RE.test(trimmed))
|
|
185
|
+
break;
|
|
186
|
+
end = j + 1;
|
|
187
|
+
chars += lines[j].length;
|
|
188
|
+
}
|
|
189
|
+
return { end, chars };
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 段落级边界收集(公告程序段/商务评审段):从标题行向后收集至空行段落结束或下一个
|
|
193
|
+
* 「第X章/条」/中文序号标题;纯数字序号("4.1 获取时间")是段内条目不中断。
|
|
194
|
+
* 返回区段结束位置与段内容(证据判定由调用方执行)。
|
|
195
|
+
*/
|
|
196
|
+
function collectParagraphRegion(lines, start, maxLines = 60) {
|
|
197
|
+
let end = start + 1;
|
|
198
|
+
const parts = [];
|
|
199
|
+
for (let j = start + 1; j < Math.min(lines.length, start + maxLines); j += 1) {
|
|
200
|
+
const trimmed = lines[j].trim();
|
|
201
|
+
if (!trimmed) {
|
|
202
|
+
end = j + 1;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
if (j > start + 1 && /^(第[一二三四五六七八九十百\d]+[章节条部分]|[一二三四五六七八九十]+[、..])/u.test(trimmed) && trimmed.length < 30)
|
|
206
|
+
break;
|
|
207
|
+
end = j + 1;
|
|
208
|
+
parts.push(lines[j]);
|
|
209
|
+
}
|
|
210
|
+
return { end, body: parts.join('\n') };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* K2 预扫描:整篇文档一次遍历,产出「起始行 → 待删区段」映射。
|
|
214
|
+
* 三条规则均要求「标题模式 + 内容/规模证据」双重确认:
|
|
215
|
+
* 1. 合同通用条款章节(不限文件类型):标题匹配 + 章节规模 ≥50 行或 ≥2000 字符,
|
|
216
|
+
* 且区段止于「专用合同条款」——项目专属数据(专用条款)绝不吞并;
|
|
217
|
+
* 2. 招标公告程序段(仅招标文件):标题匹配 + 段内含时间/地点数字证据;
|
|
218
|
+
* 3. 评标商务评审细则段(仅招标文件):标题匹配 + 段内无技术评审/施组关键词
|
|
219
|
+
* (技术评审评分细则是施组生成的得分点依据,绝不能删)。
|
|
220
|
+
*/
|
|
221
|
+
function collectSectionLevelNoise(lines, docKind) {
|
|
222
|
+
const drops = new Map();
|
|
223
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
224
|
+
const trimmed = lines[i].trim();
|
|
225
|
+
if (!trimmed || trimmed.length > 40)
|
|
226
|
+
continue;
|
|
227
|
+
// 1. 合同通用条款章节(通用适用)
|
|
228
|
+
if (CONTRACT_GENERAL_TITLE_RE.test(trimmed)) {
|
|
229
|
+
const region = collectChapterRegion(lines, i);
|
|
230
|
+
if (region.end - i >= CONTRACT_CHAPTER_MIN_LINES || region.chars >= CONTRACT_CHAPTER_MIN_CHARS) {
|
|
231
|
+
drops.set(i, { kind: 'contractGeneralClauseLines', end: region.end });
|
|
232
|
+
// -1:让循环 ++ 后落在区段末行的下一行,继续检查该行标题(相邻区段不互跳)
|
|
233
|
+
i = region.end - 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (docKind === 'tender') {
|
|
238
|
+
// 2. 招标公告程序段
|
|
239
|
+
if (TENDER_PROCEDURE_TITLE_RE.test(trimmed)) {
|
|
240
|
+
const region = collectParagraphRegion(lines, i);
|
|
241
|
+
if (region.end > i + 1 && PROCEDURE_TIME_HINT_RE.test(region.body)) {
|
|
242
|
+
drops.set(i, { kind: 'announcementProcedureLines', end: region.end });
|
|
243
|
+
i = region.end - 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// 3. 评标商务评审细则段
|
|
248
|
+
if (BUSINESS_REVIEW_TITLE_RE.test(trimmed)) {
|
|
249
|
+
const region = collectParagraphRegion(lines, i);
|
|
250
|
+
if (region.end > i + 1 && !TECH_REVIEW_HINT_RE.test(region.body)) {
|
|
251
|
+
drops.set(i, { kind: 'businessReviewLines', end: region.end });
|
|
252
|
+
i = region.end - 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// 4. 清单纯报价表格段(清单文件或招标文件内的清单章节):
|
|
257
|
+
// 标题 + 段内金额行 ≥2 双重证据(费汇总/暂估/规费/税金是纯商务数据;
|
|
258
|
+
// 分部分项清单的名称/特征/工程量不在标题列,绝不误删)
|
|
259
|
+
if ((docKind === 'bill' || docKind === 'tender') && BILL_PURE_PRICING_TITLE_RE.test(trimmed)) {
|
|
260
|
+
const region = collectParagraphRegion(lines, i, 100);
|
|
261
|
+
const priceRows = region.body.split('\n').filter(line => BILL_PRICE_ROW_RE.test(line)).length;
|
|
262
|
+
if (region.end > i + 1 && priceRows >= 2) {
|
|
263
|
+
drops.set(i, { kind: 'billPricingLines', end: region.end });
|
|
264
|
+
i = region.end - 1;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// 5. 清单扉页签章段(仅清单文件):工程量清单标题 + 造价签章证据
|
|
269
|
+
if (docKind === 'bill' && /^工程量清单(扉页|封面)?$/u.test(trimmed)) {
|
|
270
|
+
const region = collectParagraphRegion(lines, i, 40);
|
|
271
|
+
if (region.end > i + 1 && BILL_TITLE_PAGE_SIGN_RE.test(region.body)) {
|
|
272
|
+
drops.set(i, { kind: 'billTitlePageLines', end: region.end });
|
|
273
|
+
i = region.end - 1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return drops;
|
|
278
|
+
}
|
|
279
|
+
/** 入库前文本清洗主入口:行级保守清洗 + 目录/格式段级清洗 + 30% 整体回退保护 */
|
|
280
|
+
export function cleanExtractedText(input) {
|
|
281
|
+
const enabled = input.enabled ?? process.env.KB_TEXT_CLEANING !== '0';
|
|
282
|
+
if (!enabled || !input.text)
|
|
283
|
+
return { text: input.text, removedChars: 0, removedLines: 0, stats: zeroStats() };
|
|
284
|
+
const docKind = detectDocKind(input.fileName || '', input.text.slice(0, 4000));
|
|
285
|
+
const isCad = input.category === 'cad' || input.category === 'diagram';
|
|
286
|
+
const lines = input.text.split(/\r?\n/u);
|
|
287
|
+
const totalLines = lines.length;
|
|
288
|
+
// 全文高重复行统计(页眉/页脚/图框标题栏判定依据)
|
|
289
|
+
const lineCounts = new Map();
|
|
290
|
+
for (const line of lines) {
|
|
291
|
+
const key = line.trim();
|
|
292
|
+
if (!key)
|
|
293
|
+
continue;
|
|
294
|
+
lineCounts.set(key, (lineCounts.get(key) ?? 0) + 1);
|
|
295
|
+
}
|
|
296
|
+
const stats = zeroStats();
|
|
297
|
+
const kept = [];
|
|
298
|
+
let removedLines = 0;
|
|
299
|
+
let removedChars = 0;
|
|
300
|
+
let sectionRemovedChars = 0;
|
|
301
|
+
const drop = (line, counter) => {
|
|
302
|
+
removedLines += 1;
|
|
303
|
+
removedChars += line.length;
|
|
304
|
+
counter(stats);
|
|
305
|
+
};
|
|
306
|
+
// K2 章节/段落级预扫描:整篇文档一次遍历得出待删区段映射(主循环优先消费)
|
|
307
|
+
const sectionDrops = collectSectionLevelNoise(lines, docKind);
|
|
308
|
+
let index = 0;
|
|
309
|
+
while (index < lines.length) {
|
|
310
|
+
const line = lines[index];
|
|
311
|
+
const trimmed = line.trim();
|
|
312
|
+
// 0. K2 内容无关区段(章节/段落级整段删除,最高优先级,30% 回退保护兜底)
|
|
313
|
+
const sectionDrop = sectionDrops.get(index);
|
|
314
|
+
if (sectionDrop) {
|
|
315
|
+
for (let j = index; j < sectionDrop.end; j += 1) {
|
|
316
|
+
drop(lines[j], s => { s[sectionDrop.kind] += 1; });
|
|
317
|
+
sectionRemovedChars += lines[j].length;
|
|
318
|
+
}
|
|
319
|
+
index = sectionDrop.end;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
// 1. 目录区段(「目录」标题行 + 点线占比 ≥50% + 行数 ≥4 三重证据,整区删除)
|
|
323
|
+
if (TOC_TITLE_RE.test(trimmed)) {
|
|
324
|
+
const region = collectTocRegion(lines, index);
|
|
325
|
+
const count = region.end - index;
|
|
326
|
+
if (count >= 4 && region.dotLineRatio >= 0.5) {
|
|
327
|
+
for (let j = index; j < region.end; j += 1)
|
|
328
|
+
drop(lines[j], s => { s.tocRegionLines += 1; });
|
|
329
|
+
index = region.end;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// 2. 连续空行压缩:≥3 连续空行保留 2 个(单空行是段落分隔,留给分块器)
|
|
334
|
+
if (!trimmed) {
|
|
335
|
+
let run = 1;
|
|
336
|
+
while (index + run < lines.length && !lines[index + run].trim())
|
|
337
|
+
run += 1;
|
|
338
|
+
const keepCount = Math.min(run, 2);
|
|
339
|
+
for (let k = 0; k < keepCount; k += 1)
|
|
340
|
+
kept.push('');
|
|
341
|
+
const dropped = run - keepCount;
|
|
342
|
+
if (dropped > 0) {
|
|
343
|
+
removedLines += dropped;
|
|
344
|
+
stats.blankLines += dropped;
|
|
345
|
+
}
|
|
346
|
+
index += run;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
// 3. 页眉/页脚/图框标题栏高重复行
|
|
350
|
+
if (isHeaderFooterLine(trimmed, lineCounts.get(trimmed) ?? 1)) {
|
|
351
|
+
drop(line, s => { s.headerFooterLines += 1; });
|
|
352
|
+
index += 1;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// 4. 纯页码行
|
|
356
|
+
if (isPageNumberLine(trimmed, totalLines)) {
|
|
357
|
+
drop(line, s => { s.pageNumberLines += 1; });
|
|
358
|
+
index += 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
// 5. 招标文件:投标文件格式模板段(段级)
|
|
362
|
+
if (docKind === 'tender') {
|
|
363
|
+
const blockEnd = collectTenderFormatBlock(lines, index);
|
|
364
|
+
if (blockEnd > index) {
|
|
365
|
+
for (let j = index; j < blockEnd; j += 1)
|
|
366
|
+
drop(lines[j], s => { s.tenderFormatLines += 1; });
|
|
367
|
+
index = blockEnd;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
// 招标文件:泛化引用行(行级)
|
|
371
|
+
if (trimmed.length <= 60 && TENDER_GENERIC_LINE_RE.test(trimmed)) {
|
|
372
|
+
drop(line, s => { s.tenderGenericLines += 1; });
|
|
373
|
+
index += 1;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// 6. 补疑文件:零信息回复行(行级,长度 ≤25 且无数字/单位)
|
|
378
|
+
if (docKind === 'clarification' && trimmed.length <= 25 && CLARIFICATION_BOILERPLATE_RE.test(trimmed) && !/\d/u.test(trimmed)) {
|
|
379
|
+
drop(line, s => { s.clarificationLines += 1; });
|
|
380
|
+
index += 1;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
// 7. 图纸:无汉字纯坐标数字行
|
|
384
|
+
if (isCad && isCoordinateNoiseLine(trimmed)) {
|
|
385
|
+
drop(line, s => { s.cadNoiseLines += 1; });
|
|
386
|
+
index += 1;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
// 8. 图纸:图框标题栏信息行(图号/比例/日期/签名行)
|
|
390
|
+
if (isCad && isCadTitleBlockLine(trimmed)) {
|
|
391
|
+
drop(line, s => { s.cadTitleBlockLines += 1; });
|
|
392
|
+
index += 1;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
// 9. 图纸:CAD 属性行(图层/颜色/线型)
|
|
396
|
+
if (isCad && isCadAttributeLine(trimmed)) {
|
|
397
|
+
drop(line, s => { s.cadAttributeLines += 1; });
|
|
398
|
+
index += 1;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
kept.push(line);
|
|
402
|
+
index += 1;
|
|
403
|
+
}
|
|
404
|
+
const resultText = kept.join('\n');
|
|
405
|
+
// 整体回退保护(宁多勿丢):
|
|
406
|
+
// 1) 行级启发式删除导致剩余不足原文 30% → 判定误伤,回退原文;
|
|
407
|
+
// 2) K2 章节级删除(标题 + 规模/内容 + 边界三重证据)置信度高,不参与 30% 判定,
|
|
408
|
+
// 否则通用条款占比高的合同文件永远洗不掉;但剩余不足原文 10% 时仍整体回退(防极端全删)
|
|
409
|
+
const totalChars = input.text.trim().length;
|
|
410
|
+
const remainingRatio = totalChars > 0 ? resultText.trim().length / totalChars : 1;
|
|
411
|
+
const lineLevelRemovedChars = removedChars - sectionRemovedChars;
|
|
412
|
+
const lineLevelSafe = lineLevelRemovedChars === 0 || remainingRatio >= 0.3;
|
|
413
|
+
const extremeSafe = remainingRatio >= 0.1;
|
|
414
|
+
if (removedChars > 0 && totalChars > 2000 && (!lineLevelSafe || !extremeSafe)) {
|
|
415
|
+
return { text: input.text, removedChars: 0, removedLines: 0, stats: zeroStats() };
|
|
416
|
+
}
|
|
417
|
+
return { text: resultText, removedChars, removedLines, stats };
|
|
418
|
+
}
|
|
@@ -3,6 +3,7 @@ import * as os from 'node:os';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { TextChunker } from '../chunking/text-chunker.js';
|
|
5
5
|
import { FileClassifier } from '../classification/classifier.js';
|
|
6
|
+
import { cleanExtractedText } from '../cleaning/text-cleaner.js';
|
|
6
7
|
import { ALL_CATEGORIES, DEFAULT_CATEGORY_DIRS, GLOBAL_KNOWLEDGE_DIR, USER_DATA_DIR } from '../constants.js';
|
|
7
8
|
import { DedupEngine } from '../dedup/dedup-engine.js';
|
|
8
9
|
import { RelationshipDetector } from '../dedup/relationship-detector.js';
|
|
@@ -197,13 +198,37 @@ export class KnowledgeBaseManager {
|
|
|
197
198
|
});
|
|
198
199
|
continue;
|
|
199
200
|
}
|
|
200
|
-
|
|
201
|
+
// K1 入库前清洗(源头治理):解析完成后、分块入库前移除确定性噪声(页眉页脚/目录/页码/
|
|
202
|
+
// 招标格式模板段/补疑套话/图纸坐标行),入库的即干净数据;保守策略(多重证据 + 30% 回退保护),
|
|
203
|
+
// 清洗统计写入 warnings 供索引记录追溯;KB_TEXT_CLEANING=0 关闭清洗
|
|
204
|
+
const cleaning = cleanExtractedText({ text: extraction.text, category: file.category, fileName: file.relativePath });
|
|
205
|
+
const cleanedText = cleaning.text.trim().length > 0 ? cleaning.text : extraction.text;
|
|
206
|
+
if (cleaning.removedChars > 0) {
|
|
207
|
+
const cleaningDetail = [
|
|
208
|
+
cleaning.stats.headerFooterLines > 0 ? `页眉页脚 ${cleaning.stats.headerFooterLines}` : '',
|
|
209
|
+
cleaning.stats.tocRegionLines > 0 ? `目录 ${cleaning.stats.tocRegionLines}` : '',
|
|
210
|
+
cleaning.stats.pageNumberLines > 0 ? `页码 ${cleaning.stats.pageNumberLines}` : '',
|
|
211
|
+
cleaning.stats.tenderFormatLines > 0 ? `招标格式 ${cleaning.stats.tenderFormatLines}` : '',
|
|
212
|
+
cleaning.stats.tenderGenericLines > 0 ? `泛化引用 ${cleaning.stats.tenderGenericLines}` : '',
|
|
213
|
+
cleaning.stats.clarificationLines > 0 ? `补疑套话 ${cleaning.stats.clarificationLines}` : '',
|
|
214
|
+
cleaning.stats.cadNoiseLines > 0 ? `图纸坐标 ${cleaning.stats.cadNoiseLines}` : '',
|
|
215
|
+
cleaning.stats.contractGeneralClauseLines > 0 ? `合同通用条款 ${cleaning.stats.contractGeneralClauseLines}` : '',
|
|
216
|
+
cleaning.stats.announcementProcedureLines > 0 ? `公告程序段 ${cleaning.stats.announcementProcedureLines}` : '',
|
|
217
|
+
cleaning.stats.businessReviewLines > 0 ? `商务评审细则 ${cleaning.stats.businessReviewLines}` : '',
|
|
218
|
+
cleaning.stats.cadTitleBlockLines > 0 ? `图框信息 ${cleaning.stats.cadTitleBlockLines}` : '',
|
|
219
|
+
cleaning.stats.cadAttributeLines > 0 ? `CAD属性 ${cleaning.stats.cadAttributeLines}` : '',
|
|
220
|
+
cleaning.stats.billPricingLines > 0 ? `清单报价表 ${cleaning.stats.billPricingLines}` : '',
|
|
221
|
+
cleaning.stats.billTitlePageLines > 0 ? `清单扉页 ${cleaning.stats.billTitlePageLines}` : '',
|
|
222
|
+
].filter(Boolean).join('、');
|
|
223
|
+
extraction.warnings.push(`入库清洗移除噪声 ${cleaning.removedLines} 行(${cleaningDetail})`);
|
|
224
|
+
}
|
|
225
|
+
const normalizedHash = this.dedup.normalizedHash(cleanedText);
|
|
201
226
|
const normalizedDuplicate = !duplicate && normalizedHash
|
|
202
227
|
? this.store.findNormalizedDuplicate(normalizedHash, file.relativePath)
|
|
203
228
|
: undefined;
|
|
204
229
|
this.updateJobsForFile(file.relativePath, 'CHUNKING', Math.min(80, basePercent + 10), `正在切片 ${file.relativePath}`);
|
|
205
230
|
this.reportProgress({ stage: 'chunking', percent: Math.min(80, basePercent + 10), message: `正在切片 ${file.relativePath}`, filePath: file.relativePath });
|
|
206
|
-
const chunks = this.chunker.chunk(
|
|
231
|
+
const chunks = this.chunker.chunk(cleanedText, file, extraction.metadata);
|
|
207
232
|
this.reportProgress({ stage: 'chunking', percent: Math.min(84, basePercent + 14), message: `切片完成:${chunks.length} 块`, filePath: file.relativePath, chunkCount: chunks.length });
|
|
208
233
|
this.store.upsertFileHash({
|
|
209
234
|
contentHash: hash,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { TextChunker, type ChunkConfig, type TextChunk } from './chunking/text-chunker.js';
|
|
2
2
|
export { FileClassifier } from './classification/classifier.js';
|
|
3
|
+
export { cleanExtractedText, type TextCleaningInput, type TextCleaningResult, type TextCleanStats } from './cleaning/text-cleaner.js';
|
|
3
4
|
export { DedupEngine, type MinHashSignature, type SimilarityMatch } from './dedup/dedup-engine.js';
|
|
4
5
|
export { RelationshipDetector } from './dedup/relationship-detector.js';
|
|
5
6
|
export { HashEmbeddingProvider, LocalTransformersEmbeddingProvider, OpenAICompatibleEmbeddingProvider, createEmbeddingProviderFromEnvironment, type EmbeddingProvider, type LocalTransformersEmbeddingOptions, type OpenAICompatibleEmbeddingOptions } from './embedding/embedding-provider.js';
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// 它们仅在 knowledge 包内部使用。
|
|
3
3
|
export { TextChunker } from './chunking/text-chunker.js';
|
|
4
4
|
export { FileClassifier } from './classification/classifier.js';
|
|
5
|
+
export { cleanExtractedText } from './cleaning/text-cleaner.js';
|
|
5
6
|
export { DedupEngine } from './dedup/dedup-engine.js';
|
|
6
7
|
export { RelationshipDetector } from './dedup/relationship-detector.js';
|
|
7
8
|
export { HashEmbeddingProvider, LocalTransformersEmbeddingProvider, OpenAICompatibleEmbeddingProvider, createEmbeddingProviderFromEnvironment } from './embedding/embedding-provider.js';
|