Commit 2cb3710c by luoqi

fix(chain): actual-only 链立链改牙位级 — 拔除等治疗不再隐身

问题:actual-only(无诊断)链立链判据是 category 级(`!dxCategories.has(cat)`)。
当某 cat 恰好被一个【不同牙位】的诊断占了槽,该 cat 落在其他牙位的 actual 就既进不了
诊断链(牙位不重叠)、又立不了自己的链 → 整条隐身。
李梦维:K00(先天/萌出,主类目=surgical)占了 surgical 槽,3 次拔除(28/18/15)隐身 →
"已被替代:外科手术·28" 指向一条不存在的链(看着自相矛盾)。

修:改成牙位级 —— cat 有诊断桶但 actual 落在【同类诊断未覆盖的牙位】→ 单独立 actual-only
桶(tooth=未覆盖牙位,只收这些牙,不跟诊断链重叠;code='' 不参与召回 ★,纯展示)。
李梦维 → 多出 "外科手术·15;18;28" 闭环链,K00 仍 ★,替代标注指向真实链。

防双显:无牙位诊断(host 诊断常不填牙位)覆盖范围未知 → 视为该 cat 全覆盖,不 carve
(否则诊断链借 actual 牙位显示,跟新链撞同 (cat,tooth);023cbb47 K02 无牙位+充填 17;47 即此坑)。

回归(800 患者):总链 1722→1751(+29 条原本隐身的真实治疗现身),同 (cat,tooth) 双显
违规 11→11(我引入 0;那 11 条是 K02+K03 同牙的既有现象,与本改无关)。全量 89 测试通过。
注:治疗链 read-time 合成,无需重算 DB。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parent cd4ed7d9
......@@ -567,17 +567,60 @@ function collectChainBuckets(byType: FactsByType): ChainBucket[] {
// 这里只过滤 **actual-only** preventive)。K05 牙周诊断的"洁牙"已通过 K05.categories 含
// preventive 算"已启动牙周治疗",不需要独立 preventive 链兜底。
const EXCLUDED_ACTUAL_ONLY_CATS = new Set(['review', 'unknown', 'preventive']);
const dxCategories = new Set([...map.values()].map((b) => b.category));
const actualCategories = new Set<string>();
// 同 cat 诊断桶覆盖的牙位集合。
// ⚠️ wholeMouth('*whole')或【无牙位诊断】(host 诊断常不填牙位)→ 覆盖范围未知,
// 视为"该 cat 全覆盖",不再 carve actual-only —— 否则诊断链会借 actual 牙位显示,
// 跟新 actual-only 链撞同 (category, tooth) 双显(023cbb47:K02 无牙位 + 充填 17;47 即此坑)。
const dxCatsWithBucket = new Set<string>();
const dxFullCoverageCats = new Set<string>();
const dxToothByCat = new Map<string, Set<string>>();
for (const b of map.values()) {
dxCatsWithBucket.add(b.category);
if (b.tooth === '*whole' || !b.tooth) {
dxFullCoverageCats.add(b.category);
continue;
}
const set = dxToothByCat.get(b.category) ?? new Set<string>();
for (const t of b.tooth.split(';')) {
const tt = t.trim();
if (tt) set.add(tt);
}
dxToothByCat.set(b.category, set);
}
// 扫 actual 立"无诊断 / 诊断未覆盖"链:
// - cat 完全无诊断桶 → tooth='' 全收桶(原行为,如 修复冠桥 / 美容)
// - cat 有诊断桶但 actual 落在【同类诊断未覆盖的牙位】→ 立 tooth=未覆盖 的 actual-only 桶
// 修(李梦维 case):K00 主类目=surgical 占了 surgical 槽,但拔牙在 28/18/15(K00 牙位 1B;4C
// 不覆盖)→ 老版 category 级判据 `!dxCategories.has(cat)` 把整条外科 actual-only 链 ban,
// 拔牙隐身、"已被替代:外科手术·28" 指向不存在的链。改牙位级:未被同类诊断覆盖的 actual
// 牙位单独立链(只收这些牙,不跟诊断链重叠;code='' 不参与召回 ★,纯展示)。
const noDxActualCats = new Set<string>();
const uncoveredByCat = new Map<string, Set<string>>();
for (const tx of byType.treatment) {
if (tx.kind !== FactKind.ACTUAL) continue;
const cat = String((tx.content as Record<string, unknown>).category ?? '');
if (cat && !EXCLUDED_ACTUAL_ONLY_CATS.has(cat)) actualCategories.add(cat);
}
for (const cat of actualCategories) {
if (!dxCategories.has(cat)) {
map.set(`${cat}||`, { category: cat, code: '', tooth: '', signals: [] });
if (!cat || EXCLUDED_ACTUAL_ONLY_CATS.has(cat)) continue;
if (!dxCatsWithBucket.has(cat)) {
noDxActualCats.add(cat); // 原行为:该 cat 无任何诊断
continue;
}
if (dxFullCoverageCats.has(cat)) continue; // 全口 / 无牙位诊断视为全覆盖,不 carve
const txTooth = String((tx.content as Record<string, unknown>).tooth_position ?? '').trim();
if (!txTooth) continue; // 有诊断 + 无牙位 actual → 交给诊断链,不另立
const covered = dxToothByCat.get(cat);
const set = uncoveredByCat.get(cat) ?? new Set<string>();
for (const t of txTooth.split(';')) {
const tt = t.trim();
if (tt && !covered?.has(tt)) set.add(tt);
}
if (set.size > 0) uncoveredByCat.set(cat, set);
}
for (const cat of noDxActualCats) {
map.set(`${cat}||`, { category: cat, code: '', tooth: '', signals: [] });
}
for (const [cat, teeth] of uncoveredByCat) {
const tooth = normalizeTooth([...teeth].join(';'));
if (tooth) map.set(`${cat}|_act_|${tooth}`, { category: cat, code: '', tooth, signals: [] });
}
// ⭐ tooth overlap union(W3 末)— 同 (category, code) 下,tooth 集合有交集的桶合并为一条链
......
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