Commit 73b703e9 by luoqi

fix(plan-detail): 亲戚关系方向按年龄实时纠正 —— FRIDAY 侧 97% 的父母/子女方向是反的

## 测试服全量实证(不是本地样本)
jvs-dw  43,279 条亲戚边,与年龄矛盾 428 条 → 1.0%,方向基本可信
friday   2,427 条;只看父母/子女**对**更刺眼:
         motherchild 326 对里只有 8 对方向对(2.5%)
         fatherchild 231 对里只有 9 对(3.9%)   → 97% 反了

## 根因
data/friday/assemblers/patient_relation.yaml 的 enum_mapping 假设源码语义是
「本人是对方的 X」,于是映射时取了**逆关系**(码2 爸爸 → child)。数据说这个假设是错的:
FRIDAY 存的码本来就是「对方是本人的 X」,跟 PAC 契约同向 —— 多反转了一次。
互反边可以证:秦佳(47)--mother-->韩秦瑜(21) 与 韩秦瑜--child-->秦佳 两条边互相自洽、
但都与年龄矛盾;去掉那次反转两条就都对了。
️ 摄入侧的修法(改 yaml + 重摄该资源)另开一件事 —— patient_relation 是 upsert 资源、
   不进 transaction,没有原文可 reparse,得从源重拉。

## 这次做的是展示侧:年龄定方向,不降级
按你的要求不降级成「亲属」,而是**实时算出真实关系**:
  · 标成长辈但对方更年轻 → 子女 / 孙辈
  · 标成晚辈但对方更年长 → 按对方性别拆父/母(性别缺 → 中性「父母」,不硬猜)
  · 配偶 / 兄弟姐妹是对称关系,没有方向可纠,原样返回(同岁配偶很正常,不许被误标)
  · 任一方缺生日 → 原样返回,不猜(会显示的边 98.8% 两边都有生日)
纠正过的在关系后打一个 `*`,hover 显示「源数据记的是「mother」,与双方年龄不符,已按年龄纠正」——
客服跟宿主对账时能看出差异在哪,而不是以为 PAC 显示错了。载荷同时留 relationshipRaw。

口径收在 @pac/types/kin-relationship.ts(纯函数,附实证数字),11 条用例锁住,
其中"同岁也算矛盾""配偶不许被纠""缺生日不猜"三条是最容易被后来人改坏的。

本地实测(王红兵 58 岁):源里的「妈妈 王迪 31岁」现在显示「子女* · 31岁」,配偶 59 岁不动。
717 tests / 47 suites + 两端 typecheck 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 6a7e0bdf
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { calcAge, maskName, maskPhone } from '@pac/utils'; import { calcAge, maskName, maskPhone } from '@pac/utils';
import { applyLiveDays, ApiCode, KIN_RELATIONSHIPS } from '@pac/types'; import { applyLiveDays, ApiCode, KIN_RELATIONSHIPS, resolveKinRelationship } from '@pac/types';
import { BizError } from '../../common/errors/biz-error'; import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service'; import { ChainComposerService } from '../plan/engine/chain-composer.service';
...@@ -70,6 +70,7 @@ export class PlanAggregateService { ...@@ -70,6 +70,7 @@ export class PlanAggregateService {
name: true, name: true,
phone: true, phone: true,
birthDate: true, birthDate: true,
gender: true,
}, },
}, },
}, },
...@@ -130,6 +131,7 @@ export class PlanAggregateService { ...@@ -130,6 +131,7 @@ export class PlanAggregateService {
name: true; name: true;
phone: true; phone: true;
birthDate: true; birthDate: true;
gender: true;
}; };
}; };
}; };
...@@ -415,6 +417,7 @@ function serializeProfile( ...@@ -415,6 +417,7 @@ function serializeProfile(
name: string | null; name: string | null;
phone: string | null; phone: string | null;
birthDate: Date | null; birthDate: Date | null;
gender: string | null;
} | null; } | null;
}>; }>;
}, },
...@@ -458,21 +461,37 @@ function serializeProfile( ...@@ -458,21 +461,37 @@ function serializeProfile(
// friend / other 那两类里混的是推荐人噪音(other 占了边表一半以上),列出来客服会误当家属。 // friend / other 那两类里混的是推荐人噪音(other 占了边表一半以上),列出来客服会误当家属。
// ⭐ planId:关系人在**本 scope 内**能打开的工单;没有(没建工单 / 不在数据范围)→ null, // ⭐ planId:关系人在**本 scope 内**能打开的工单;没有(没建工单 / 不在数据范围)→ null,
// 前端就不给链接,免得点进去 404。 // 前端就不给链接,免得点进去 404。
const selfAge = patient.birthDate ? calcAge(patient.birthDate) : null;
const contacts = (patient.relationsOut ?? []) const contacts = (patient.relationsOut ?? [])
.map((r) => ({ .map((r) => {
relationship: r.relationship, const relAge = r.relatedPatient?.birthDate ? calcAge(r.relatedPatient.birthDate) : null;
relationshipLabel: RELATIONSHIP_LABEL[r.relationship] ?? r.relationship, // ⭐ 关系方向按**年龄实时判**,不信源里填的方向 —— FRIDAY 侧 97% 的父母/子女对是反的
// (根因在 data/friday/assemblers/patient_relation.yaml 多做了一次逆关系映射,摄入侧要修;
// 展示侧不能等:说错亲属关系,客服在通话中会当场出丑)。口径见 resolveKinRelationship。
const { relationship, ageCorrected } = resolveKinRelationship(
r.relationship,
selfAge,
relAge,
r.relatedPatient?.gender ?? null,
);
return {
relationship,
relationshipLabel: RELATIONSHIP_LABEL[relationship] ?? relationship,
/** 源数据原样的关系码 —— 纠正过时留着,排查 / 跟宿主对账用 */
relationshipRaw: r.relationship,
ageCorrected,
name: r.relatedPatient?.name ?? null, name: r.relatedPatient?.name ?? null,
phone: r.relatedPatient?.phone ?? null, phone: r.relatedPatient?.phone ?? null,
linked: !!r.relatedPatientId, linked: !!r.relatedPatientId,
isKin: KIN_RELATIONSHIPS.includes(r.relationship), isKin: KIN_RELATIONSHIPS.includes(relationship),
age: r.relatedPatient?.birthDate ? calcAge(r.relatedPatient.birthDate) : null, age: relAge,
relatedPatientId: r.relatedPatientId, relatedPatientId: r.relatedPatientId,
// 宿主 VIEW_PATIENT 槽位的占位值 —— 前端配了就跳宿主档案,没配才回落 PAC 工单页 // 宿主 VIEW_PATIENT 槽位的占位值 —— 前端配了就跳宿主档案,没配才回落 PAC 工单页
externalId: r.relatedPatient?.externalId ?? r.relatedExternalId, externalId: r.relatedPatient?.externalId ?? r.relatedExternalId,
medicalRecordNumber: r.relatedPatient?.medicalRecordNumber ?? null, medicalRecordNumber: r.relatedPatient?.medicalRecordNumber ?? null,
planId: r.relatedPatientId ? (relatedPlanIdByPatient.get(r.relatedPatientId) ?? null) : null, planId: r.relatedPatientId ? (relatedPlanIdByPatient.get(r.relatedPatientId) ?? null) : null,
})) };
})
.sort((a, b) => Number(b.linked) - Number(a.linked)); .sort((a, b) => Number(b.linked) - Number(a.linked));
// ⚠️⚠️ TEST ONLY ⚠️⚠️ 监护人手机 fallback(详见 pickGuardianTestOnly 注释)。 // ⚠️⚠️ TEST ONLY ⚠️⚠️ 监护人手机 fallback(详见 pickGuardianTestOnly 注释)。
...@@ -540,6 +559,7 @@ const RELATIONSHIP_LABEL: Record<string, string> = { ...@@ -540,6 +559,7 @@ const RELATIONSHIP_LABEL: Record<string, string> = {
grandparent: '祖辈', grandparent: '祖辈',
spouse: '配偶', spouse: '配偶',
child: '子女', child: '子女',
parent: '父母', // 年龄纠正出来的中性长辈码(关系人性别缺失时用;源数据里没有这个码)
sibling: '兄弟姐妹', sibling: '兄弟姐妹',
grandchild: '孙辈', grandchild: '孙辈',
friend: '朋友', friend: '朋友',
......
import { resolveKinRelationship } from '@pac/types';
/**
* 亲戚关系方向的年龄纠正。
*
* 由来(2026-07-30 测试服全量实证):
* jvs-dw 43,279 条亲戚边里与年龄矛盾 428 条(1.0%),方向基本可信;
* friday 只看父母/子女**对**:mother↔child 326 对里只有 8 对方向是对的(2.5%)、
* father↔child 231 对里只有 9 对(3.9%)—— **97% 反了**。
* 根因在 data/friday/assemblers/patient_relation.yaml 多做了一次逆关系映射。
*
* 用例锁的是这条纪律:**年龄说了算,但只在能判的时候判**。
* 说错亲属关系(把女儿说成妈妈)客服在通话中会当场出丑,比不显示更糟。
*/
describe('resolveKinRelationship — 年龄定方向', () => {
test('⭐ 标成妈妈但对方更年轻 → 纠正成子女(实测样本:秦佳47 / "妈妈"韩秦瑜21)', () => {
expect(resolveKinRelationship('mother', 47, 21, '女')).toEqual({
relationship: 'child',
ageCorrected: true,
});
});
test('⭐ 标成子女但对方更年长 → 按对方性别纠成父/母(实测样本:韩秦瑜21 / "子女"秦佳47)', () => {
expect(resolveKinRelationship('child', 21, 47, '女')).toEqual({
relationship: 'mother',
ageCorrected: true,
});
expect(resolveKinRelationship('child', 35, 62, '男')).toEqual({
relationship: 'father',
ageCorrected: true,
});
});
test('⭐ 纠成长辈但对方性别缺失 → 中性「parent」,不硬猜父还是母', () => {
expect(resolveKinRelationship('child', 20, 50, null)).toEqual({
relationship: 'parent',
ageCorrected: true,
});
expect(resolveKinRelationship('child', 20, 50, ' ')).toEqual({
relationship: 'parent',
ageCorrected: true,
});
});
test('方向本来就对 → 原样返回,不标纠正', () => {
expect(resolveKinRelationship('mother', 34, 58, '女')).toEqual({
relationship: 'mother',
ageCorrected: false,
});
expect(resolveKinRelationship('child', 56, 7, '男')).toEqual({
relationship: 'child',
ageCorrected: false,
});
});
test('祖辈 / 孙辈 同样纠(阈值只看谁更年长,不额外要求 30 岁差)', () => {
expect(resolveKinRelationship('grandparent', 60, 20).relationship).toBe('grandchild');
expect(resolveKinRelationship('grandchild', 20, 60).relationship).toBe('grandparent');
});
test('⭐ 同龄也算矛盾 —— 父母子女不可能同岁', () => {
expect(resolveKinRelationship('mother', 40, 40, '女').ageCorrected).toBe(true);
expect(resolveKinRelationship('child', 40, 40, '女').ageCorrected).toBe(true);
});
test('⭐ 配偶 / 兄弟姐妹是对称关系,没有方向可纠 —— 同岁配偶很正常,不许被误标', () => {
expect(resolveKinRelationship('spouse', 64, 64, '男')).toEqual({
relationship: 'spouse',
ageCorrected: false,
});
expect(resolveKinRelationship('sibling', 30, 45, '女')).toEqual({
relationship: 'sibling',
ageCorrected: false,
});
});
test('⭐ 任一方缺生日 → 原样返回,不猜(会显示的边有 98.8% 两边都有生日,剩下的宁可不判)', () => {
expect(resolveKinRelationship('mother', null, 21, '女')).toEqual({
relationship: 'mother',
ageCorrected: false,
});
expect(resolveKinRelationship('mother', 47, null, '女')).toEqual({
relationship: 'mother',
ageCorrected: false,
});
});
test('非亲戚码(friend/other)不参与纠正', () => {
expect(resolveKinRelationship('friend', 20, 60)).toEqual({
relationship: 'friend',
ageCorrected: false,
});
expect(resolveKinRelationship('other', 60, 20)).toEqual({
relationship: 'other',
ageCorrected: false,
});
});
});
...@@ -67,6 +67,8 @@ export const mockPatient = { ...@@ -67,6 +67,8 @@ export const mockPatient = {
planId: null, planId: null,
externalId: '5i5ya_PA00999001', externalId: '5i5ya_PA00999001',
medicalRecordNumber: 'SH0Q099001', medicalRecordNumber: 'SH0Q099001',
relationshipRaw: 'mother',
ageCorrected: false,
}, },
] as Array<{ ] as Array<{
relationship: string; relationship: string;
...@@ -83,6 +85,9 @@ export const mockPatient = { ...@@ -83,6 +85,9 @@ export const mockPatient = {
/** 关系人宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位 */ /** 关系人宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位 */
externalId: string; externalId: string;
medicalRecordNumber: string | null; medicalRecordNumber: string | null;
/** 源数据原样关系码 + 是否被年龄纠正过 */
relationshipRaw: string;
ageCorrected: boolean;
}>, }>,
// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback),真实手机号到位前仅演示 // ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback),真实手机号到位前仅演示
guardian: { guardian: {
......
...@@ -1633,8 +1633,18 @@ function RelatedKinRow({ contacts }: { contacts: typeof mockPatient.profile.cont ...@@ -1633,8 +1633,18 @@ function RelatedKinRow({ contacts }: { contacts: typeof mockPatient.profile.cont
className="flex items-center gap-3 rounded-md bg-slate-50 px-2.5 py-1.5 text-[11.5px]" className="flex items-center gap-3 rounded-md bg-slate-50 px-2.5 py-1.5 text-[11.5px]"
> >
<span className="flex-none font-medium text-slate-900">{c.name}</span> <span className="flex-none font-medium text-slate-900">{c.name}</span>
<span className="flex-none text-slate-500"> <span
className="flex-none text-slate-500"
// 被年龄纠正过就把源值挂在 hover 上 —— 客服跟宿主对账时能看出差异在哪,
// 而不是以为 PAC 显示错了(源里 97% 的父母/子女方向是反的,见 resolveKinRelationship)
title={
c.ageCorrected
? `源数据记的是「${c.relationshipRaw}」,与双方年龄不符,已按年龄纠正`
: undefined
}
>
{c.relationshipLabel} {c.relationshipLabel}
{c.ageCorrected && <span className="ml-0.5 text-slate-400">*</span>}
{c.age != null && ` · ${c.age}岁`} {c.age != null && ` · ${c.age}岁`}
</span> </span>
<span className="ml-auto flex-none"> <span className="ml-auto flex-none">
......
...@@ -54,6 +54,10 @@ export type PlanDetailData = { ...@@ -54,6 +54,10 @@ export type PlanDetailData = {
/// 关系人的宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位用(配了就跳宿主档案) /// 关系人的宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位用(配了就跳宿主档案)
externalId: string; externalId: string;
medicalRecordNumber: string | null; medicalRecordNumber: string | null;
/// 源数据原样的关系码;与 relationship 不同 = 被年龄纠正过
relationshipRaw: string;
/// 关系方向被年龄纠正过 —— UI 给一句 hover 说明,让客服知道这不是源数据原文
ageCorrected: boolean;
}>; }>;
/// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback)。真实手机号到位前仅演示用。 /// ⚠️ TEST ONLY — 监护人(儿童/老人触达 fallback)。真实手机号到位前仅演示用。
guardian: { guardian: {
......
...@@ -8,3 +8,4 @@ export * from './clinical-signals'; ...@@ -8,3 +8,4 @@ export * from './clinical-signals';
export * from './visit-recency'; export * from './visit-recency';
export * from './persona-tag-filters'; export * from './persona-tag-filters';
export * from './host-action-message'; export * from './host-action-message';
export * from './kin-relationship';
/**
* 亲戚关系方向的**运行时纠正** —— 按年龄实时判,不信源数据填的方向。
*
* ## 为什么需要(2026-07-30 测试服全量实证)
* `patient_relations.relationship` 的方向在两个宿主上质量差了两个数量级:
*
* jvs-dw(瑞尔) 43,279 条亲戚边,与年龄矛盾 428 条 → **1.0%**,基本可信
* friday 2,427 条亲戚边,矛盾 1,187 条;只看父母/子女**对**更刺眼:
* mother↔child 326 对里只有 8 对方向对(2.5%)
* father↔child 231 对里只有 9 对方向对(3.9%)
* → **97% 是反的**
*
* 根因在 data/friday/assemblers/patient_relation.yaml:那份 enum_mapping 假设源码语义是
* 「本人是对方的 X」,于是映射时取了**逆关系**(码2 爸爸 → child)。数据说这个假设是错的 ——
* FRIDAY 存的码本来就是「对方是本人的 X」,跟 PAC 契约同向,多反转了一次。
* 摄入侧要修(改 yaml + 重摄该资源),但那是另一件事;**展示侧不能等**:
* 跟客服说"这是他妈妈"而实际是女儿,通话中会当场出丑。
*
* ## 做法:年龄定方向
* 生日覆盖率 98.8%(会显示的那批边),所以这道判定几乎总能用上。
* · 标成长辈但对方更年轻 → 实际是晚辈
* · 标成晚辈但对方更年长 → 实际是长辈(按对方性别拆父/母;性别缺 → 「父母」不硬猜)
* · 配偶 / 兄弟姐妹是**对称关系**,没有方向可纠,原样返回
* · 任一方缺生日 → 原样返回(不猜)
*
* 同龄(差 0 岁)也算矛盾:父母子女不可能同岁,而配偶同岁很正常 —— 后者本就不参与判定。
*/
/** 长辈类(关系人比本人年长才合理) */
const ELDER: Record<string, string> = { mother: 'child', father: 'child', grandparent: 'grandchild' };
/** 晚辈类(关系人比本人年轻才合理)→ 纠正后的长辈码由性别决定,见下 */
const YOUNGER = new Set(['child', 'grandchild']);
export interface KinResolution {
/** 纠正后的关系码(可能等于入参) */
relationship: string;
/** 是否被年龄纠正过 —— UI 可据此给一句 hover 说明,让客服知道这不是源数据原文 */
ageCorrected: boolean;
}
/**
* @param relationship 源数据里的关系码
* @param selfAge 本人年龄
* @param relAge 关系人年龄
* @param relGender 关系人性别('男'/'女'/'M'/'F' 等;缺失 → 纠正成通用「父母」)
*/
export function resolveKinRelationship(
relationship: string,
selfAge: number | null | undefined,
relAge: number | null | undefined,
relGender?: string | null,
): KinResolution {
const keep = { relationship, ageCorrected: false };
if (selfAge == null || relAge == null) return keep;
// 长辈类标错 → 降到对应晚辈类(child / grandchild),不需要性别
if (ELDER[relationship] && relAge <= selfAge) {
return { relationship: ELDER[relationship]!, ageCorrected: true };
}
// 晚辈类标错 → 升到长辈类;父/母要看关系人性别,拿不到就用中性的 parent
if (YOUNGER.has(relationship) && relAge >= selfAge) {
if (relationship === 'grandchild') return { relationship: 'grandparent', ageCorrected: true };
const g = (relGender ?? '').trim().toUpperCase();
const code = g === '男' || g === 'M' ? 'father' : g === '女' || g === 'F' ? 'mother' : 'parent';
return { relationship: code, ageCorrected: true };
}
return keep;
}
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