Commit 1842fb02 by luoqi

fix(persona): 回填 evidence.factIds(证据链)— 7 个 fact 派生特征

#4 证据链债:多个特征返回 evidence.factIds=[],详情页'为什么贴这标签'无法溯源到 fact(违反 db 原则4)。
- 回填 7 个 fact 派生特征,收集驱动该标签的 fact id:
  · discount_anchor → 锚点 payment fact
  · special_attention → 命中的 no_show/迟到 预约 + 不可等候 emr fact(按触发标签收集)
  · time_preference → 窗口内计入的 appointment fact
  · lifecycle_stage → 就诊 + 消费 fact(去重)
  · treatment_sensitivity → 命中关键词的 emr fact(改 per-fact 匹配)
  · potential_treatment / urgency_level → gap 源 fact(PotentialGap 加 factId:selector SQL 加 sig.id)
- 5 个非 fact 派生特征(age/gender/acquisition/family/referral/contraindication)保留空 + 注释说明
  (证据=patient 主档/副表立柱/关系边,本就非 fact)。
- 本地 --force 重算 928:fact 派生特征 100% 有证据(discount 338/338、lifecycle 928/928、
  potential 771/771、special 88/88、time 601/601、urgency 771/771);age/gender 仍 0(符合预期)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent a19bbf3e
......@@ -47,6 +47,7 @@ export class PotentialTreatmentSelector {
const rows = await this.prisma.$queryRaw<RawGapRow[]>`
SELECT
sig.id AS fact_id,
sig.content->>'code' AS code,
sig.content->>'name_zh' AS name_zh,
sig.type AS signal_type,
......@@ -71,6 +72,7 @@ export class PotentialTreatmentSelector {
for (const r of rows) {
out.push({
primaryCode,
factId: r.fact_id,
code: r.code,
nameZh: r.name_zh ?? null,
tooth: r.tooth ?? null,
......@@ -90,6 +92,7 @@ export class PotentialTreatmentSelector {
export interface PotentialGap {
primaryCode: string; // K08 / K02 / ...
factId: string; // 触发该 gap 的 diagnosis/recommendation fact id(证据链)
code: string; // 实际触发码(K08 / IMPLANT_RECOMMENDED ...)
nameZh: string | null; // 诊断中文名(K03 拆 拔牙/修复 用)
tooth: string | null; // 剩余未治牙位(';' 分隔;全口码为 null)
......@@ -99,6 +102,7 @@ export interface PotentialGap {
}
interface RawGapRow {
fact_id: string;
code: string;
name_zh: string | null;
signal_type: string;
......
......@@ -30,7 +30,7 @@ export class AcquisitionChannelFeatureExtractor implements FeatureExtractor {
score: null,
data: { channel, label, sub },
// 来自副表立柱(源自 fact_client_out,非 fact 证据链)
evidence: { factIds: [] },
evidence: { factIds: [] }, // 证据=patient_profiles 副表立柱(非 fact)
};
}
}
......@@ -69,7 +69,7 @@ export class AgeBracketFeatureExtractor implements FeatureExtractor {
ageYears: age,
},
// 年龄来自 patient 主档 birthDate(非 fact),无 fact 证据
evidence: { factIds: [] },
evidence: { factIds: [] }, // 证据=patient.birthDate(主档,非 fact)
};
}
}
......@@ -59,7 +59,7 @@ export class ContraindicationFeatureExtractor implements FeatureExtractor {
description: types.map((t) => `${t.zh}(${t.reason})`).join(' / '),
score: null,
data: { types: types.map((t) => t.code), labels: types.map((t) => t.zh), detail: types },
evidence: { factIds: [] },
evidence: { factIds: [] }, // v1 证据=patient.birthDate(年龄禁忌,非 fact)
};
}
}
......@@ -35,6 +35,7 @@ export class DiscountAnchorFeatureExtractor implements FeatureExtractor {
let project: string | null = null;
let anchorAmount = 0;
let anchorDiscount = 0;
let anchorFactId: string | null = null;
for (const f of pays) {
const c = (f.content ?? {}) as Record<string, unknown>;
......@@ -49,6 +50,7 @@ export class DiscountAnchorFeatureExtractor implements FeatureExtractor {
project = String(c.settlement_project ?? '').trim() || null;
anchorAmount = amount;
anchorDiscount = disc;
anchorFactId = f.id;
}
}
if (!Number.isFinite(minRatio)) return null; // 从无折扣 → 无锚点
......@@ -67,7 +69,7 @@ export class DiscountAnchorFeatureExtractor implements FeatureExtractor {
anchorAmountCents: anchorAmount,
anchorDiscountCents: anchorDiscount,
},
evidence: { factIds: [] },
evidence: { factIds: anchorFactId ? [anchorFactId] : [] },
};
}
}
......@@ -33,7 +33,7 @@ export class GenderFeatureExtractor implements FeatureExtractor {
score: null,
data: { gender: g.code, label: g.zh },
// 来自 patient 主档 gender(非 fact)
evidence: { factIds: [] },
evidence: { factIds: [] }, // 证据=patient.gender(主档,非 fact)
};
}
}
......@@ -134,7 +134,17 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor {
netThisCents: netThis,
hasAppt,
},
evidence: { factIds: [] },
// 证据 = 驱动分期的就诊 + 消费事实(去重)
evidence: {
factIds: [
...new Set([
...visitFacts.map((f) => f.id),
...pays.map((f) => f.id),
...recharges.map((f) => f.id),
...refunds.map((f) => f.id),
]),
],
},
};
}
}
......@@ -85,9 +85,11 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
string,
{ zh: string; teeth: Set<string>; daysSince: number; confidence: number; hasDx: boolean; hasRec: boolean }
>();
const factIds = new Set<string>();
for (const g of gaps) {
const lbl = classifyGapToLabel(g, age);
if (!lbl) continue;
factIds.add(g.factId);
const cur =
agg.get(lbl.key) ??
{ zh: lbl.zh, teeth: new Set<string>(), daysSince: 0, confidence: 0, hasDx: false, hasRec: false };
......@@ -122,7 +124,7 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
description: labels.join(' / '),
score: null,
data: { types: keys, labels, detail },
evidence: { factIds: [] },
evidence: { factIds: [...factIds] },
};
}
}
......@@ -42,24 +42,33 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor {
let noShow = 0;
let arrivalRecs = 0;
let lateRecs = 0;
const noShowIds: string[] = [];
const lateIds: string[] = [];
for (const f of appts) {
const c = (f.content ?? {}) as Record<string, unknown>;
const planned = f.plannedFor ?? null;
if (!planned || planned.getTime() < yearAgo) continue;
const status = String(c.status ?? '');
if (SpecialAttentionFeatureExtractor.ATTENDED.has(status)) attended++;
else if (status === 'no_show') noShow++;
else if (status === 'no_show') {
noShow++;
noShowIds.push(f.id);
}
// 迟到:到店时间(+8h 修正摄入 TZ bug)vs 预约时间
const arrRaw = c.arrived_at;
if (arrRaw) {
const arr = new Date(String(arrRaw)).getTime() + SpecialAttentionFeatureExtractor.ARRIVED_TZ_FIX_MS;
if (Number.isFinite(arr)) {
arrivalRecs++;
if (arr - planned.getTime() > SpecialAttentionFeatureExtractor.LATE_MS) lateRecs++;
if (arr - planned.getTime() > SpecialAttentionFeatureExtractor.LATE_MS) {
lateRecs++;
lateIds.push(f.id);
}
}
}
}
const decided = attended + noShow;
const evidenceIds = new Set<string>();
const codes: string[] = [];
const labels: string[] = [];
......@@ -67,19 +76,28 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor {
codes.push(c);
labels.push(z);
};
if (decided >= 3 && attended / decided < 0.5) add('frequent_no_show', '屡次爽约');
if (arrivalRecs >= 3 && lateRecs / arrivalRecs >= 0.5) add('often_late', '经常迟到');
if (ctx.profile?.doNotContact) add('do_not_disturb', '免打扰');
if (decided >= 3 && attended / decided < 0.5) {
add('frequent_no_show', '屡次爽约');
noShowIds.forEach((id) => evidenceIds.add(id));
}
if (arrivalRecs >= 3 && lateRecs / arrivalRecs >= 0.5) {
add('often_late', '经常迟到');
lateIds.forEach((id) => evidenceIds.add(id));
}
if (ctx.profile?.doNotContact) add('do_not_disturb', '免打扰'); // 来自 profile,非 fact
// 不可等候:notes / tags / 病历自由文本关键词
const emrText = (ctx.factsByType.get(FactType.EMR_RECORD) ?? [])
.map((f) => {
const c = (f.content ?? {}) as Record<string, unknown>;
return [c.illness_desc, c.diagnosis_text, c.treatment_plan, c.doctor_advice, c.notes].join(' ');
})
.join(' ');
const hay = `${ctx.profile?.notes ?? ''} ${(ctx.profile?.tags ?? []).join(' ')} ${emrText}`;
if (SpecialAttentionFeatureExtractor.WAIT_KW.some((k) => hay.includes(k))) add('cannot_wait', '不可等候');
// 不可等候:notes / tags / 病历自由文本关键词(命中的 emr fact 入证据)
const profileHay = `${ctx.profile?.notes ?? ''} ${(ctx.profile?.tags ?? []).join(' ')}`;
let waitHit = SpecialAttentionFeatureExtractor.WAIT_KW.some((k) => profileHay.includes(k));
for (const f of ctx.factsByType.get(FactType.EMR_RECORD) ?? []) {
const c = (f.content ?? {}) as Record<string, unknown>;
const text = [c.illness_desc, c.diagnosis_text, c.treatment_plan, c.doctor_advice, c.notes].join(' ');
if (SpecialAttentionFeatureExtractor.WAIT_KW.some((k) => text.includes(k))) {
waitHit = true;
evidenceIds.add(f.id);
}
}
if (waitHit) add('cannot_wait', '不可等候');
if (codes.length === 0) return null;
return {
......@@ -94,7 +112,7 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor {
lateRate: arrivalRecs > 0 ? Math.round((100 * lateRecs) / arrivalRecs) : null,
arrivalRecs,
},
evidence: { factIds: [] },
evidence: { factIds: [...evidenceIds] },
};
}
}
......@@ -38,10 +38,12 @@ export class TimePreferenceFeatureExtractor implements FeatureExtractor {
let afternoon = 0;
let evening = 0;
let periodTotal = 0; // 落在 8-21 的记录(算时段占比的分母)
const factIds: string[] = [];
for (const f of appts) {
const at = f.plannedFor ?? f.occurredAt;
if (!at || at.getTime() < since) continue;
total++;
factIds.push(f.id);
const bj = new Date(at.getTime() + TimePreferenceFeatureExtractor.TZ_OFFSET_MS);
const dow = bj.getUTCDay(); // 0=周日..6=周六
if (dow === 0 || dow === 6) weekend++;
......@@ -90,7 +92,7 @@ export class TimePreferenceFeatureExtractor implements FeatureExtractor {
afternoonPct: pct(afternoon, periodTotal),
eveningPct: pct(evening, periodTotal),
},
evidence: { factIds: [] },
evidence: { factIds },
};
}
}
......@@ -40,31 +40,31 @@ export class TreatmentSensitivityFeatureExtractor implements FeatureExtractor {
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const emrs = (ctx.factsByType.get(FactType.EMR_RECORD) ?? []) as ActiveFact[];
const parts: string[] = [];
for (const f of emrs) {
// per-fact 文本(保留 id,供命中 fact 入证据)
const emrTexts: Array<{ id: string; text: string }> = emrs.map((f) => {
const c = (f.content ?? {}) as Record<string, unknown>;
parts.push(
String(c.illness_desc ?? ''),
String(c.pre_illness ?? ''),
String(c.past_history ?? ''),
String(c.general_condition ?? ''),
String(c.exam_findings ?? ''),
String(c.diagnosis_text ?? ''),
String(c.treatment_plan ?? ''),
String(c.doctor_advice ?? ''),
String(c.disposal ?? ''),
);
}
parts.push(ctx.profile?.notes ?? '', (ctx.profile?.tags ?? []).join(' '));
const hay = parts.join(' ');
return {
id: f.id,
text: [
c.illness_desc, c.pre_illness, c.past_history, c.general_condition,
c.exam_findings, c.diagnosis_text, c.treatment_plan, c.doctor_advice, c.disposal,
].map((v) => String(v ?? '')).join(' '),
};
});
const profileText = `${ctx.profile?.notes ?? ''} ${(ctx.profile?.tags ?? []).join(' ')}`;
const hay = `${emrTexts.map((t) => t.text).join(' ')} ${profileText}`;
if (!hay.trim()) return null;
const codes: string[] = [];
const labels: string[] = [];
const evidenceIds = new Set<string>();
for (const r of TreatmentSensitivityFeatureExtractor.RULES) {
if (r.kw.some((k) => hay.includes(k))) {
codes.push(r.code);
labels.push(r.zh);
if (!r.kw.some((k) => hay.includes(k))) continue;
codes.push(r.code);
labels.push(r.zh);
// 命中的 emr fact 入证据(profile 命中无 fact id)
for (const t of emrTexts) {
if (r.kw.some((k) => t.text.includes(k))) evidenceIds.add(t.id);
}
}
if (codes.length === 0) return null;
......@@ -74,7 +74,7 @@ export class TreatmentSensitivityFeatureExtractor implements FeatureExtractor {
description: labels.join(' / '),
score: null,
data: { types: codes, labels },
evidence: { factIds: [] },
evidence: { factIds: [...evidenceIds] },
};
}
}
......@@ -34,9 +34,13 @@ export class UrgencyLevelFeatureExtractor implements FeatureExtractor {
// K00/K09/年龄外的 gap 不算"潜在治疗路径"待处理项 → 不计急迫)。
const age = ageYearsAt(ctx.patient.birthDate, ctx.now);
const labelKeys = new Set<string>();
const factIds = new Set<string>();
for (const g of ctx.potentialGaps ?? []) {
const lbl = classifyGapToLabel(g, age);
if (lbl) labelKeys.add(lbl.key);
if (lbl) {
labelKeys.add(lbl.key);
factIds.add(g.factId);
}
}
if (labelKeys.size === 0) return null; // 无潜在待转(8 标签)→ 此路径无急迫
const gaps = ctx.potentialGaps ?? [];
......@@ -49,11 +53,16 @@ export class UrgencyLevelFeatureExtractor implements FeatureExtractor {
...get(FactType.VISIT_REGISTRATION_RECORD),
];
let last: number | null = null;
let lastFactId: string | null = null;
for (const f of visitFacts) {
if (!f.occurredAt) continue;
const t = f.occurredAt.getTime();
if (last === null || t > last) last = t;
if (last === null || t > last) {
last = t;
lastFactId = f.id;
}
}
if (lastFactId) factIds.add(lastFactId);
const now = ctx.now.getTime();
// 无就诊记录(罕见,有诊断必有诊)→ 回退用 gap 最早诊断天数
const lastDays =
......@@ -80,7 +89,7 @@ export class UrgencyLevelFeatureExtractor implements FeatureExtractor {
description: `${zh} · 末诊${lastDays}天 · ${pendingLabels} 类潜在治疗`,
score: null,
data: { level: code, levelZh: zh, lastVisitDays: lastDays, pendingTypes: pendingLabels, path: 'potential_treatment' },
evidence: { factIds: [] },
evidence: { factIds: [...factIds] },
};
}
}
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