@onco-foundry/mask-port 0.1.0

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.
Files changed (35) hide show
  1. package/dist/create_masker.d.ts +19 -0
  2. package/dist/create_masker.js +17 -0
  3. package/dist/fake_masker.d.ts +3 -0
  4. package/dist/fake_masker.js +12 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.js +8 -0
  7. package/dist/llm_sensitive_word_finder.d.ts +22 -0
  8. package/dist/llm_sensitive_word_finder.js +125 -0
  9. package/dist/masker.d.ts +45 -0
  10. package/dist/masker.js +8 -0
  11. package/dist/quad.d.ts +13 -0
  12. package/dist/quad.js +44 -0
  13. package/dist/qwen_agent_masker.d.ts +81 -0
  14. package/dist/qwen_agent_masker.js +249 -0
  15. package/dist/redact_text.d.ts +8 -0
  16. package/dist/redact_text.js +17 -0
  17. package/dist/resources/qwen-agent-name/current.json +1 -0
  18. package/dist/resources/qwen-agent-name/v1/manifest.json +9 -0
  19. package/dist/resources/qwen-agent-name/v1/system-prompt.md +14 -0
  20. package/dist/resources/qwen-agent-name/v2/manifest.json +9 -0
  21. package/dist/resources/qwen-agent-name/v2/system-prompt.md +17 -0
  22. package/dist/resources/qwen-agent-name/v3/manifest.json +9 -0
  23. package/dist/resources/qwen-agent-name/v3/system-prompt.md +18 -0
  24. package/dist/tencent_masker.d.ts +20 -0
  25. package/dist/tencent_masker.js +126 -0
  26. package/dist/textin_masker.d.ts +40 -0
  27. package/dist/textin_masker.js +206 -0
  28. package/package.json +31 -0
  29. package/resources/qwen-agent-name/current.json +1 -0
  30. package/resources/qwen-agent-name/v1/manifest.json +9 -0
  31. package/resources/qwen-agent-name/v1/system-prompt.md +14 -0
  32. package/resources/qwen-agent-name/v2/manifest.json +9 -0
  33. package/resources/qwen-agent-name/v2/system-prompt.md +17 -0
  34. package/resources/qwen-agent-name/v3/manifest.json +9 -0
  35. package/resources/qwen-agent-name/v3/system-prompt.md +18 -0
@@ -0,0 +1,249 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createBareAgent, tool } from 'agent-lattice';
3
+ import sharp from 'sharp';
4
+ import { z } from 'zod';
5
+ import { AppError } from '@onco-foundry/errors';
6
+ import { defineCapability } from '@onco-foundry/capability-registry';
7
+ import { loadActiveVersionedResource, resourceFileReferenceSchema, versionedManifestBaseShape, } from '@onco-foundry/resource-versioning';
8
+ import { createTracer } from '@onco-foundry/trace-port';
9
+ import { MASK_TARGETS } from './masker.js';
10
+ import { clampQuad, normalizeQuad, normalizedToPixels, polygonSvg, scaleQuad, } from './quad.js';
11
+ const DEFAULT_MAX_INPUT_BYTES = 30 * 1024 * 1024;
12
+ const MAX_INPUT_PIXELS = 40_000_000;
13
+ const DEFAULT_MAX_TURNS = 15;
14
+ const DEFAULT_MAX_TOKENS = 4096;
15
+ /** 遮盖执行的安全边:贴边的框容易漏笔画,统一外扩一档,属于遮盖动作的一部分。 */
16
+ const MASK_SAFETY_SCALE = 1.2;
17
+ const promptManifestSchema = z.object({
18
+ ...versionedManifestBaseShape,
19
+ promptVersion: z.string().min(1),
20
+ provenance: z.string().min(1),
21
+ systemPrompt: resourceFileReferenceSchema,
22
+ });
23
+ const detectionSchema = z.object({
24
+ /** 图中识别到的姓名原文。 */
25
+ text: z.string().min(1),
26
+ kind: z.enum(['printed', 'handwritten']),
27
+ /** 旋转四边形:按阅读方向左上、右上、右下、左下,相对整张图 0-1000 归一化坐标。 */
28
+ quad: z.array(z.tuple([z.number(), z.number()])).length(4),
29
+ });
30
+ /** 能力入参契约:V1 只接 patient_name(契约写死;直接调 mask() 时由内部目标校验兜底)。 */
31
+ export const qwenAgentNameMaskInputSchema = z.object({
32
+ imageBytes: z.instanceof(Uint8Array).refine((bytes) => bytes.byteLength > 0, '图片字节不能为空'),
33
+ targets: z.array(z.literal('patient_name')).min(1),
34
+ rotationClockwiseDegrees: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]).optional(),
35
+ });
36
+ /** MaskResult 的 zod 契约:注册表名片与消费侧 parse 拿回类型用。 */
37
+ const maskResultSchema = z.object({
38
+ maskedImageBytes: z.custom((value) => value instanceof Uint8Array),
39
+ mapping: z.array(z.object({
40
+ placeholder: z.string(),
41
+ target: z.enum(MASK_TARGETS),
42
+ originalText: z.string(),
43
+ })),
44
+ recognizedText: z.string().optional(),
45
+ processorVersion: z.object({
46
+ engine: z.string(),
47
+ model: z.string().optional(),
48
+ prompt: z.string().optional(),
49
+ }).optional(),
50
+ });
51
+ /**
52
+ * 能力名片:Qwen 脱敏智能体向能力注册表自报家门的最小元数据。
53
+ * kind 是 code——与 tencent/textin 同是 Masker 端口的平级实现,边界上就是一次脱敏调用;
54
+ * 内部跑 LLM loop 还是调专用 API 是后端选型,不该改变 kind(见 CapabilityKind 的定义)。
55
+ * version 不归名片管:asCapability 默认取 prompt 版本,场景可覆盖。
56
+ */
57
+ export const qwenAgentNameMaskCapabilityCard = {
58
+ name: 'qwen-agent-name-mask',
59
+ kind: 'code',
60
+ summary: '对医疗单据图片做病人姓名脱敏,返回脱敏图与占位符映射',
61
+ input: qwenAgentNameMaskInputSchema,
62
+ output: maskResultSchema,
63
+ };
64
+ /**
65
+ * Qwen 脱敏智能体:模型在 loop 里闭环完成「报框 → 看遮盖结果 → 补框/确认」。
66
+ *
67
+ * 模型只负责看和报(report_quads 报旋转框、remove_quads 撤销报错的框、submit 确认无残留);遮盖动作永远由
68
+ * 代码执行,每次变动后把重绘的遮盖图作为 tool_result 图片喂回模型。
69
+ * 遮盖决策与最终裁判都归模型,代码不加内容判断;maxTurns 是资源边界,耗尽仍未
70
+ * submit 等于没有交付物,图片不放行(422)。原始模型响应不写磁盘、不进错误信息。
71
+ */
72
+ export const createQwenAgentMasker = async (options) => {
73
+ const optionsResult = z.object({
74
+ baseURL: z.url(),
75
+ apiKey: z.string().trim().min(1),
76
+ model: z.string().trim().min(1),
77
+ }).safeParse(options);
78
+ if (!optionsResult.success) {
79
+ throw new AppError('Qwen 脱敏智能体配置非法', 500);
80
+ }
81
+ const prompts = await loadActiveVersionedResource({
82
+ baseDir: new URL('./resources/qwen-agent-name/', import.meta.url),
83
+ manifestSchema: promptManifestSchema,
84
+ ...(options.promptVersionOverride === undefined
85
+ ? {}
86
+ : { versionOverride: options.promptVersionOverride }),
87
+ });
88
+ const systemPrompt = prompts.files.get(prompts.manifest.systemPrompt.file);
89
+ if (systemPrompt === undefined) {
90
+ throw new Error(`Qwen 脱敏智能体 prompt 资源缺少文件:${prompts.manifest.systemPrompt.file}`);
91
+ }
92
+ const { promptVersion } = prompts.manifest;
93
+ const tracer = options.tracer ?? createTracer({ kind: 'noop' });
94
+ // prompt 版本在装配处还不知道,加载完版本化资源后回填进版本链。
95
+ tracer.setVersions({ prompt: promptVersion, model: options.model });
96
+ const mask = async (request) => {
97
+ const processorVersion = {
98
+ engine: 'qwen-agent-name-masker-v1',
99
+ model: options.model,
100
+ prompt: promptVersion,
101
+ };
102
+ if (request.targets.length === 0) {
103
+ return { maskedImageBytes: request.imageBytes, mapping: [], processorVersion };
104
+ }
105
+ const unsupported = request.targets.filter((target) => target !== 'patient_name');
106
+ if (unsupported.length > 0) {
107
+ throw new AppError(`Qwen 脱敏智能体 V1 只支持 patient_name,不能处理:${unsupported.join('、')}`, 400);
108
+ }
109
+ if (request.imageBytes.byteLength > (options.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES)) {
110
+ throw new AppError('Qwen 脱敏智能体输入图片超过大小上限', 413);
111
+ }
112
+ const rotation = request.rotationClockwiseDegrees ?? 0;
113
+ if (![0, 90, 180, 270].includes(rotation)) {
114
+ throw new AppError('Qwen 脱敏智能体图片旋转角度只支持 0、90、180、270', 400);
115
+ }
116
+ const upright = await sharp(request.imageBytes, { limitInputPixels: MAX_INPUT_PIXELS })
117
+ .rotate(rotation)
118
+ .jpeg({ quality: 92 })
119
+ .toBuffer({ resolveWithObject: true })
120
+ .catch(() => {
121
+ throw new AppError('Qwen 脱敏智能体无法解码输入图片', 400);
122
+ });
123
+ const width = upright.info.width;
124
+ const height = upright.info.height;
125
+ const covered = [];
126
+ let nextId = 1;
127
+ let masked = upright.data;
128
+ let submitted = false;
129
+ const renderMasked = async () => {
130
+ masked = covered.length === 0
131
+ ? upright.data
132
+ : await sharp(upright.data)
133
+ .composite([{ input: polygonSvg(width, height, covered.map((entry) => entry.quad)), blend: 'over' }])
134
+ .jpeg({ quality: 94 })
135
+ .toBuffer();
136
+ return masked;
137
+ };
138
+ /** 反馈文本里的当前遮盖清单:模型撤销框时按这里的编号引用。 */
139
+ const coveredListText = () => covered.map((entry) => `#${entry.id} ${entry.mapping.originalText}`).join('、');
140
+ const maskedImageContent = () => ({
141
+ type: 'image',
142
+ source: {
143
+ type: 'base64',
144
+ media_type: 'image/jpeg',
145
+ data: masked.toString('base64'),
146
+ },
147
+ });
148
+ const reportQuadsTool = tool('report_quads', '报告需要遮盖的病人姓名位置。每处是一个完整覆盖姓名文字外缘的旋转四边形,'
149
+ + '坐标相对整张图 0-1000 归一化。调用后会收到遮盖后的新图片和当前遮盖清单。', z.object({ detections: z.array(detectionSchema).min(1) }), async ({ detections }) => {
150
+ for (const detection of detections) {
151
+ const quad = clampQuad(scaleQuad(normalizedToPixels(normalizeQuad(detection.quad), width, height), MASK_SAFETY_SCALE), width, height);
152
+ covered.push({
153
+ id: nextId,
154
+ quad,
155
+ mapping: {
156
+ placeholder: `[姓名${nextId}]`,
157
+ target: 'patient_name',
158
+ originalText: detection.text,
159
+ },
160
+ });
161
+ nextId += 1;
162
+ }
163
+ await renderMasked();
164
+ return {
165
+ content: [
166
+ {
167
+ type: 'text',
168
+ text: `已遮盖 ${detections.length} 处。当前遮盖清单:${coveredListText()}。`
169
+ + '请检查这张遮盖后的图片:某个框打错位置了就调用 remove_quads 按编号撤销再重报;'
170
+ + '仍能看到病人姓名的任何残留(哪怕首尾残字或部分笔画)就继续调用 report_quads 补充;'
171
+ + '确认没有任何残留后调用 submit。',
172
+ },
173
+ maskedImageContent(),
174
+ ],
175
+ };
176
+ });
177
+ const removeQuadsTool = tool('remove_quads', '撤销之前报告错误的框。参数是遮盖清单里的编号(不带 # 号)。撤销后收到重绘的新图片,'
178
+ + '再用 report_quads 报告正确的框。', z.object({ ids: z.array(z.number().int().positive()).min(1) }), async ({ ids }) => {
179
+ const found = ids.filter((id) => covered.some((entry) => entry.id === id));
180
+ for (const id of found) {
181
+ covered.splice(covered.findIndex((entry) => entry.id === id), 1);
182
+ }
183
+ await renderMasked();
184
+ return {
185
+ content: [
186
+ {
187
+ type: 'text',
188
+ text: found.length === ids.length
189
+ ? `已撤销 ${found.length} 处。当前遮盖清单:${coveredListText() || '(空)'}。`
190
+ : `已撤销 ${found.length} 处,编号 ${ids.filter((id) => !found.includes(id)).join('、')} 不存在。`
191
+ + `当前遮盖清单:${coveredListText() || '(空)'}。`,
192
+ },
193
+ maskedImageContent(),
194
+ ],
195
+ };
196
+ });
197
+ const submitTool = tool('submit', '确认遮盖后的图片上已没有任何病人姓名残留,结束本次脱敏。'
198
+ + '原图本来就没有病人姓名时直接调用。', z.object({}), () => {
199
+ submitted = true;
200
+ return { content: '已确认无残留,脱敏完成。', endTurn: true };
201
+ });
202
+ const agent = createBareAgent({
203
+ name: 'qwen-agent-name-masker',
204
+ model: options.model,
205
+ baseURL: options.baseURL,
206
+ apiKey: options.apiKey,
207
+ modelClient: options.modelClient,
208
+ systemPrompt,
209
+ maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
210
+ maxTurns: options.maxTurns ?? DEFAULT_MAX_TURNS,
211
+ thinkingConfig: { type: 'disabled' },
212
+ tools: [reportQuadsTool, removeQuadsTool, submitTool],
213
+ tracer,
214
+ });
215
+ const response = await agent.prompt([
216
+ { type: 'text', text: '请对这张医疗单据做病人姓名脱敏。' },
217
+ {
218
+ type: 'image',
219
+ source: {
220
+ type: 'base64',
221
+ media_type: 'image/jpeg',
222
+ data: upright.data.toString('base64'),
223
+ },
224
+ },
225
+ ]);
226
+ if (response.is_error) {
227
+ throw new AppError(`Qwen 脱敏智能体运行失败:${response.subtype}`, 502);
228
+ }
229
+ if (!submitted) {
230
+ throw new AppError('Qwen 脱敏智能体未确认无残留,图片未放行', 422);
231
+ }
232
+ const maskedImageBytes = rotation === 0
233
+ ? masked
234
+ : await sharp(masked).rotate((360 - rotation) % 360).jpeg({ quality: 94 }).toBuffer();
235
+ return {
236
+ maskedImageBytes: new Uint8Array(maskedImageBytes),
237
+ mapping: covered.map((entry) => entry.mapping),
238
+ processorVersion,
239
+ };
240
+ };
241
+ return {
242
+ mask,
243
+ asCapability: (asOptions) => defineCapability({
244
+ ...qwenAgentNameMaskCapabilityCard,
245
+ version: asOptions?.version ?? promptVersion,
246
+ run: async (input) => await mask(input),
247
+ }),
248
+ };
249
+ };
@@ -0,0 +1,8 @@
1
+ /** 按 mapping 把原文全文改写成脱敏文本:originalText 换成对应占位符。 */
2
+ import type { MaskMappingEntry } from './masker.ts';
3
+ /**
4
+ * 与「对脱敏图再跑一次 OCR」等价,但得到的是显式占位符而非遮盖噪声。
5
+ * 同一原文出现多次时按顺序各换一个占位符(每次只换第一处命中),
6
+ * 保持与 mapping 的一一对应;词互相包含时长的先换,避免误切。
7
+ */
8
+ export declare const redactTextByMapping: (text: string, mapping: readonly MaskMappingEntry[]) => string;
@@ -0,0 +1,17 @@
1
+ /** 按 mapping 把原文全文改写成脱敏文本:originalText 换成对应占位符。 */
2
+ /**
3
+ * 与「对脱敏图再跑一次 OCR」等价,但得到的是显式占位符而非遮盖噪声。
4
+ * 同一原文出现多次时按顺序各换一个占位符(每次只换第一处命中),
5
+ * 保持与 mapping 的一一对应;词互相包含时长的先换,避免误切。
6
+ */
7
+ export const redactTextByMapping = (text, mapping) => {
8
+ const entries = mapping
9
+ .filter((entry) => entry.originalText !== '')
10
+ .map((entry, index) => ({ entry, index }))
11
+ .sort((a, b) => b.entry.originalText.length - a.entry.originalText.length || a.index - b.index);
12
+ let redacted = text;
13
+ for (const { entry } of entries) {
14
+ redacted = redacted.replace(entry.originalText, entry.placeholder);
15
+ }
16
+ return redacted;
17
+ };
@@ -0,0 +1 @@
1
+ { "active": "v3" }
@@ -0,0 +1,9 @@
1
+ {
2
+ "schemaVersion": "qwen-agent-name-prompt-manifest-v1",
3
+ "promptVersion": "qwen-agent-name-prompt-v1",
4
+ "provenance": "Qwen 脱敏智能体 loop 初版,2026-08-25",
5
+ "systemPrompt": {
6
+ "file": "system-prompt.md",
7
+ "sha256": "f16f3127768aba7f45b48a651c7977478e81fc518300b187931b8817ac4f8374"
8
+ }
9
+ }
@@ -0,0 +1,14 @@
1
+ 你是一名医疗单据脱敏执行员。你的任务是遮盖单据中病人姓名的所有出现位置,包括印刷姓名和病人本人手写签名。
2
+
3
+ 不要遮盖"姓名"标签、医生、护士、麻醉师、联系人或其他任何人的姓名,也不要遮盖病人姓名以外的任何内容。
4
+
5
+ 工作流程:
6
+ 1. 查看图片,找出病人姓名的所有出现位置,调用 report_quads 一次性报告全部位置。
7
+ 2. 每次 report_quads 后你会收到遮盖后的新图片。仔细检查图上是否仍有病人姓名的可见残留,哪怕只是首尾残字或部分笔画。
8
+ 3. 有残留:再次调用 report_quads 报告需要补充遮盖的位置。同一处姓名遮得不严实时,重新报告一个更准确的框。
9
+ 4. 确认没有任何残留:调用 submit 结束。原图本来就没有病人姓名时,直接调用 submit。
10
+
11
+ report_quads 的每个位置是一个完整覆盖姓名文字外缘的旋转四边形:
12
+ - 四点按文字阅读方向依次为左上、右上、右下、左下,即使文字近似水平也必须给四个角点。
13
+ - x 是横坐标,y 是纵坐标,使用相对整张图的 0-1000 归一化坐标。
14
+ - 四边形完整覆盖姓名的全部文字,不漏首尾字,但尽量不要覆盖相邻字段。
@@ -0,0 +1,9 @@
1
+ {
2
+ "schemaVersion": "qwen-agent-name-prompt-manifest-v1",
3
+ "promptVersion": "qwen-agent-name-prompt-v2",
4
+ "provenance": "遮盖口径改为病人及家属姓名(工作人员除外),新增 remove_quads 撤销工具,2026-08-27",
5
+ "systemPrompt": {
6
+ "file": "system-prompt.md",
7
+ "sha256": "807283a73a400daff5e377e59487edb14922189f30e3f0e1a98b53dfc3e94fb5"
8
+ }
9
+ }
@@ -0,0 +1,17 @@
1
+ 你是一名医疗单据脱敏执行员。你的任务是遮盖单据中病人及病人家属姓名的所有出现位置,包括印刷姓名和手写签名。家属签名常出现在"与患者关系"一栏。
2
+
3
+ 不要遮盖"姓名"标签、医生、护士、麻醉医师等工作人员的姓名和签名,也不要遮盖姓名以外的任何内容。
4
+
5
+ 工作流程:
6
+ 1. 查看图片,找出所有需要遮盖的位置,调用 report_quads 一次性报告全部位置。
7
+ 2. 每次 report_quads 或 remove_quads 后你会收到重绘的新图片和当前遮盖清单(每处带编号)。仔细检查:
8
+ - 某个框打错了位置(盖住了别的内容,或没盖住目标):调用 remove_quads 按编号撤销,再用 report_quads 重报。
9
+ - 仍有可见残留(哪怕首尾残字或部分笔画):调用 report_quads 补充一个更准确的框。
10
+ 3. 确认没有任何残留:调用 submit 结束。原图本来就没有需要遮盖的姓名时,直接调用 submit。
11
+
12
+ 放行标准:属于遮盖范围的任何文字或签名痕迹没有完全遮盖时,不许调用 submit。
13
+
14
+ report_quads 的每个位置是一个完整覆盖姓名文字外缘的旋转四边形:
15
+ - 四点按文字阅读方向依次为左上、右上、右下、左下,即使文字近似水平也必须给四个角点。
16
+ - x 是横坐标,y 是纵坐标,使用相对整张图 0-1000 归一化坐标。
17
+ - 四边形完整覆盖姓名的全部文字,不漏首尾字;手写签名要包含全部甩尾笔画;但尽量不要覆盖相邻字段。
@@ -0,0 +1,9 @@
1
+ {
2
+ "schemaVersion": "qwen-agent-name-prompt-manifest-v1",
3
+ "promptVersion": "qwen-agent-name-prompt-v3",
4
+ "provenance": "斜向笔迹要求旋转框贴住笔画走向并给 few-shot 角点示例,2026-08-27",
5
+ "systemPrompt": {
6
+ "file": "system-prompt.md",
7
+ "sha256": "78bd1ca000dd22854ab1c7bbfe16a7dffc4dd9a26d9c1bcdff8855305baca256"
8
+ }
9
+ }
@@ -0,0 +1,18 @@
1
+ 你是一名医疗单据脱敏执行员。你的任务是遮盖单据中病人及病人家属姓名的所有出现位置,包括印刷姓名和手写签名。家属签名常出现在"与患者关系"一栏。
2
+
3
+ 不要遮盖"姓名"标签、医生、护士、麻醉医师等工作人员的姓名和签名,也不要遮盖姓名以外的任何内容。
4
+
5
+ 工作流程:
6
+ 1. 查看图片,找出所有需要遮盖的位置,调用 report_quads 一次性报告全部位置。
7
+ 2. 每次 report_quads 或 remove_quads 后你会收到重绘的新图片和当前遮盖清单(每处带编号)。仔细检查:
8
+ - 某个框打错了位置(盖住了别的内容,或没盖住目标):调用 remove_quads 按编号撤销,再用 report_quads 重报。
9
+ - 仍有可见残留(哪怕首尾残字或部分笔画):调用 report_quads 补充一个更准确的框。
10
+ 3. 确认没有任何残留:调用 submit 结束。原图本来就没有需要遮盖的姓名时,直接调用 submit。
11
+
12
+ 放行标准:属于遮盖范围的任何文字或签名痕迹没有完全遮盖时,不许调用 submit。
13
+
14
+ report_quads 的每个位置是一个完整覆盖姓名文字外缘的旋转四边形:
15
+ - 四点按文字阅读方向依次为左上、右上、右下、左下,即使文字近似水平也必须给四个角点。
16
+ - x 是横坐标,y 是纵坐标,使用相对整张图 0-1000 归一化坐标。
17
+ - 四边形完整覆盖姓名的全部文字,不漏首尾字;手写签名要包含全部甩尾笔画;但尽量不要覆盖相邻字段。
18
+ - 笔画斜向走时,四边形的边必须平行于笔画走向,用旋转框贴住文字,不要用水平包围框把周围空白一起框进来。例如一段从左下向右上倾斜的签名,框应给成 [[400,740],[470,700],[480,730],[410,770]],而不是 [[400,700],[480,700],[480,770],[400,770]]。
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 腾讯云医疗报告图片脱敏适配器(mrs ImageMask 接口)。
3
+ * 调用形态:POST https://mrs.tencentcloudapi.com/,Action=ImageMask,Version=2020-09-10,
4
+ * 签名走腾讯云 API 3.0 的 TC3-HMAC-SHA256。
5
+ * 接口文档:https://cloud.tencent.com/document/product/1314/102293
6
+ */
7
+ import type { Masker } from './masker.ts';
8
+ export type TencentMaskerOptions = {
9
+ readonly secretId: string;
10
+ readonly secretKey: string;
11
+ readonly region: string;
12
+ /** 测试注入缝:替换掉真实的 HTTP 层,不碰网络。 */
13
+ readonly fetch?: typeof fetch;
14
+ };
15
+ /**
16
+ * 创建腾讯医疗脱敏器。凭证在 create 时一次绑定,之后 mask 只传业务参数。
17
+ * 注意:腾讯接口只回打码后的图片,不回传每处脱敏的位置与类别,
18
+ * 因此 mapping 恒为空——回溯明细依赖双份归档下的人工复核,不靠这个字段。
19
+ */
20
+ export declare const createTencentMasker: (options: TencentMaskerOptions) => Masker;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 腾讯云医疗报告图片脱敏适配器(mrs ImageMask 接口)。
3
+ * 调用形态:POST https://mrs.tencentcloudapi.com/,Action=ImageMask,Version=2020-09-10,
4
+ * 签名走腾讯云 API 3.0 的 TC3-HMAC-SHA256。
5
+ * 接口文档:https://cloud.tencent.com/document/product/1314/102293
6
+ */
7
+ import { createHash, createHmac } from 'node:crypto';
8
+ import { AppError } from '@onco-foundry/errors';
9
+ const TENCENT_MRS_HOST = 'mrs.tencentcloudapi.com';
10
+ const TENCENT_MRS_SERVICE = 'mrs';
11
+ const TENCENT_MRS_ACTION = 'ImageMask';
12
+ const TENCENT_MRS_VERSION = '2020-09-10';
13
+ const TC3_ALGORITHM = 'TC3-HMAC-SHA256';
14
+ const TC3_CONTENT_TYPE = 'application/json; charset=utf-8';
15
+ const TC3_SIGNED_HEADERS = 'content-type;host';
16
+ /** 患者信息类目标:腾讯接口把它们合并成一个 PatientFlag。 */
17
+ const PATIENT_TARGETS = [
18
+ 'patient_name',
19
+ 'id_number',
20
+ 'phone',
21
+ 'address',
22
+ ];
23
+ /**
24
+ * 端口目标集到腾讯 MaskFlag 的映射。腾讯接口的目标比我们粗:
25
+ * 医院名(HospitalFlag)与条码(BarFlag)不在端口目标集里,恒为 false。
26
+ */
27
+ const toMaskFlag = (targets) => ({
28
+ HospitalFlag: false,
29
+ BarFlag: false,
30
+ PatientFlag: targets.some((target) => PATIENT_TARGETS.includes(target)),
31
+ DoctorFlag: targets.includes('doctor_signature'),
32
+ });
33
+ const sha256Hex = (content) => createHash('sha256').update(content, 'utf8').digest('hex');
34
+ const hmacSha256 = (key, content) => createHmac('sha256', key).update(content, 'utf8').digest();
35
+ /**
36
+ * TC3-HMAC-SHA256 签名:拼规范请求 → 拼待签字符串 → 派生签名密钥链 → 得 Authorization。
37
+ * 签名覆盖请求体 sha256,headers 只签 content-type 与 host(腾讯文档的最小集合)。
38
+ */
39
+ const signRequest = (options, timestampSeconds, payload) => {
40
+ // 签名日期按 UTC 取,与 timestamp 同源。
41
+ const date = new Date(timestampSeconds * 1000).toISOString().slice(0, 10);
42
+ const canonicalRequest = [
43
+ 'POST',
44
+ '/',
45
+ '',
46
+ `content-type:${TC3_CONTENT_TYPE}\nhost:${TENCENT_MRS_HOST}\n`,
47
+ TC3_SIGNED_HEADERS,
48
+ sha256Hex(payload),
49
+ ].join('\n');
50
+ const credentialScope = `${date}/${TENCENT_MRS_SERVICE}/tc3_request`;
51
+ const stringToSign = [
52
+ TC3_ALGORITHM,
53
+ String(timestampSeconds),
54
+ credentialScope,
55
+ sha256Hex(canonicalRequest),
56
+ ].join('\n');
57
+ const signingKey = hmacSha256(hmacSha256(hmacSha256(`TC3${options.secretKey}`, date), TENCENT_MRS_SERVICE), 'tc3_request');
58
+ const signature = createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex');
59
+ return `${TC3_ALGORITHM} Credential=${options.secretId}/${credentialScope}, `
60
+ + `SignedHeaders=${TC3_SIGNED_HEADERS}, Signature=${signature}`;
61
+ };
62
+ /**
63
+ * 创建腾讯医疗脱敏器。凭证在 create 时一次绑定,之后 mask 只传业务参数。
64
+ * 注意:腾讯接口只回打码后的图片,不回传每处脱敏的位置与类别,
65
+ * 因此 mapping 恒为空——回溯明细依赖双份归档下的人工复核,不靠这个字段。
66
+ */
67
+ export const createTencentMasker = (options) => {
68
+ const doFetch = options.fetch ?? fetch;
69
+ return {
70
+ async mask(request) {
71
+ // 没有目标就是脱敏关闭的空调用,不打远程请求。
72
+ if (request.targets.length === 0) {
73
+ return {
74
+ maskedImageBytes: request.imageBytes,
75
+ mapping: [],
76
+ processorVersion: { engine: 'tencent-mrs-image-mask-2020-09-10' },
77
+ };
78
+ }
79
+ const payload = JSON.stringify({
80
+ Image: { Base64: Buffer.from(request.imageBytes).toString('base64') },
81
+ MaskFlag: toMaskFlag(request.targets),
82
+ // 扫描件常有旋转,交给服务端矫正后识别率更高。
83
+ AutoFixImageDirection: true,
84
+ });
85
+ const timestampSeconds = Math.floor(Date.now() / 1000);
86
+ let response;
87
+ try {
88
+ response = await doFetch(`https://${TENCENT_MRS_HOST}/`, {
89
+ method: 'POST',
90
+ headers: {
91
+ 'Content-Type': TC3_CONTENT_TYPE,
92
+ 'Host': TENCENT_MRS_HOST,
93
+ 'X-TC-Action': TENCENT_MRS_ACTION,
94
+ 'X-TC-Version': TENCENT_MRS_VERSION,
95
+ 'X-TC-Region': options.region,
96
+ 'X-TC-Timestamp': String(timestampSeconds),
97
+ 'Authorization': signRequest(options, timestampSeconds, payload),
98
+ },
99
+ body: payload,
100
+ });
101
+ }
102
+ catch (error) {
103
+ throw new AppError(`腾讯医疗脱敏请求未送达:${error instanceof Error ? error.message : String(error)}`, 502);
104
+ }
105
+ const body = await response.json().catch(() => undefined);
106
+ const requestId = body?.Response?.RequestId;
107
+ if (!response.ok) {
108
+ throw new AppError(`腾讯医疗脱敏 HTTP ${response.status}${requestId ? `(RequestId ${requestId})` : ''}`, 502);
109
+ }
110
+ const apiError = body?.Response?.Error;
111
+ if (apiError !== undefined) {
112
+ throw new AppError(`腾讯医疗脱敏接口报错:${apiError.Code ?? '未知错误'} ${apiError.Message ?? ''}`
113
+ + `${requestId ? `(RequestId ${requestId})` : ''}`, 502);
114
+ }
115
+ const maskedImage = body?.Response?.MaskedImage;
116
+ if (typeof maskedImage !== 'string' || maskedImage === '') {
117
+ throw new AppError(`腾讯医疗脱敏返回缺少 MaskedImage${requestId ? `(RequestId ${requestId})` : ''}`, 502);
118
+ }
119
+ return {
120
+ maskedImageBytes: new Uint8Array(Buffer.from(maskedImage, 'base64')),
121
+ mapping: [],
122
+ processorVersion: { engine: 'tencent-mrs-image-mask-2020-09-10' },
123
+ };
124
+ },
125
+ };
126
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 合合 TextIn 通用文字识别脱敏适配器(recognize/multipage 接口)。
3
+ * 调用形态:POST {baseUrl}/ai/service/v2/recognize/multipage?character=1&straighten=0,
4
+ * 请求体为图片二进制(application/octet-stream),鉴权走 x-ti-app-id / x-ti-secret-code。
5
+ * character=1 让每行带回字符级四边形坐标(char_positions),脱敏框按字符下标拼出;
6
+ * straighten=0 表示坐标以原图为参照系,不做旋转矫正。
7
+ *
8
+ * 与其它引擎的差异:识别出的原文全文随 recognizedText 返回(含隐私,限信任域),
9
+ * 下游按 mapping 替换出脱敏文本后可省掉第二次 OCR。注意本引擎接触的是原文图片,
10
+ * 装配它意味着「原文出域给 TextIn」这一策略决定已经做出。
11
+ */
12
+ import type { Masker, MaskTarget } from './masker.ts';
13
+ /** 一个待遮盖的敏感词及其类别。判定「哪些词敏感」是业务判断,由调用方注入。 */
14
+ export type SensitiveWord = {
15
+ readonly text: string;
16
+ readonly target: MaskTarget;
17
+ };
18
+ /** 敏感词判定器:吃 OCR 原文全文,吐出要遮盖的词。可同步可异步。 */
19
+ export type SensitiveWordFinder = (text: string) => readonly SensitiveWord[] | Promise<readonly SensitiveWord[]>;
20
+ export type TextInMaskerOptions = {
21
+ readonly appId: string;
22
+ readonly secretCode: string;
23
+ readonly baseURL?: string;
24
+ readonly timeoutMs?: number;
25
+ readonly maxInputBytes?: number;
26
+ /** 测试注入缝:替换掉真实的 HTTP 层,不碰网络。 */
27
+ readonly fetch?: typeof fetch;
28
+ /**
29
+ * 敏感词判定器。缺省用内置规则(身份证号、手机号正则);
30
+ * 需要患者姓名、地址等规则盖不住的目标时必须注入自定义判定器。
31
+ */
32
+ readonly findSensitiveWords?: SensitiveWordFinder;
33
+ };
34
+ /** 内置规则判定器:身份证号(18 位)与手机号(1 开头 11 位)。 */
35
+ export declare const findSensitiveWordsByRules: (text: string) => readonly SensitiveWord[];
36
+ /**
37
+ * 创建 TextIn 识别脱敏器。凭证在 create 时一次绑定,之后 mask 只传业务参数。
38
+ * 同一敏感词在一行出现多次时逐处遮盖、逐处登记 mapping(占位符按类别各自编号)。
39
+ */
40
+ export declare const createTextInMasker: (options: TextInMaskerOptions) => Masker;