@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: 4096,
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
- throw new AppError('LLM 敏感词提取响应缺少文本输出', 502);
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) {
@@ -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
  /**
@@ -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
- const requested = (await finder(recognizedText))
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 quad = locateWordQuad(line, hit, word.text.length);
178
- if (quad === undefined)
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.2",
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/resource-versioning": "0.2.0",
22
- "@onco-foundry/trace-port": "0.3.2"
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"