@customize-agent/knowledge 4.1.1 → 4.1.3
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,58 @@
|
|
|
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
|
+
*
|
|
14
|
+
* 保守策略(宁多勿丢红线):每条规则都要求「标题模式 + 内容特征/规模」多重证据,
|
|
15
|
+
* 只删高置信噪声;清洗后文本若不足原文 30% 整体回退;KB_TEXT_CLEANING=0 关闭清洗。
|
|
16
|
+
*/
|
|
17
|
+
export interface TextCleanStats {
|
|
18
|
+
/** 页眉/页脚/图框标题栏高重复行数 */
|
|
19
|
+
headerFooterLines: number;
|
|
20
|
+
/** 纯页码行数 */
|
|
21
|
+
pageNumberLines: number;
|
|
22
|
+
/** 目录区段行数 */
|
|
23
|
+
tocRegionLines: number;
|
|
24
|
+
/** 压缩掉的连续空行数 */
|
|
25
|
+
blankLines: number;
|
|
26
|
+
/** 招标文件:投标文件格式模板段行数 */
|
|
27
|
+
tenderFormatLines: number;
|
|
28
|
+
/** 招标文件:泛化引用行数 */
|
|
29
|
+
tenderGenericLines: number;
|
|
30
|
+
/** 补疑文件:零信息回复行数 */
|
|
31
|
+
clarificationLines: number;
|
|
32
|
+
/** 图纸:无汉字纯坐标行数 */
|
|
33
|
+
cadNoiseLines: number;
|
|
34
|
+
/** 合同通用条款章节行数(K2 内容无关) */
|
|
35
|
+
contractGeneralClauseLines: number;
|
|
36
|
+
/** 招标公告程序段行数(K2 内容无关) */
|
|
37
|
+
announcementProcedureLines: number;
|
|
38
|
+
/** 评标商务评审细则段行数(K2 内容无关) */
|
|
39
|
+
businessReviewLines: number;
|
|
40
|
+
}
|
|
41
|
+
export interface TextCleaningResult {
|
|
42
|
+
/** 清洗后的文本(30% 回退保护下可能等于原文) */
|
|
43
|
+
text: string;
|
|
44
|
+
removedLines: number;
|
|
45
|
+
removedChars: number;
|
|
46
|
+
stats: TextCleanStats;
|
|
47
|
+
}
|
|
48
|
+
export interface TextCleaningInput {
|
|
49
|
+
text: string;
|
|
50
|
+
/** 文件分类(classifier 的 FileCategory),用于选择清洗规则组 */
|
|
51
|
+
category?: FileCategory | string;
|
|
52
|
+
/** 文件名/相对路径(招标/补疑文档类型判定依据) */
|
|
53
|
+
fileName?: string;
|
|
54
|
+
/** 显式开关;缺省读环境变量 KB_TEXT_CLEANING(=0 关闭) */
|
|
55
|
+
enabled?: boolean;
|
|
56
|
+
}
|
|
57
|
+
/** 入库前文本清洗主入口:行级保守清洗 + 目录/格式段级清洗 + 30% 整体回退保护 */
|
|
58
|
+
export declare function cleanExtractedText(input: TextCleaningInput): TextCleaningResult;
|
|
@@ -0,0 +1,346 @@
|
|
|
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
|
+
};
|
|
7
|
+
}
|
|
8
|
+
/** 文档类型判定:文件名 + 文首内容双重特征(招标文件 / 补疑答疑 / 其他) */
|
|
9
|
+
function detectDocKind(fileName, head) {
|
|
10
|
+
if (/(补疑|答疑|澄清|补遗|变更通知|招标答疑)/u.test(fileName) || /答疑纪要|澄清函|补遗文件|招标文件答疑/u.test(head))
|
|
11
|
+
return 'clarification';
|
|
12
|
+
if (/(招标文件|招标公告|招标书|邀请招标)/u.test(fileName) || /投标人须知|招标公告|投标邀请/u.test(head))
|
|
13
|
+
return 'tender';
|
|
14
|
+
return 'other';
|
|
15
|
+
}
|
|
16
|
+
/** 页眉/页脚/图框标题栏高重复行:全文出现 ≥3 次 + 长度 4-60 + 非表格行 + 非列表项 + 非表头 */
|
|
17
|
+
function isHeaderFooterLine(trimmed, count) {
|
|
18
|
+
if (count < 3)
|
|
19
|
+
return false;
|
|
20
|
+
if (trimmed.length < 4 || trimmed.length > 60)
|
|
21
|
+
return false;
|
|
22
|
+
if (trimmed.includes('|'))
|
|
23
|
+
return false;
|
|
24
|
+
if (/^[-*•·]\s|^\d+[、..]\s/u.test(trimmed))
|
|
25
|
+
return false;
|
|
26
|
+
// 表头保护:短行含表头关键词(纯文本格式清单/表格每页重复的表头)保留
|
|
27
|
+
if (trimmed.length < 12 && /序号|名称|单位|数量|金额|备注|编码/u.test(trimmed))
|
|
28
|
+
return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
/** 纯页码行:独立成行的纯数字/页码格式(小文件保护:行数 <20 不删,可能是数据文件) */
|
|
32
|
+
function isPageNumberLine(trimmed, totalLines) {
|
|
33
|
+
if (totalLines < 20)
|
|
34
|
+
return false;
|
|
35
|
+
return /^\d{1,4}$/u.test(trimmed)
|
|
36
|
+
|| /^第\s*\d{1,4}\s*页/u.test(trimmed)
|
|
37
|
+
|| /^\d{1,4}\s*\/\s*\d{1,4}$/u.test(trimmed)
|
|
38
|
+
|| /^[-–—]\s*\d{1,4}\s*[-–—]$/u.test(trimmed);
|
|
39
|
+
}
|
|
40
|
+
const TOC_DOT_LINE_RE = /^.{2,80}[.·…]{3,}\s*\d{1,4}\s*$/u;
|
|
41
|
+
const TOC_TITLE_RE = /^目\s*录\s*$/u;
|
|
42
|
+
/**
|
|
43
|
+
* 目录区段探测:从「目录」标题行开始,连续收集点线行("标题......页码")与短标题行,
|
|
44
|
+
* 遇到正文特征行(长行/表格行)停止。返回区段结束位置与点线行占比,由调用方按
|
|
45
|
+
* 「行数 ≥4 且点线占比 ≥50%」双重条件决定是否整区删除。
|
|
46
|
+
*/
|
|
47
|
+
function collectTocRegion(lines, start) {
|
|
48
|
+
let end = start + 1;
|
|
49
|
+
let dotLines = 0;
|
|
50
|
+
const hardLimit = Math.min(lines.length, start + 300);
|
|
51
|
+
let lastWasDotLine = false;
|
|
52
|
+
for (let j = start + 1; j < hardLimit; j += 1) {
|
|
53
|
+
const trimmed = lines[j].trim();
|
|
54
|
+
if (!trimmed)
|
|
55
|
+
continue;
|
|
56
|
+
if (TOC_DOT_LINE_RE.test(trimmed)) {
|
|
57
|
+
dotLines += 1;
|
|
58
|
+
end = j + 1;
|
|
59
|
+
lastWasDotLine = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// 目录区内的章节标题行:短行、非表格、非列表、非「第X章 长正文」形态、非句末标点结尾的正文句;
|
|
63
|
+
// 仅当下一行仍是点线行时才继续收集(标题行夹在点线行之间的排版),否则视为正文标题结束区段——
|
|
64
|
+
// 目录末行与正文首行同为「第X章 XXX」文本时无法靠行内容区分,只能靠点线上下文
|
|
65
|
+
const nextTrimmed = (lines[j + 1] ?? '').trim();
|
|
66
|
+
const looksLikeTocTitle = trimmed.length < 50 && !trimmed.includes('|') && !/^[-*•·]\s/u.test(trimmed) && !/^第[一二三四五六七八九十百\d]+[章节部分][::]/u.test(trimmed) && !/[。;;!?!?]$/u.test(trimmed);
|
|
67
|
+
if (looksLikeTocTitle && lastWasDotLine && TOC_DOT_LINE_RE.test(nextTrimmed)) {
|
|
68
|
+
end = j + 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
const count = end - start;
|
|
74
|
+
return { end, dotLineRatio: count > 0 ? dotLines / count : 0 };
|
|
75
|
+
}
|
|
76
|
+
/** 招标文件:投标文件格式模板段标题(投标函/身份证明/授权书等格式模板) */
|
|
77
|
+
const TENDER_FORMAT_TITLE_RE = /投标函(格式)?$|法定代表人(身份)?证明(书)?(格式)?$|授权委托书(格式)?$|投标保证金.*格式$|资格审查资料.*格式$|近年.*情况.*格式$/u;
|
|
78
|
+
/** 招标文件:段内盖章/签字/日期填空标记(格式模板段证据) */
|
|
79
|
+
const TENDER_FORMAT_SIGN_RE = /(盖[章章]*)|(签[字名])|(公[章章]*)|(日期)|(盖章)|盖单位章|签字或盖章/u;
|
|
80
|
+
/**
|
|
81
|
+
* 投标文件格式模板段探测:标题行匹配格式模板特征后,向后收集段内容(至空行分隔段落结束),
|
|
82
|
+
* 段内含盖章/签字标记才算模板段(标题 + 盖章标记双重证据)。
|
|
83
|
+
*/
|
|
84
|
+
function collectTenderFormatBlock(lines, start) {
|
|
85
|
+
if (!TENDER_FORMAT_TITLE_RE.test(lines[start].trim()))
|
|
86
|
+
return start;
|
|
87
|
+
let end = start + 1;
|
|
88
|
+
let hasSign = false;
|
|
89
|
+
for (let j = start + 1; j < lines.length; j += 1) {
|
|
90
|
+
const trimmed = lines[j].trim();
|
|
91
|
+
if (TENDER_FORMAT_SIGN_RE.test(lines[j]))
|
|
92
|
+
hasSign = true;
|
|
93
|
+
if (!trimmed) {
|
|
94
|
+
end = j + 1;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
// 下一个章节标题行(「第X章/条」或中文序号)视为新段起点;纯数字序号("1. xxx")在
|
|
98
|
+
// 格式模板段内是常见的填空条目,不中断段收集
|
|
99
|
+
if (j > start + 1 && /^(第[一二三四五六七八九十百\d]+[章节条部分]|[一二三四五六七八九十]+[、..])/u.test(trimmed) && trimmed.length < 30)
|
|
100
|
+
break;
|
|
101
|
+
end = j + 1;
|
|
102
|
+
}
|
|
103
|
+
return hasSign ? end : start;
|
|
104
|
+
}
|
|
105
|
+
/** 招标文件:泛化引用行("详见投标人须知前附表"类短行,无实质项目信息;后缀短语可选) */
|
|
106
|
+
const TENDER_GENERIC_LINE_RE = /^(详见|具体见|以|按).{0,30}?(前附表|招标文件|招标公告|补疑|澄清|清单|图纸|合同文件)((的)?(要求|规定|为准|执行))?[。;;]?$|^投标人应(仔细|认真)(阅读|核对)(招标文件|本项目)(的全部内容)?[。;;]?$|^未(按|依照|按照)(上述|本|招标文件)要求/u;
|
|
107
|
+
/** 补疑文件:零信息回复行("答:按招标文件执行"类套话;含数字/单位的实质回复不删) */
|
|
108
|
+
const CLARIFICATION_BOILERPLATE_RE = /^(?:(?:答|答复|回复)[::]\s*)?(?:按|以)(招标文件|招标公告|合同文件|清单|图纸|补疑|答疑)?(约定|规定|要求|执行|为准)[。;;]?$|^(?:(?:答|答复|回复)[::]\s*)?不(作|做|予)(调整|修改|变更)[。;;]?$|^(?:(?:答|答复|回复)[::]\s*)?维持(不变|原状)[。;;]?$|^(?:(?:答|答复|回复)[::]\s*)?(本项|此项)(无|没有)(调整|修改|变化)[。;;]?$/u;
|
|
109
|
+
/** 图纸:无汉字纯坐标数字行(≥2 组相邻 "数字,数字" 坐标对即 ≥3 个连续坐标值,设计坐标不写入施组正文) */
|
|
110
|
+
function isCoordinateNoiseLine(trimmed) {
|
|
111
|
+
if (/[\p{Script=Han}]/u.test(trimmed))
|
|
112
|
+
return false;
|
|
113
|
+
const coordMatches = trimmed.match(/-?\d+(?:\.\d+)?\s*[,,]\s*-?\d+(?:\.\d+)?/gu) ?? [];
|
|
114
|
+
return coordMatches.length >= 2;
|
|
115
|
+
}
|
|
116
|
+
/** 合同通用条款章节标题(GF 系列示范文本的标准通用条款,不含项目数据) */
|
|
117
|
+
const CONTRACT_GENERAL_TITLE_RE = /通用合同条款|合同通用条款/u;
|
|
118
|
+
/** 专用合同条款标题:项目数据所在章节,必须保留,同时作为通用条款章节的边界 */
|
|
119
|
+
const CONTRACT_SPECIAL_TITLE_RE = /专用合同条款|合同专用条款/u;
|
|
120
|
+
/** 通用条款章节最小规模证据:行数 ≥50 或字符数 ≥2000(防误删同名短段) */
|
|
121
|
+
const CONTRACT_CHAPTER_MIN_LINES = 50;
|
|
122
|
+
const CONTRACT_CHAPTER_MIN_CHARS = 2000;
|
|
123
|
+
/** 招标公告程序段标题:文件获取/递交/开标等程序性小节(何时何地操作,与施组编制无关) */
|
|
124
|
+
const TENDER_PROCEDURE_TITLE_RE = /招标文件的获取|投标文件的递交|投标文件递交|开标(时间|地点|方式)|投标截止/u;
|
|
125
|
+
/** 程序段内容证据:段内含时间/日期/时分或「获取/递交/截止/开标 + 数字」才判定为程序段 */
|
|
126
|
+
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;
|
|
127
|
+
/** 评标商务评审段标题:报价/商务评分细则(技术标不涉及报价,用不到) */
|
|
128
|
+
const BUSINESS_REVIEW_TITLE_RE = /商务(标)?(部分)?评审|报价评审|投标报价评审|商务部分|报价得分/u;
|
|
129
|
+
/** 技术评审线索:段内出现技术评审/施组相关关键词则不删(评标办法混合排版时商务与技术相连,宁多勿丢) */
|
|
130
|
+
const TECH_REVIEW_HINT_RE = /施工组织设计|技术(标)?(部分)?评审|技术部分|技术标/u;
|
|
131
|
+
/** 新「第X部分/编」标题(合同通用条款章节边界;通用条款标题本身不触发) */
|
|
132
|
+
const PART_TITLE_RE = /^第[一二三四五六七八九十百\d]+[部分编]/u;
|
|
133
|
+
/**
|
|
134
|
+
* 章节级边界收集(合同通用条款):从标题行向后收集至「专用合同条款」或下一个「第X部分/编」
|
|
135
|
+
* 标题为止,返回区段结束位置与累计字符数(规模证据由调用方判定)。
|
|
136
|
+
*/
|
|
137
|
+
function collectChapterRegion(lines, start) {
|
|
138
|
+
let end = start + 1;
|
|
139
|
+
let chars = lines[start].length;
|
|
140
|
+
const hardLimit = Math.min(lines.length, start + 1500);
|
|
141
|
+
for (let j = start + 1; j < hardLimit; j += 1) {
|
|
142
|
+
const trimmed = lines[j].trim();
|
|
143
|
+
if (CONTRACT_SPECIAL_TITLE_RE.test(trimmed) && trimmed.length < 30)
|
|
144
|
+
break;
|
|
145
|
+
if (j > start + 1 && PART_TITLE_RE.test(trimmed) && trimmed.length < 40 && !CONTRACT_GENERAL_TITLE_RE.test(trimmed))
|
|
146
|
+
break;
|
|
147
|
+
end = j + 1;
|
|
148
|
+
chars += lines[j].length;
|
|
149
|
+
}
|
|
150
|
+
return { end, chars };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 段落级边界收集(公告程序段/商务评审段):从标题行向后收集至空行段落结束或下一个
|
|
154
|
+
* 「第X章/条」/中文序号标题;纯数字序号("4.1 获取时间")是段内条目不中断。
|
|
155
|
+
* 返回区段结束位置与段内容(证据判定由调用方执行)。
|
|
156
|
+
*/
|
|
157
|
+
function collectParagraphRegion(lines, start, maxLines = 60) {
|
|
158
|
+
let end = start + 1;
|
|
159
|
+
const parts = [];
|
|
160
|
+
for (let j = start + 1; j < Math.min(lines.length, start + maxLines); j += 1) {
|
|
161
|
+
const trimmed = lines[j].trim();
|
|
162
|
+
if (!trimmed) {
|
|
163
|
+
end = j + 1;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
if (j > start + 1 && /^(第[一二三四五六七八九十百\d]+[章节条部分]|[一二三四五六七八九十]+[、..])/u.test(trimmed) && trimmed.length < 30)
|
|
167
|
+
break;
|
|
168
|
+
end = j + 1;
|
|
169
|
+
parts.push(lines[j]);
|
|
170
|
+
}
|
|
171
|
+
return { end, body: parts.join('\n') };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* K2 预扫描:整篇文档一次遍历,产出「起始行 → 待删区段」映射。
|
|
175
|
+
* 三条规则均要求「标题模式 + 内容/规模证据」双重确认:
|
|
176
|
+
* 1. 合同通用条款章节(不限文件类型):标题匹配 + 章节规模 ≥50 行或 ≥2000 字符,
|
|
177
|
+
* 且区段止于「专用合同条款」——项目专属数据(专用条款)绝不吞并;
|
|
178
|
+
* 2. 招标公告程序段(仅招标文件):标题匹配 + 段内含时间/地点数字证据;
|
|
179
|
+
* 3. 评标商务评审细则段(仅招标文件):标题匹配 + 段内无技术评审/施组关键词
|
|
180
|
+
* (技术评审评分细则是施组生成的得分点依据,绝不能删)。
|
|
181
|
+
*/
|
|
182
|
+
function collectSectionLevelNoise(lines, docKind) {
|
|
183
|
+
const drops = new Map();
|
|
184
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
185
|
+
const trimmed = lines[i].trim();
|
|
186
|
+
if (!trimmed || trimmed.length > 40)
|
|
187
|
+
continue;
|
|
188
|
+
// 1. 合同通用条款章节(通用适用)
|
|
189
|
+
if (CONTRACT_GENERAL_TITLE_RE.test(trimmed)) {
|
|
190
|
+
const region = collectChapterRegion(lines, i);
|
|
191
|
+
if (region.end - i >= CONTRACT_CHAPTER_MIN_LINES || region.chars >= CONTRACT_CHAPTER_MIN_CHARS) {
|
|
192
|
+
drops.set(i, { kind: 'contractGeneralClauseLines', end: region.end });
|
|
193
|
+
i = region.end;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (docKind !== 'tender')
|
|
198
|
+
continue;
|
|
199
|
+
// 2. 招标公告程序段
|
|
200
|
+
if (TENDER_PROCEDURE_TITLE_RE.test(trimmed)) {
|
|
201
|
+
const region = collectParagraphRegion(lines, i);
|
|
202
|
+
if (region.end > i + 1 && PROCEDURE_TIME_HINT_RE.test(region.body)) {
|
|
203
|
+
drops.set(i, { kind: 'announcementProcedureLines', end: region.end });
|
|
204
|
+
i = region.end;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// 3. 评标商务评审细则段
|
|
209
|
+
if (BUSINESS_REVIEW_TITLE_RE.test(trimmed)) {
|
|
210
|
+
const region = collectParagraphRegion(lines, i);
|
|
211
|
+
if (region.end > i + 1 && !TECH_REVIEW_HINT_RE.test(region.body)) {
|
|
212
|
+
drops.set(i, { kind: 'businessReviewLines', end: region.end });
|
|
213
|
+
i = region.end;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return drops;
|
|
218
|
+
}
|
|
219
|
+
/** 入库前文本清洗主入口:行级保守清洗 + 目录/格式段级清洗 + 30% 整体回退保护 */
|
|
220
|
+
export function cleanExtractedText(input) {
|
|
221
|
+
const enabled = input.enabled ?? process.env.KB_TEXT_CLEANING !== '0';
|
|
222
|
+
if (!enabled || !input.text)
|
|
223
|
+
return { text: input.text, removedChars: 0, removedLines: 0, stats: zeroStats() };
|
|
224
|
+
const docKind = detectDocKind(input.fileName || '', input.text.slice(0, 4000));
|
|
225
|
+
const isCad = input.category === 'cad' || input.category === 'diagram';
|
|
226
|
+
const lines = input.text.split(/\r?\n/u);
|
|
227
|
+
const totalLines = lines.length;
|
|
228
|
+
// 全文高重复行统计(页眉/页脚/图框标题栏判定依据)
|
|
229
|
+
const lineCounts = new Map();
|
|
230
|
+
for (const line of lines) {
|
|
231
|
+
const key = line.trim();
|
|
232
|
+
if (!key)
|
|
233
|
+
continue;
|
|
234
|
+
lineCounts.set(key, (lineCounts.get(key) ?? 0) + 1);
|
|
235
|
+
}
|
|
236
|
+
const stats = zeroStats();
|
|
237
|
+
const kept = [];
|
|
238
|
+
let removedLines = 0;
|
|
239
|
+
let removedChars = 0;
|
|
240
|
+
let sectionRemovedChars = 0;
|
|
241
|
+
const drop = (line, counter) => {
|
|
242
|
+
removedLines += 1;
|
|
243
|
+
removedChars += line.length;
|
|
244
|
+
counter(stats);
|
|
245
|
+
};
|
|
246
|
+
// K2 章节/段落级预扫描:整篇文档一次遍历得出待删区段映射(主循环优先消费)
|
|
247
|
+
const sectionDrops = collectSectionLevelNoise(lines, docKind);
|
|
248
|
+
let index = 0;
|
|
249
|
+
while (index < lines.length) {
|
|
250
|
+
const line = lines[index];
|
|
251
|
+
const trimmed = line.trim();
|
|
252
|
+
// 0. K2 内容无关区段(章节/段落级整段删除,最高优先级,30% 回退保护兜底)
|
|
253
|
+
const sectionDrop = sectionDrops.get(index);
|
|
254
|
+
if (sectionDrop) {
|
|
255
|
+
for (let j = index; j < sectionDrop.end; j += 1) {
|
|
256
|
+
drop(lines[j], s => { s[sectionDrop.kind] += 1; });
|
|
257
|
+
sectionRemovedChars += lines[j].length;
|
|
258
|
+
}
|
|
259
|
+
index = sectionDrop.end;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// 1. 目录区段(「目录」标题行 + 点线占比 ≥50% + 行数 ≥4 三重证据,整区删除)
|
|
263
|
+
if (TOC_TITLE_RE.test(trimmed)) {
|
|
264
|
+
const region = collectTocRegion(lines, index);
|
|
265
|
+
const count = region.end - index;
|
|
266
|
+
if (count >= 4 && region.dotLineRatio >= 0.5) {
|
|
267
|
+
for (let j = index; j < region.end; j += 1)
|
|
268
|
+
drop(lines[j], s => { s.tocRegionLines += 1; });
|
|
269
|
+
index = region.end;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// 2. 连续空行压缩:≥3 连续空行保留 2 个(单空行是段落分隔,留给分块器)
|
|
274
|
+
if (!trimmed) {
|
|
275
|
+
let run = 1;
|
|
276
|
+
while (index + run < lines.length && !lines[index + run].trim())
|
|
277
|
+
run += 1;
|
|
278
|
+
const keepCount = Math.min(run, 2);
|
|
279
|
+
for (let k = 0; k < keepCount; k += 1)
|
|
280
|
+
kept.push('');
|
|
281
|
+
const dropped = run - keepCount;
|
|
282
|
+
if (dropped > 0) {
|
|
283
|
+
removedLines += dropped;
|
|
284
|
+
stats.blankLines += dropped;
|
|
285
|
+
}
|
|
286
|
+
index += run;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
// 3. 页眉/页脚/图框标题栏高重复行
|
|
290
|
+
if (isHeaderFooterLine(trimmed, lineCounts.get(trimmed) ?? 1)) {
|
|
291
|
+
drop(line, s => { s.headerFooterLines += 1; });
|
|
292
|
+
index += 1;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// 4. 纯页码行
|
|
296
|
+
if (isPageNumberLine(trimmed, totalLines)) {
|
|
297
|
+
drop(line, s => { s.pageNumberLines += 1; });
|
|
298
|
+
index += 1;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
// 5. 招标文件:投标文件格式模板段(段级)
|
|
302
|
+
if (docKind === 'tender') {
|
|
303
|
+
const blockEnd = collectTenderFormatBlock(lines, index);
|
|
304
|
+
if (blockEnd > index) {
|
|
305
|
+
for (let j = index; j < blockEnd; j += 1)
|
|
306
|
+
drop(lines[j], s => { s.tenderFormatLines += 1; });
|
|
307
|
+
index = blockEnd;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// 招标文件:泛化引用行(行级)
|
|
311
|
+
if (trimmed.length <= 60 && TENDER_GENERIC_LINE_RE.test(trimmed)) {
|
|
312
|
+
drop(line, s => { s.tenderGenericLines += 1; });
|
|
313
|
+
index += 1;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// 6. 补疑文件:零信息回复行(行级,长度 ≤25 且无数字/单位)
|
|
318
|
+
if (docKind === 'clarification' && trimmed.length <= 25 && CLARIFICATION_BOILERPLATE_RE.test(trimmed) && !/\d/u.test(trimmed)) {
|
|
319
|
+
drop(line, s => { s.clarificationLines += 1; });
|
|
320
|
+
index += 1;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
// 7. 图纸:无汉字纯坐标数字行
|
|
324
|
+
if (isCad && isCoordinateNoiseLine(trimmed)) {
|
|
325
|
+
drop(line, s => { s.cadNoiseLines += 1; });
|
|
326
|
+
index += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
kept.push(line);
|
|
330
|
+
index += 1;
|
|
331
|
+
}
|
|
332
|
+
const resultText = kept.join('\n');
|
|
333
|
+
// 整体回退保护(宁多勿丢):
|
|
334
|
+
// 1) 行级启发式删除导致剩余不足原文 30% → 判定误伤,回退原文;
|
|
335
|
+
// 2) K2 章节级删除(标题 + 规模/内容 + 边界三重证据)置信度高,不参与 30% 判定,
|
|
336
|
+
// 否则通用条款占比高的合同文件永远洗不掉;但剩余不足原文 10% 时仍整体回退(防极端全删)
|
|
337
|
+
const totalChars = input.text.trim().length;
|
|
338
|
+
const remainingRatio = totalChars > 0 ? resultText.trim().length / totalChars : 1;
|
|
339
|
+
const lineLevelRemovedChars = removedChars - sectionRemovedChars;
|
|
340
|
+
const lineLevelSafe = lineLevelRemovedChars === 0 || remainingRatio >= 0.3;
|
|
341
|
+
const extremeSafe = remainingRatio >= 0.1;
|
|
342
|
+
if (removedChars > 0 && totalChars > 2000 && (!lineLevelSafe || !extremeSafe)) {
|
|
343
|
+
return { text: input.text, removedChars: 0, removedLines: 0, stats: zeroStats() };
|
|
344
|
+
}
|
|
345
|
+
return { text: resultText, removedChars, removedLines, stats };
|
|
346
|
+
}
|
|
@@ -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,33 @@ 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
|
+
].filter(Boolean).join('、');
|
|
219
|
+
extraction.warnings.push(`入库清洗移除噪声 ${cleaning.removedLines} 行(${cleaningDetail})`);
|
|
220
|
+
}
|
|
221
|
+
const normalizedHash = this.dedup.normalizedHash(cleanedText);
|
|
201
222
|
const normalizedDuplicate = !duplicate && normalizedHash
|
|
202
223
|
? this.store.findNormalizedDuplicate(normalizedHash, file.relativePath)
|
|
203
224
|
: undefined;
|
|
204
225
|
this.updateJobsForFile(file.relativePath, 'CHUNKING', Math.min(80, basePercent + 10), `正在切片 ${file.relativePath}`);
|
|
205
226
|
this.reportProgress({ stage: 'chunking', percent: Math.min(80, basePercent + 10), message: `正在切片 ${file.relativePath}`, filePath: file.relativePath });
|
|
206
|
-
const chunks = this.chunker.chunk(
|
|
227
|
+
const chunks = this.chunker.chunk(cleanedText, file, extraction.metadata);
|
|
207
228
|
this.reportProgress({ stage: 'chunking', percent: Math.min(84, basePercent + 14), message: `切片完成:${chunks.length} 块`, filePath: file.relativePath, chunkCount: chunks.length });
|
|
208
229
|
this.store.upsertFileHash({
|
|
209
230
|
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';
|