@onco-foundry/mask-port 0.1.2 → 0.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.
|
@@ -33,6 +33,7 @@ const extractedSchema = z.array(z.object({
|
|
|
33
33
|
/** 姓名类目标:形态校验只拦阿拉伯数字(单字符中文名是合法情形,不拦)。 */
|
|
34
34
|
const NAME_TARGETS = new Set(['patient_name', 'doctor_signature']);
|
|
35
35
|
const modelResponseSchema = z.object({
|
|
36
|
+
stop_reason: z.string().optional(),
|
|
36
37
|
content: z.array(z.object({
|
|
37
38
|
type: z.string(),
|
|
38
39
|
text: z.string().optional(),
|
|
@@ -88,7 +89,8 @@ export const createLlmSensitiveWordFinder = (options) => {
|
|
|
88
89
|
system: SYSTEM_PROMPT,
|
|
89
90
|
messages: [{ role: 'user', content: text }],
|
|
90
91
|
temperature: 0,
|
|
91
|
-
max_tokens
|
|
92
|
+
// 推理型模型的思考也吃 max_tokens:给小了会在正文输出前被截断,响应里一个 text 块都没有。
|
|
93
|
+
max_tokens: 16384,
|
|
92
94
|
stream: false,
|
|
93
95
|
}),
|
|
94
96
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -107,7 +109,9 @@ export const createLlmSensitiveWordFinder = (options) => {
|
|
|
107
109
|
// 带推理的模型会先回 thinking 块,取第一个 text 块。
|
|
108
110
|
const textBlock = payload.data.content.find((block) => block.type === 'text' && block.text);
|
|
109
111
|
if (textBlock?.text === undefined) {
|
|
110
|
-
|
|
112
|
+
const blockTypes = payload.data.content.map((block) => block.type).join('、');
|
|
113
|
+
throw new AppError(`LLM 敏感词提取响应缺少文本输出(stop_reason=${payload.data.stop_reason ?? '未知'},`
|
|
114
|
+
+ `块类型=${blockTypes};stop_reason=max_tokens 说明思考烧光了 token 上限)`, 502);
|
|
111
115
|
}
|
|
112
116
|
const parsed = extractedSchema.safeParse(extractJsonArray(textBlock.text));
|
|
113
117
|
if (!parsed.success) {
|
package/dist/textin_masker.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type TextInMaskerOptions = {
|
|
|
23
23
|
readonly baseURL?: string;
|
|
24
24
|
readonly timeoutMs?: number;
|
|
25
25
|
readonly maxInputBytes?: number;
|
|
26
|
+
/** 自动处理链路使用:已发现的敏感词无法完整定位时拒绝交付。 */
|
|
27
|
+
readonly requireAllMatches?: boolean;
|
|
26
28
|
/** 测试注入缝:替换掉真实的 HTTP 层,不碰网络。 */
|
|
27
29
|
readonly fetch?: typeof fetch;
|
|
28
30
|
/**
|
package/dist/textin_masker.js
CHANGED
|
@@ -156,17 +156,28 @@ export const createTextInMasker = (options) => {
|
|
|
156
156
|
if (payload.data.code !== 200 || payload.data.result === undefined) {
|
|
157
157
|
throw new AppError(`TextIn 脱敏接口报错:${payload.data.code} ${payload.data.message}`, 502);
|
|
158
158
|
}
|
|
159
|
+
if (options.requireAllMatches && payload.data.result.pages.length !== 1) {
|
|
160
|
+
throw new AppError('TextIn 脱敏需要单页图片识别结果', 502);
|
|
161
|
+
}
|
|
159
162
|
const page = payload.data.result.pages[0];
|
|
160
163
|
const lines = page.lines;
|
|
161
164
|
const recognizedText = lines.map((line) => line.text).join('\n');
|
|
162
165
|
const scaleX = page.width !== undefined && page.width > 0 ? imageWidth / page.width : 1;
|
|
163
166
|
const scaleY = page.height !== undefined && page.height > 0 ? imageHeight / page.height : 1;
|
|
164
|
-
|
|
167
|
+
if (options.requireAllMatches && !recognizedText.trim()) {
|
|
168
|
+
throw new AppError('TextIn 脱敏未识别出文字,无法确认处理结果', 502);
|
|
169
|
+
}
|
|
170
|
+
const detected = await finder(recognizedText);
|
|
171
|
+
if (options.requireAllMatches && detected.some(word => !word.text || !(word.target in TARGET_LABELS) || !recognizedText.includes(word.text))) {
|
|
172
|
+
throw new AppError('TextIn 脱敏判定结果无法定位', 502);
|
|
173
|
+
}
|
|
174
|
+
const requested = detected
|
|
165
175
|
.filter((word) => word.text !== '' && request.targets.includes(word.target));
|
|
166
176
|
const finalQuads = [];
|
|
167
177
|
const mappings = [];
|
|
168
178
|
const counters = new Map();
|
|
169
179
|
for (const word of requested) {
|
|
180
|
+
let located = 0;
|
|
170
181
|
for (const line of lines) {
|
|
171
182
|
let fromIndex = 0;
|
|
172
183
|
while (true) {
|
|
@@ -174,9 +185,30 @@ export const createTextInMasker = (options) => {
|
|
|
174
185
|
if (hit < 0)
|
|
175
186
|
break;
|
|
176
187
|
fromIndex = hit + word.text.length;
|
|
177
|
-
const
|
|
178
|
-
|
|
188
|
+
const locatedLine = options.requireAllMatches && line.char_positions?.length !== line.text.length
|
|
189
|
+
? { ...line, char_positions: undefined } : line;
|
|
190
|
+
const quad = locateWordQuad(locatedLine, hit, word.text.length);
|
|
191
|
+
if (quad === undefined) {
|
|
192
|
+
if (options.requireAllMatches)
|
|
193
|
+
throw new AppError('TextIn 脱敏敏感词缺少坐标', 502);
|
|
179
194
|
continue;
|
|
195
|
+
}
|
|
196
|
+
if (options.requireAllMatches) {
|
|
197
|
+
const scaled = scaleQuadToImage(quad, scaleX, scaleY);
|
|
198
|
+
const xs = scaled.map(point => point[0]);
|
|
199
|
+
const ys = scaled.map(point => point[1]);
|
|
200
|
+
const area = Math.abs(scaled.reduce((sum, [x, y], index) => {
|
|
201
|
+
const next = scaled[(index + 1) % scaled.length];
|
|
202
|
+
return sum + x * next[1] - next[0] * y;
|
|
203
|
+
}, 0));
|
|
204
|
+
if (area < 1 || scaled.flat().some(n => !Number.isFinite(n)) ||
|
|
205
|
+
Math.min(...xs) < 0 || Math.min(...ys) < 0 ||
|
|
206
|
+
Math.max(...xs) > imageWidth || Math.max(...ys) > imageHeight ||
|
|
207
|
+
Math.max(...xs) <= Math.min(...xs) || Math.max(...ys) <= Math.min(...ys)) {
|
|
208
|
+
throw new AppError('TextIn 脱敏坐标无效', 502);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
located++;
|
|
180
212
|
finalQuads.push(clampQuad(scaleQuad(scaleQuadToImage(quad, scaleX, scaleY), SAFETY_SCALE), imageWidth, imageHeight));
|
|
181
213
|
const count = (counters.get(word.target) ?? 0) + 1;
|
|
182
214
|
counters.set(word.target, count);
|
|
@@ -187,6 +219,9 @@ export const createTextInMasker = (options) => {
|
|
|
187
219
|
});
|
|
188
220
|
}
|
|
189
221
|
}
|
|
222
|
+
if (options.requireAllMatches && located === 0) {
|
|
223
|
+
throw new AppError('TextIn 脱敏敏感词无法在单行定位', 502);
|
|
224
|
+
}
|
|
190
225
|
}
|
|
191
226
|
const overlay = polygonSvg(imageWidth, imageHeight, finalQuads);
|
|
192
227
|
const uprightMasked = await sharp(upright.data)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onco-foundry/mask-port",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"zod": "^4.4.3",
|
|
19
19
|
"@onco-foundry/capability-registry": "0.1.1",
|
|
20
20
|
"@onco-foundry/errors": "0.1.1",
|
|
21
|
-
"@onco-foundry/
|
|
22
|
-
"@onco-foundry/
|
|
21
|
+
"@onco-foundry/trace-port": "0.3.2",
|
|
22
|
+
"@onco-foundry/resource-versioning": "0.3.0"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public"
|