Commit f138e5a3 by luoqi

feat: 隐藏面向用户不该看的内部细节(RFM 原始分/标题/oracle 对账)

按反馈,详情页去掉给客服看没意义的开发/QA 细节:
- RFM:① hover 副标题去掉'→ 八象限'(术语);② 描述去掉 R5F2M1 原始 R/F/M 分
  (rfm.feature.ts;R/F/M 仍留 data 供内部/圈人群用)。
- 画像标签:去掉'画像标签 / 详情→'标题行,身份卡里直接列 chip(无标题、无 drawer 入口)。
- 牙位事实抽屉:移除 oracle 召回对账面板 + 每泳道 oracle 徽标 + 子标题'oracle 召回对账'
  (这是 dev/QA 差分验证,不该给客服看;验证走 verify-recall.sql)。
  tooth-timeline 删 ReconPanel/ReconBadge + recall-oracle import;drawer 去 reasons 传参。
- web tsc 通过;本地 905 --force 重算,rfm 描述已无 RxFxMx。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent d45b0c86
...@@ -179,7 +179,7 @@ export class RfmFeatureExtractor implements FeatureExtractor { ...@@ -179,7 +179,7 @@ export class RfmFeatureExtractor implements FeatureExtractor {
return { return {
key: this.key, key: this.key,
description: `${seg.zh} · ${recencyStr} · 就诊${freqCount}次 · 累计¥${yuan} · R${rScore}F${fScore}M${mScore}`, description: `${seg.zh} · ${recencyStr} · 就诊${freqCount}次 · 累计¥${yuan}`,
// score 列已弃用语义(场景从 data 自算分);此处留空 // score 列已弃用语义(场景从 data 自算分);此处留空
score: null, score: null,
data: { data: {
......
...@@ -94,8 +94,8 @@ export function Drawer({ ...@@ -94,8 +94,8 @@ export function Drawer({
width = 'w-[640px]'; width = 'w-[640px]';
} else if (kind === 'teeth') { } else if (kind === 'teeth') {
title = '牙位事实'; title = '牙位事实';
subtitle = '每颗牙 / 全口治疗线 · 时间倒序 · oracle 召回对账'; subtitle = '每颗牙 / 全口治疗线 · 时间倒序';
body = <ToothTimeline facts={facts} reasons={reasons} />; body = <ToothTimeline facts={facts} />;
width = 'w-[560px]'; width = 'w-[560px]';
} else if (kind === 'persona') { } else if (kind === 'persona') {
title = '患者画像'; title = '患者画像';
......
...@@ -80,7 +80,7 @@ interface AlgoMeta { ...@@ -80,7 +80,7 @@ interface AlgoMeta {
const ALGORITHMS: Record<string, AlgoMeta> = { const ALGORITHMS: Record<string, AlgoMeta> = {
[PersonaFeatureKey.RFM]: { [PersonaFeatureKey.RFM]: {
title: '价值分群 (RFM)', title: '价值分群 (RFM)',
subtitle: 'R 最近 · F 频次 · M 金额 → 八象限', subtitle: 'R 最近 · F 频次 · M 金额',
formula: 'R(就诊间隔)+ F(就诊次)+ M(净消费分位)各 1-5 分', formula: 'R(就诊间隔)+ F(就诊次)+ M(净消费分位)各 1-5 分',
rules: [ rules: [
{ label: '重要价值', body: 'R≥4 F≥3 M≥4 — 近期高频高额' }, { label: '重要价值', body: 'R≥4 F≥3 M≥4 — 近期高频高额' },
......
...@@ -311,7 +311,6 @@ export function PlanDetailApp({ ...@@ -311,7 +311,6 @@ export function PlanDetailApp({
<IdentityCard <IdentityCard
patient={patient} patient={patient}
features={persona.features} features={persona.features}
onOpenPersona={() => setDrawerOpen('persona')}
onOpenImage={() => onOpenImage={() =>
showToast('slate', '影像调阅', '跳转宿主页面') showToast('slate', '影像调阅', '跳转宿主页面')
} }
...@@ -925,13 +924,11 @@ function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) { ...@@ -925,13 +924,11 @@ function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) {
function IdentityCard({ function IdentityCard({
patient, patient,
features, features,
onOpenPersona,
onOpenImage, onOpenImage,
onOpenProfile, onOpenProfile,
}: { }: {
patient: typeof mockPatient; patient: typeof mockPatient;
features: typeof mockPersona.features; features: typeof mockPersona.features;
onOpenPersona: () => void;
onOpenImage: () => void; onOpenImage: () => void;
onOpenProfile: () => void; onOpenProfile: () => void;
}) { }) {
...@@ -1069,15 +1066,9 @@ function IdentityCard({ ...@@ -1069,15 +1066,9 @@ function IdentityCard({
</span> </span>
)} )}
</div> </div>
{/* 画像标签(原独立卡片并入此处)— hover 看"是什么 + 怎么算的" */} {/* 画像标签(并入身份卡)— hover 看"是什么 + 怎么算的";无标题/详情入口(内部细节不外露)*/}
{features.length > 0 && ( {features.length > 0 && (
<div className="mt-2 pt-2 border-t border-slate-100"> <div className="mt-2 pt-2 border-t border-slate-100">
<div className="mb-1 flex items-center justify-between">
<span className="text-[10px] font-medium text-slate-400">画像标签</span>
<button onClick={onOpenPersona} className="text-[10px] text-teal-700 hover:underline">
详情 →
</button>
</div>
<PersonaTagCloud features={features} /> <PersonaTagCloud features={features} />
</div> </div>
)} )}
......
...@@ -3,14 +3,6 @@ ...@@ -3,14 +3,6 @@
import { diagnosisCodeNameZh, treatmentCategoryNameZh, lookupDxTreatment } from '@pac/types'; import { diagnosisCodeNameZh, treatmentCategoryNameZh, lookupDxTreatment } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
import type { PlanReason } from './mock-data';
import {
runRecallOracle,
reconcile,
verdictMeta,
type OracleVerdict,
type ReconRow,
} from './recall-oracle';
/** /**
* ToothTimeline — 每颗牙(+ 全口治疗线)的事实时间轴 * ToothTimeline — 每颗牙(+ 全口治疗线)的事实时间轴
...@@ -31,31 +23,12 @@ const WHOLE_PERIO = '全口 · 牙周'; ...@@ -31,31 +23,12 @@ const WHOLE_PERIO = '全口 · 牙周';
const WHOLE_ORTHO = '全口 · 正畸'; const WHOLE_ORTHO = '全口 · 正畸';
const WHOLE_OTHER = '全口 · 其他'; const WHOLE_OTHER = '全口 · 其他';
export function ToothTimeline({ export function ToothTimeline({ facts }: { facts: AdaptedFact[] }) {
facts,
reasons = [],
}: {
facts: AdaptedFact[];
/// 生产召回结果(plan.reasons)— 传入则跑独立 oracle 做对账验证
reasons?: PlanReason[];
}) {
const clinical = facts.filter((f) => CLINICAL_TYPES.has(f.type)); const clinical = facts.filter((f) => CLINICAL_TYPES.has(f.type));
if (clinical.length === 0) { if (clinical.length === 0) {
return <div className="text-center py-12 text-sm text-slate-400">无牙位事实</div>; return <div className="text-center py-12 text-sm text-slate-400">无牙位事实</div>;
} }
// ── 独立 oracle:重算每颗牙/全口的召回判定,跟生产 reasons 对账 ──
const verdicts = runRecallOracle(facts, new Date());
const prodSignals = reasons.map((r) => r.signals ?? null);
const recon = reconcile(verdicts, prodSignals);
// 每条泳道的 oracle 判定(供泳道头展示徽标)
const verdictsByLane = new Map<string, OracleVerdict[]>();
for (const v of verdicts) {
const arr = verdictsByLane.get(v.laneKey) ?? [];
arr.push(v);
verdictsByLane.set(v.laneKey, arr);
}
// 分桶 // 分桶
const lanes = new Map<string, AdaptedFact[]>(); const lanes = new Map<string, AdaptedFact[]>();
const push = (k: string, f: AdaptedFact) => { const push = (k: string, f: AdaptedFact) => {
...@@ -102,13 +75,11 @@ export function ToothTimeline({ ...@@ -102,13 +75,11 @@ export function ToothTimeline({
return ( return (
<div className="space-y-2.5"> <div className="space-y-2.5">
<ReconPanel recon={recon} />
{orderedKeys.map((k) => { {orderedKeys.map((k) => {
const rows = [...lanes.get(k)!].sort( const rows = [...lanes.get(k)!].sort(
(a, b) => tkey(b) - tkey(a) || typeRank(b) - typeRank(a), (a, b) => tkey(b) - tkey(a) || typeRank(b) - typeRank(a),
); );
const isWhole = k.startsWith('全口'); const isWhole = k.startsWith('全口');
const laneVerdicts = verdictsByLane.get(k) ?? [];
return ( return (
<div key={k} className="rounded-lg border border-slate-200 overflow-hidden"> <div key={k} className="rounded-lg border border-slate-200 overflow-hidden">
<div <div
...@@ -118,20 +89,6 @@ export function ToothTimeline({ ...@@ -118,20 +89,6 @@ export function ToothTimeline({
)} )}
> >
<span className="tabular-nums">{isWhole ? k : `牙位 ${k}`}</span> <span className="tabular-nums">{isWhole ? k : `牙位 ${k}`}</span>
{laneVerdicts.map((v, i) => (
<span
key={`${v.subKey}-${i}`}
title={`${v.subLabel} · ${diagnosisCodeNameZh(v.code) || v.code} — ${v.detail}`}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-px rounded border text-[10px] font-medium',
verdictMeta(v.kind).tone,
)}
>
{v.kind === 'recall' && <span className="w-1 h-1 rounded-full bg-emerald-500" />}
{verdictMeta(v.kind).zh}
<span className="font-normal opacity-70">{v.subLabel}</span>
</span>
))}
<span className="ml-auto text-[10px] font-normal text-slate-400">{rows.length}</span> <span className="ml-auto text-[10px] font-normal text-slate-400">{rows.length}</span>
</div> </div>
<div className="divide-y divide-slate-50"> <div className="divide-y divide-slate-50">
...@@ -146,80 +103,6 @@ export function ToothTimeline({ ...@@ -146,80 +103,6 @@ export function ToothTimeline({
); );
} }
/// 对账面板:oracle(独立第二实现)vs 生产 plan_reasons。分歧 = bug 捕获点。
function ReconPanel({
recon,
}: {
recon: { rows: ReconRow[]; agree: number; oracleOnly: number; prodOnly: number };
}) {
const { rows, agree, oracleOnly, prodOnly } = recon;
const hasDiff = oracleOnly > 0 || prodOnly > 0;
return (
<div
className={cn(
'rounded-lg border overflow-hidden',
hasDiff ? 'border-rose-200' : 'border-emerald-200',
)}
>
<div
className={cn(
'flex items-center gap-2 px-2.5 py-1.5 text-[11.5px] font-semibold border-b',
hasDiff
? 'bg-rose-50 text-rose-800 border-rose-100'
: 'bg-emerald-50 text-emerald-800 border-emerald-100',
)}
>
<span>召回对账</span>
<span className="font-normal text-[10.5px] opacity-70">oracle 独立重算 vs 生产</span>
<span className="ml-auto flex items-center gap-2 tabular-nums">
<span className="text-emerald-700">{agree}</span>
<span className={cn(prodOnly > 0 ? 'text-rose-700' : 'text-slate-400')}>
⚠ 仅生产 {prodOnly}
</span>
<span className={cn(oracleOnly > 0 ? 'text-amber-700' : 'text-slate-400')}>
⚠ 仅oracle {oracleOnly}
</span>
</span>
</div>
{rows.length === 0 ? (
<div className="px-2.5 py-2 text-[11px] text-slate-400">无召回信号(两边均无)</div>
) : (
<div className="divide-y divide-slate-50">
{rows.map((r) => (
<div key={r.key} className="flex items-center gap-2 px-2.5 py-1.5 text-[11.5px]">
<span className="w-[88px] flex-none tabular-nums text-slate-600">{r.toothLabel}</span>
<span className="flex-none text-slate-800">{r.subLabel}</span>
<span className="flex-1 min-w-0 truncate text-[10.5px] text-slate-400" title={r.oracle?.detail ?? ''}>
{r.oracle?.detail ?? '生产召回,oracle 无对应信号(诊断可能已被取代)'}
</span>
<ReconBadge mark={r.mark} />
</div>
))}
</div>
)}
</div>
);
}
function ReconBadge({ mark }: { mark: ReconRow['mark'] }) {
const meta =
mark === 'agree'
? { zh: '✓ 一致', tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }
: mark === 'prod_only'
? { zh: '⚠ 仅生产', tone: 'bg-rose-50 text-rose-700 border-rose-200' }
: { zh: '⚠ 仅oracle', tone: 'bg-amber-50 text-amber-700 border-amber-200' };
return (
<span
className={cn(
'flex-none px-1.5 py-px rounded border text-[10px] font-medium whitespace-nowrap',
meta.tone,
)}
>
{meta.zh}
</span>
);
}
function ToothFactRow({ fact }: { fact: AdaptedFact }) { function ToothFactRow({ fact }: { fact: AdaptedFact }) {
const tIso = fact.occurredAt ?? fact.plannedFor; const tIso = fact.occurredAt ?? fact.plannedFor;
const planned = !fact.occurredAt && !!fact.plannedFor; const planned = !fact.occurredAt && !!fact.plannedFor;
......
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