Commit cd4ed7d9 by luoqi

feat(sync): 术式 category 关键词分类兜底(精确未命中长尾,救回 ~70% 漏配)

问题:enum_mapping 是 normalize 后的【整串精确匹配】,treatName 2万+ distinct
精确字典覆盖不全 → 长尾全靠 _default:'' 丢弃 → 真 actual 治疗被丢 → 召回排除
失效 → 误召(诊断后明明做过同类治疗却仍被召回)。实测 tx|act missing-category
59410 occ / 20681 distinct。

方案(分层,只接管精确未命中,精确命中零影响,最坏退化=_default 现状,无回归):
- 引擎(host 无关算法):applyEnum 精确未命中 → keyword 分类器 → _default。
  classifyByKeyword 提为导出纯函数:按 ,;。切段 + 剥离条件从句(stripClauses)
  + 按 rules 顺序含词匹配(优先级裁决:种植>冠、根管>冠、操作词>人群词)。
- yaml(host 术式差异):treatment_actual/planned 加 keyword_mapping(10 类真治疗,
  有序优先级)+ keyword_strip_clauses(actual 剥必要时/建议等;planned 只剥必要时,
  保留建议/拟——计划本就这么措辞)。流程/无操作不配 → 落 _default(复用上游
  route_by_pattern 的 review 分流,不重复)。

实测回收:distinct 72.4% / occurrence 70.0%;仍丢弃 Top 全是流程噪音(咨询/复诊/
无需处理/口扫…),负向词剥离使"定期观察,必要时拔除"正确落空不误判 surgical。
新增单测 26 例(真实漏配术式 fixture);全量 89 测试通过,tsc 0 错。

生效路径:新增量数据自动走;存量需 reparse(truncate facts + sync --full)才回填。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parent 70d53a9d
...@@ -431,3 +431,28 @@ enum_mapping: ...@@ -431,3 +431,28 @@ enum_mapping:
# 常规复查 / 复查 / 定期复查 / 检查(无具体内容) / 暂观 / 观察 / 无治疗 / 重复 / 取资料 / 拆线 # 常规复查 / 复查 / 定期复查 / 检查(无具体内容) / 暂观 / 观察 / 无治疗 / 重复 / 取资料 / 拆线
# 建议XX / 推荐XX(走 recommendation_record) # 建议XX / 推荐XX(走 recommendation_record)
_default: "" _default: ""
# ─────────────────────────────────────────────────────────
# 关键词分类兜底 —— 精确 enum_mapping 未命中时,按序含词匹配 → 赋 category。
# 治本长尾:treatName 2万+ distinct 精确字典覆盖不全,以前全靠 _default:'' 丢(→ 误召)。
# 引擎算法(切段 / 含词 / 按序裁决 / 剥离从句)host 无关;此处规则词 = 本 host 术式特征(host 差异)。
# 只覆盖【真治疗】10 大类;流程/无操作(观察/复诊/无治疗)不在此 → 落 _default 丢弃
# (review 噪音已由 transforms.route_by_pattern 上游分流,这里不重复)。
# 顺序 = 优先级:主操作/高价值在前 → 关键词赢复合(种植>冠、根管>冠、操作词>人群词)。
# ⚠️ 安全:只接管精确未命中的长尾,精确命中零影响;错配最坏退化=_default(现状),无回归。
keyword_mapping:
category:
- { value: implant, any: [种植, 即拔即种, 植体, 二期] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 扩弓] }
- { value: cosmetic, any: [贴面, 漂白, 美白] }
- { value: prosthodontic, any: [, , 义齿, 修复体, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] }
- { value: restorative, any: [充填, 补牙, 树脂, 玻璃离子, 嵌体, 垫底] }
- { value: periodontic, any: [洁治, 洗牙, 洁牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] }
- { value: preventive, any: [涂氟, 窝沟封闭, 封闭, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# actual 语义:这些词开头的从句 = 本次没做(条件/未来/建议)→ 切段丢弃后再匹配。
# 例 "充填,必要时根管治疗" → 丢"必要时根管治疗" → "充填" → restorative(不误判成 endodontic)。
keyword_strip_clauses: [必要时, 如需, 择期, 建议, 推荐, 考虑]
...@@ -417,3 +417,23 @@ enum_mapping: ...@@ -417,3 +417,23 @@ enum_mapping:
# 常规复查 / 复查 / 定期复查 / 检查(无具体内容)/ 已交付纸质病历 / 缴费 / 咨询 / # 常规复查 / 复查 / 定期复查 / 检查(无具体内容)/ 已交付纸质病历 / 缴费 / 咨询 /
# 听方案 / 沟通治疗方案 / 方案沟通 / 拒绝拍片 / 未拍片 / 治疗中 / 未指定治疗项 / 转诊 # 听方案 / 沟通治疗方案 / 方案沟通 / 拒绝拍片 / 未拍片 / 治疗中 / 未指定治疗项 / 转诊
_default: "" _default: ""
# ─────────────────────────────────────────────────────────
# 关键词分类兜底 —— 跟 treatment_actual 同一套真治疗规则(精确未命中按序含词匹配)。
# 引擎算法 host 无关;规则词 = 本 host 术式特征。只覆盖真治疗 10 类,流程/无操作落 _default。
keyword_mapping:
category:
- { value: implant, any: [种植, 即拔即种, 植体, 二期] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 扩弓] }
- { value: cosmetic, any: [贴面, 漂白, 美白] }
- { value: prosthodontic, any: [, , 义齿, 修复体, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] }
- { value: restorative, any: [充填, 补牙, 树脂, 玻璃离子, 嵌体, 垫底] }
- { value: periodontic, any: [洁治, 洗牙, 洁牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] }
- { value: preventive, any: [涂氟, 窝沟封闭, 封闭, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# planned 语义:计划本就以"建议/拟/推荐"措辞 → 不剥,只剥纯条件词(必要时/如需/择期)。
# 例 "建议全口洁治后,必要时正畸治疗" → 丢"必要时正畸治疗" → "建议全口洁治后" → periodontic。
keyword_strip_clauses: [必要时, 如需, 择期]
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import type { z } from 'zod'; import type { z } from 'zod';
import { CanonicalResourceSchemas, type CanonicalResourceKey } from '@pac/types'; import { CanonicalResourceSchemas, type CanonicalResourceKey } from '@pac/types';
import type { AssemblerConfig, EmitsConfig } from './assembler.schema'; import type { AssemblerConfig, EmitsConfig, KeywordRule } from './assembler.schema';
import { import {
ContractDriftError, ContractDriftError,
normalizeCanonical, normalizeCanonical,
...@@ -128,8 +128,13 @@ export class AssemblerEngine { ...@@ -128,8 +128,13 @@ export class AssemblerEngine {
const canonical: Record<string, unknown> = {}; const canonical: Record<string, unknown> = {};
for (const [canonicalKey, hostField] of Object.entries(config.field_mapping)) { for (const [canonicalKey, hostField] of Object.entries(config.field_mapping)) {
const v = row[hostField]; const v = row[hostField];
canonical[canonicalKey] = canonical[canonicalKey] = this.applyEnum(
this.applyEnum(canonicalKey, v, config.enum_mapping); canonicalKey,
v,
config.enum_mapping,
config.keyword_mapping,
config.keyword_strip_clauses,
);
} }
// ④ join_arrays:嵌套子表数组 // ④ join_arrays:嵌套子表数组
...@@ -253,26 +258,33 @@ export class AssemblerEngine { ...@@ -253,26 +258,33 @@ export class AssemblerEngine {
/** /**
* 应用 enum_mapping:host 编码值 → PAC 枚举字符串 * 应用 enum_mapping:host 编码值 → PAC 枚举字符串
* *
* 查表顺序: * 查表顺序(单值字段):
* 1. 精确匹配 fieldMap[key] * 1. 精确匹配 fieldMap[key] ← 最高优先,现状不变
* 2. 特殊键 fieldMap._default(兜底,长尾值统一映射) * 2. keyword_mapping 关键词分类(若配) ← 新增:精确未命中的长尾兜底
* 3. 原值透传(让 zod 决定是否合法) * 3. 特殊键 fieldMap._default(兜底,长尾值统一映射)
* 4. 原值透传(让 zod 决定是否合法)
*
* 关键安全性:关键词层【只接管精确未命中】的值,对已精确命中的零影响;
* 最坏退化 = _default(= 现状丢弃),错配只发生在"本来就要丢"的数据上 → 无回归。
* *
* 典型 _default 场景:treatName 21k distinct,enum_mapping 只能 cover top N, * 典型场景:treatName 2万+ distinct,精确字典只 cover top N,剩余长尾以前全靠
* 剩余长尾用 `_default: unknown` 统一兜底,parser 见到 'unknown' * `_default: ''` 丢弃(→ 误召);keyword_mapping 用"含词+优先级"把长尾的真治疗救回 category。
* 跳过(silently warn,不污染 fact)。 *
* array 字段(如 treat_stages)只走精确 + _default(关键词只服务单值 category)。
*/ */
private applyEnum( private applyEnum(
canonicalKey: string, canonicalKey: string,
value: unknown, value: unknown,
mapping: Record<string, Record<string, string>> | undefined, mapping: Record<string, Record<string, string>> | undefined,
keywordMapping?: Record<string, KeywordRule[]>,
stripClauses?: string[],
): unknown { ): unknown {
if (!mapping) return value; const fieldMap = mapping?.[canonicalKey];
const fieldMap = mapping[canonicalKey]; const kwRules = keywordMapping?.[canonicalKey];
if (!fieldMap) return value;
// ⭐ W4 末:支持 array 字段(如 treat_stages = ["开髓","根备"] → ["pulp_extirpation","canal_preparation"]) // ⭐ W4 末:支持 array 字段(如 treat_stages = ["开髓","根备"] → ["pulp_extirpation","canal_preparation"])
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (!fieldMap) return value;
const out: string[] = []; const out: string[] = [];
for (const elem of value) { for (const elem of value) {
if (typeof elem !== 'string' && typeof elem !== 'number') continue; if (typeof elem !== 'string' && typeof elem !== 'number') continue;
...@@ -287,11 +299,55 @@ export class AssemblerEngine { ...@@ -287,11 +299,55 @@ export class AssemblerEngine {
if (typeof value !== 'string' && typeof value !== 'number') return value; if (typeof value !== 'string' && typeof value !== 'number') return value;
const key = String(value); const key = String(value);
const mapped = fieldMap[key];
if (mapped !== undefined) return mapped; // ① 精确命中(最高优先,现状不变)
const defaultV = fieldMap['_default']; if (fieldMap) {
return defaultV !== undefined ? defaultV : value; const mapped = fieldMap[key];
if (mapped !== undefined) return mapped;
}
// ② 关键词分类兜底(仅精确未命中时;'' = 显式丢弃)
if (kwRules && kwRules.length) {
const kw = classifyByKeyword(key, kwRules, stripClauses);
if (kw !== undefined) return kw;
}
// ③ _default / 透传
if (fieldMap) {
const defaultV = fieldMap['_default'];
if (defaultV !== undefined) return defaultV;
}
return value;
}
}
/**
* 关键词分类器(纯函数,host 无关,可单测):normalized 文本 → PAC enum。
* 1. (可选)剥离条件/未来从句:按 , ; 。切段,丢以 stripClauses 任一词开头的段
* 例 "充填,必要时根管治疗" + strip=["必要时"] → "充填"(避免被 endodontic 误抢)
* 2. 按 rules 顺序(= 优先级)扫,文本含某规则 any 中任一词 → 返回该 rule.value(首个命中即停)
* 优先级让"主操作/高价值"赢复合(种植>冠、根管>冠、操作词>人群词)
* 返回 undefined = 无任何规则命中(交回 applyEnum 走 _default)。
*
* 注:输入应已经过 normalize(标点/空白归一);这里只做切段 + 含词。
*/
export function classifyByKeyword(
value: string,
rules: KeywordRule[],
stripClauses?: string[],
): string | undefined {
let text = value;
if (stripClauses && stripClauses.length) {
text = value
.split(/[,;。]/)
.map((s) => s.trim())
.filter((seg) => seg && !stripClauses.some((p) => seg.startsWith(p)))
.join(',');
}
if (!text) return undefined;
for (const rule of rules) {
if (rule.any.some((kw) => kw && text.includes(kw))) return rule.value;
} }
return undefined;
} }
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
......
...@@ -28,6 +28,19 @@ import { ...@@ -28,6 +28,19 @@ import {
const fieldMapping = z.record(z.string(), z.string()); const fieldMapping = z.record(z.string(), z.string());
const enumMappingForField = z.record(z.string(), z.string()); const enumMappingForField = z.record(z.string(), z.string());
/// 关键词分类规则(有序数组 = 优先级,从前到后首个命中即用)。
/// 仅在精确 enum_mapping 未命中时启用,作为 _default 之前的一层兜底。
/// normalized 文本(剥离 keyword_strip_clauses 段后)含 `any` 中任一词 → 赋 `value`(PAC enum)。
/// 匹配算法在引擎(切段 / 含词 / 按序裁决,host 无关);规则词在 yaml(host 术式差异)。
const KeywordRuleSchema = z.object({
/// 命中后赋的 PAC enum 值(如 'endodontic'); '' 表示丢弃(等价 _default 空)
value: z.string(),
/// 含任一词即命中(子串 includes)
any: z.array(z.string().min(1)).min(1),
});
export type KeywordRule = z.infer<typeof KeywordRuleSchema>;
const keywordMappingForField = z.array(KeywordRuleSchema);
/// 子数组:host 的 1:N 关联表 → canonical 资源里的数组字段 /// 子数组:host 的 1:N 关联表 → canonical 资源里的数组字段
export const JoinArraySchema = z.object({ export const JoinArraySchema = z.object({
/// 子表名(对应文件里 raw_tables[<这里>]) /// 子表名(对应文件里 raw_tables[<这里>])
...@@ -92,6 +105,15 @@ export const AssemblerConfigSchema = z.object({ ...@@ -92,6 +105,15 @@ export const AssemblerConfigSchema = z.object({
/// 主表字段的枚举映射(key 是 canonical 字段名) /// 主表字段的枚举映射(key 是 canonical 字段名)
enum_mapping: z.record(z.string(), enumMappingForField).optional(), enum_mapping: z.record(z.string(), enumMappingForField).optional(),
/// 关键词分类兜底(key 是 canonical 字段名)— 精确 enum_mapping 未命中时按序匹配。
/// 治本长尾:host 术式 free-text(2万+ distinct)精确字典覆盖不全,关键词一条吃一类。
keyword_mapping: z.record(z.string(), keywordMappingForField).optional(),
/// 关键词匹配前剥离的"条件/未来从句"前缀(按 , ; 。切段,丢以这些词开头的段)。
/// 例:actual 治疗 "充填,必要时根管治疗" → 丢 "必要时根管治疗" → 只剩 "充填" → restorative。
/// planned 计划不配(计划本就以"建议/拟"措辞,不能剥)。host/语义差异在此声明,引擎通用。
keyword_strip_clauses: z.array(z.string().min(1)).optional(),
/// 1:N 子表嵌套(canonical 数组字段名 → 子表 + 字段映射) /// 1:N 子表嵌套(canonical 数组字段名 → 子表 + 字段映射)
join_arrays: z.record(z.string(), JoinArraySchema).optional(), join_arrays: z.record(z.string(), JoinArraySchema).optional(),
}); });
......
/**
* 治疗术式关键词分类器 — CI 防漂移测试。
*
* 验证:精确 enum_mapping 未命中时,keyword_mapping(按序含词 + 条件从句剥离)能把
* host 长尾 free-text 术式正确归到 PAC category;噪音/无操作正确落空(→ _default 丢弃)。
*
* fixture 术式取自服务器真实漏配样本(/tmp/sync-full2.log missing-category WARN)。
* 直接加载线上 jvs-dw yaml 的 keyword_mapping(顺带校验新 schema 字段合法)。
*/
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { classifyByKeyword } from '../src/modules/sync/assembler/assembler-engine';
import { AssemblerConfigSchema } from '../src/modules/sync/assembler/assembler.schema';
function loadCfg(file: string) {
const p = path.join(__dirname, '..', 'data', 'jvs-dw', 'assemblers', file);
const parsed = yaml.load(fs.readFileSync(p, 'utf-8'));
const r = AssemblerConfigSchema.safeParse(parsed);
if (!r.success) {
throw new Error(`${file} schema invalid:\n${JSON.stringify(r.error.issues, null, 2)}`);
}
return r.data;
}
describe('treatment_actual keyword classifier (real jvs-dw yaml)', () => {
const cfg = loadCfg('treatment_actual.yaml');
const rules = cfg.keyword_mapping!.category!;
const strip = cfg.keyword_strip_clauses;
// [术式(已 normalize:ASCII 标点), 期望 category 或 undefined(落 _default), 解决的问题类]
const cases: Array<[string, string | undefined, string]> = [
['洁牙+喷砂', 'periodontic', 'A 加号复合'],
['龈上洁治+局部刮治', 'periodontic', 'A'],
['龈上洁治术+喷砂', 'periodontic', 'A 后缀变体(术)'],
['根管治疗,冠修复', 'endodontic', 'B 复合优先级 根管>冠'],
['种植冠/桥修复(多颗)', 'implant', 'B 种植>冠'],
['根管治疗+桩冠修复', 'endodontic', 'B'],
['洗牙', 'periodontic', 'C 同义口语'],
['细洁', 'periodontic', 'C'],
['定期涂氟', 'preventive', 'C'],
['拟涂氟,已告知家长及患者病情', 'preventive', 'D 剥离备注噪音(已告知)'],
['窝沟封闭。已告知家长', 'preventive', 'D'],
['充填,必要时根管治疗', 'restorative', 'E 负向词:剥离"必要时根管"→充填'],
['戴贴面', 'cosmetic', '贴面先于冠'],
['乳牙拔除术', 'surgical', '操作词(拔除)>人群词(乳牙)'],
['儿童早期矫治', 'orthodontic', '操作词(矫治)>人群词(儿童)'],
['活动义齿修复', 'prosthodontic', ''],
['全瓷冠修复', 'prosthodontic', ''],
// —— 噪音 / 无操作 → undefined(落 _default 丢弃,不误判成治疗)——
['定期观察,必要时拔除', undefined, 'E:剥离"必要时拔除"→只剩观察→不误判 surgical'],
['无需处理', undefined, '无操作'],
['不予处理(无间隙)', undefined, '无操作'],
['未指定治疗项', undefined, '无操作'],
['拍照口扫,反馈给加工单位,调整治疗计划', undefined, '流程'],
];
test.each(cases)('「%s」→ %s', (input, expected) => {
expect(classifyByKeyword(input, rules, strip)).toBe(expected);
});
it('keyword_mapping/strip 字段被 schema 接受且非空', () => {
expect(rules.length).toBeGreaterThan(5);
expect(strip).toContain('必要时');
});
});
describe('treatment_planned keyword classifier (计划:不剥建议/拟)', () => {
const cfg = loadCfg('treatment_planned.yaml');
const rules = cfg.keyword_mapping!.category!;
const strip = cfg.keyword_strip_clauses;
it('「建议拔除智齿」→ surgical(planned 保留"建议")', () => {
expect(classifyByKeyword('建议拔除智齿', rules, strip)).toBe('surgical');
});
it('「建议全口洁治后,必要时正畸治疗」→ periodontic(剥"必要时正畸"留洁治)', () => {
expect(classifyByKeyword('建议全口洁治后,必要时正畸治疗', rules, strip)).toBe('periodontic');
});
it('planned strip 不含"建议"', () => {
expect(strip).not.toContain('建议');
expect(strip).toContain('必要时');
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment