Commit 7f019b26 by luoqi

fix(ai): hydrate patient salutations and correct contact display

parent db03f5cd
Pipeline #3796 failed in 0 seconds
import { PacService } from '../../pac/pac.service';
import { hydrateAddressTraits, rememberPatientTraits } from '../shared/patient-traits';
import { scopeKeys } from '../llm/tool-scope';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { StorageService } from '../../media/storage.service';
import { CorpusService } from '../../corpus/corpus.service';
......@@ -97,6 +99,7 @@ export class CommonContextService {
* ⚠️ 它缓存着 PAC 的「最近就诊诊所」,命中时是纯本地(见 `ServingClinicService` 头注)。
*/
private readonly serving: ServingClinicService,
private readonly pac: PacService,
) {}
async build(
......@@ -366,6 +369,12 @@ export class CommonContextService {
* ⚠️ `speakerVids` 是按"最近在前"排的,所以按它的顺序排就对了。
* 单聊时只有一位,排序无意义但也无害。
*/
await hydrateAddressTraits(links, {
read: (host, ids) => this.pac.getAddressPatientsForAccount(tenantId, conv.accountId, host, ids),
remember: (hostCode, hostPatientId, patient) => rememberPatientTraits(this.prisma,
{ tenantId, hostCode, hostPatientId }, patient, now),
onError: () => Logger.warn('称呼资料补读或缓存失败,沿用已有依据', 'CommonContextService'),
}, now);
const patients: PatientRef[] = bySpeakerOrder(links, speakerVids).map((l) => ({
hostCode: l.hostCode,
hostPatientId: l.hostPatientId,
......@@ -392,7 +401,7 @@ export class CommonContextService {
* ⭐⭐ 算法叫法(罗先生/王女士)—— **姓 + 性别 + 成年**齐了才出,缺一为 null(docs/17 §7.2)。
* ⭐ 和前一天提醒**同一个函数**(`algoAddressFor`,2026-10-04 用户:「尽量同出」):本人的微信,业务系统登记的姓、性别优先,
* 没有再看企微实名 / 企微 sex;家属的微信⛔ 不算。
* ⚠️ 业务系统那几样用 link 上的本地缓存(读到患者资料时回写,见 `rememberPatientTraits`)⇒ ⛔ 没有多一次往返。
* 业务系统资料优先读绑定缓存;缺失时上方按托管号权限补读,并立即用于本轮称呼。
*/
algoAddress: algoAddressFor({
relation: l.relation,
......
......@@ -4,9 +4,9 @@ import { patientTraitsOf } from './patient-address';
/**
* ⭐⭐ 读到患者资料时,把称呼要的三样(姓、性别、年龄段)回写到这位患者的绑定上 —— **唯一**一处写(2026-10-04)。
*
* 两处调用:AI 查患者要点(`get_patient_brief`)、前一天提醒读患者资料(`AppointmentConfirmService.briefsOf`)。
* 都是**本来就读回来了**的那一份 ⇒ ⛔ 不为它多问一次业务系统。
* ⭐ AI 聊天装配「现在的情况」只读本地,靠这几列和提醒用同一套叫法(`algoAddressFor`)。
* 调用方:AI 查患者要点、前一天提醒,以及对话开始时补读缺失的称呼资料。
* 复用已读取的患者卡,只缓存称呼所需派生属性。
* ⭐ AI 聊天和提醒用同一套叫法(`algoAddressFor`)。
*
* ⚠️ 按患者身份写(tenant + host + patient),⛔ 不按 peer_vid:一个患者的所有活动绑定都更新;
* 家属那几条用不上(`algoAddressFor` 对家属⛔ 不算),存着也不会叫错人。
......@@ -31,3 +31,35 @@ export async function rememberPatientTraits(
data,
});
}
/** 首次对话缺少称呼依据时补读患者卡,并在本轮直接使用;不依赖模型选工具。 */
export async function hydrateAddressTraits<T extends {
hostCode: string; hostPatientId: string; relation: string | null;
patientSurname: string | null; patientGender: string | null; patientAgeBand: string | null;
}>(links: T[], deps: {
read: (host: string, ids: string[]) => Promise<Array<{ id: string; name?: string | null; gender?: string | null; birthDate?: string | null }>>;
remember: (host: string, id: string, patient: { name?: string | null; gender?: string | null; birthDate?: string | null }) => Promise<void>;
onError: () => void;
}, now: Date): Promise<void> {
const missing = links.filter(l => (!l.relation || l.relation === 'self') &&
(!l.patientSurname || !['male', 'female'].includes(l.patientGender ?? '') || !['adult', 'child'].includes(l.patientAgeBand ?? '')));
for (const host of new Set(missing.map(l => l.hostCode))) {
const ids = [...new Set(missing.filter(l => l.hostCode === host).map(l => l.hostPatientId))];
for (let i = 0; i < ids.length; i += 100) {
const chunk = ids.slice(i, i + 100);
try {
const patients = await deps.read(host, chunk);
for (const p of patients) {
if (!chunk.includes(p.id)) continue;
const t = patientTraitsOf(p, now);
for (const l of missing.filter(l => l.hostCode === host && l.hostPatientId === p.id)) {
l.patientSurname = t.surname ?? l.patientSurname;
l.patientGender = t.gender ?? l.patientGender;
l.patientAgeBand = t.ageBand ?? l.patientAgeBand;
}
try { await deps.remember(host, p.id, p); } catch { deps.onError(); }
}
} catch { deps.onError(); } // PAC 不可用仍能回应,保留已有依据或使用「您」。
}
}
}
......@@ -200,6 +200,16 @@ export class PacService {
return items[0] ?? null;
}
/** 对话称呼仅补读患者卡;仍按托管号权限和宿主限定。 */
async getAddressPatientsForAccount(tenantId: string, accountId: string, host: string, patientIds: string[]): Promise<PacPatient[]> {
const scope = await this.scope.pacScopeForAccount(tenantId, accountId);
if (scope.host !== host || patientIds.length === 0) return [];
const client = this.require();
return this.call(() => client.getPatients({
host: scope.host, tenantId: scope.tenantId, orgScope: scope.orgScope, patientIds,
}));
}
/**
* ⭐ **系统路径**的患者全貌 —— 给定时扫描用,**没有登录人**。
*
......
import { hydrateAddressTraits } from '../src/modules/agent/shared/patient-traits';
import { algoAddressFor } from '../src/modules/agent/shared/patient-address';
const now = new Date('2026-10-14T04:00:00Z');
const link = () => ({hostCode:'friday',hostPatientId:'p1',relation:'self',patientSurname:null as string|null,patientGender:null as string|null,patientAgeBand:null as string|null});
const patient = {id:'p1',name:'王小华',gender:'F',birthDate:'1989-01-01'};
describe('对话称呼补读', () => {
it('首次对话当轮获得称呼,缓存写失败不丢当前资料', async () => {
const l=link(); const read=jest.fn().mockResolvedValue([patient]); const onError=jest.fn();
await hydrateAddressTraits([l],{read,remember:async()=>{throw Error('db');},onError},now);
expect(read).toHaveBeenCalledWith('friday',['p1']);
expect(algoAddressFor({relation:l.relation,wechat:{realName:null,sex:null},patient:{surname:l.patientSurname,gender:l.patientGender as 'female',ageBand:l.patientAgeBand as 'adult'}})).toBe('王女士');
expect(onError).toHaveBeenCalledTimes(1);
});
it('完整缓存及家属不补读',async()=>{
const read=jest.fn();
await hydrateAddressTraits([{...link(),patientSurname:'王',patientGender:'female',patientAgeBand:'adult'},{...link(),relation:'mother'}],{read,remember:jest.fn(),onError:jest.fn()},now);
expect(read).not.toHaveBeenCalled();
});
it('相同编号跨宿主隔离,未请求患者不写入',async()=>{
const a=link(),b={...link(),hostCode:'jvs-dw'};const remember=jest.fn();
await hydrateAddressTraits([a,b],{read:async host=>host==='friday'?[patient,{...patient,id:'other'}]:[],remember,onError:jest.fn()},now);
expect(a.patientSurname).toBe('王');expect(b.patientSurname).toBeNull();expect(remember).toHaveBeenCalledTimes(1);
});
it('读取失败不阻断,儿童不会生成先生女士',async()=>{
const l=link();const onError=jest.fn();
await hydrateAddressTraits([l],{read:async()=>{throw Error('offline');},remember:jest.fn(),onError},now);
expect(l.patientAgeBand).toBeNull();expect(onError).toHaveBeenCalledTimes(1);
await hydrateAddressTraits([l],{read:async()=>[{...patient,birthDate:'2020-01-01'}],remember:jest.fn(),onError},now);
expect(l.patientAgeBand).toBe('child');
});
});
import { PacService } from '../src/modules/pac/pac.service';
describe('称呼补读 PAC 权限',()=>{
it('沿用托管号范围,跨宿主不发请求',async()=>{
const getPatients=jest.fn().mockResolvedValue([]);
const pacScopeForAccount=jest.fn().mockResolvedValue({host:'friday',tenantId:'data-tenant',orgScope:['clinic-a']});
const service={scope:{pacScopeForAccount},require:()=>({getPatients}),call:(fn:()=>unknown)=>fn()};
await PacService.prototype.getAddressPatientsForAccount.call(service as never,'ai-tenant','account','jvs-dw',['p1']);
expect(getPatients).not.toHaveBeenCalled();
await PacService.prototype.getAddressPatientsForAccount.call(service as never,'ai-tenant','account','friday',['p1']);
expect(pacScopeForAccount).toHaveBeenLastCalledWith('ai-tenant','account');
expect(getPatients).toHaveBeenCalledWith({host:'friday',tenantId:'data-tenant',orgScope:['clinic-a'],patientIds:['p1']});
});
});
......@@ -330,7 +330,7 @@ run('语料 · 真库探针(回滚)', () => {
await raw(tx, id['瑞尔']!, { kind: 'script', slot: 'price', body: '品牌的问价话术' });
await raw(tx, id['北京朝阳公园']!, { kind: 'script', slot: 'price', body: '门店的问价话术' });
const ctxSvc = new CommonContextService(tx as never, { signedUrl: () => null } as never, { recent: () => [] } as never, svc, servingAt(chainBJ()));
const ctxSvc = new CommonContextService(tx as never, { signedUrl: () => null } as never, { recent: () => [] } as never, svc, servingAt(chainBJ()), { getAddressPatientsForAccount: async () => [] } as never);
const ctx = await ctxSvc.build(T, conv.id);
expect(ctx).not.toBeNull();
expect(ctx!.houseRules).toContain('价格与优惠:⛔ 不主动给折扣');
......@@ -360,7 +360,7 @@ run('语料 · 真库探针(回滚)', () => {
await raw(tx, id['瑞尔']!, { kind: 'redline', slot: 'price', body: '不主动给折扣' });
await raw(tx, id['北京朝阳公园']!, { body: '周末只有一位医生' });
const ctxSvc = new CommonContextService(tx as never, { signedUrl: () => null } as never, { recent: () => [] } as never, svc, servingAt(chainBJ()));
const ctxSvc = new CommonContextService(tx as never, { signedUrl: () => null } as never, { recent: () => [] } as never, svc, servingAt(chainBJ()), { getAddressPatientsForAccount: async () => [] } as never);
const ctx = await ctxSvc.build(T, conv.id);
expect(ctx!.knowledgeCard).toBe('');
expect(toolNamesFor(ctx!)).not.toContain(GET_KNOWLEDGE.name);
......@@ -383,7 +383,7 @@ run('语料 · 真库探针(回滚)', () => {
expect(h.catalog).toBe('');
expect(h.persona).toContain('自称「小瑞」');
const ctxSvc = new CommonContextService(tx as never, { signedUrl: () => null } as never, { recent: () => [] } as never, svc, servingAt(chainBJ()));
const ctxSvc = new CommonContextService(tx as never, { signedUrl: () => null } as never, { recent: () => [] } as never, svc, servingAt(chainBJ()), { getAddressPatientsForAccount: async () => [] } as never);
const ctx = await ctxSvc.build(T, conv.id);
const system = buildSystemPrompt({ toolNames: toolNamesFor(ctx!), house: ctx!.houseRules, catalog: ctx!.knowledgeCard, persona: ctx!.personaRules });
expect(system).toContain('自称「小瑞」');
......
import { lastContact } from '../../ai-web/src/lib/pac-profile';
import type { PatientProfile } from '../../ai-web/src/lib/api';
const now=new Date('2026-10-14T04:00:00Z');
const fact=(at:string|null,kind='actual')=>({type:'consultation',kind,occurredAt:at,plannedFor:null});
const profile=(facts:unknown[])=>({facts} as PatientProfile);
describe('距上次接触',()=>{
it('跳过未来 actual,找到最近已发生事实',()=>{
expect(lastContact(profile([fact('2026-10-30T00:00:00Z'),fact('2026-10-12T04:00:00Z'),fact('2026-10-01T04:00:00Z')]),now)?.days).toBe(2);
});
it('同日未来时刻也不能算接触,计划和无发生时间都排除',()=>{
expect(lastContact(profile([fact('2026-10-14T05:00:00Z'),fact('2026-10-01T04:00:00Z','planned'),{...fact(null),plannedFor:'2026-10-01T04:00:00Z'},fact('invalid')]),now)).toBeNull();
});
it('此刻已发生算0天,无资料返回空',()=>{
expect(lastContact(profile([fact(now.toISOString())]),now)?.days).toBe(0);
expect(lastContact(null,now)).toBeNull();
});
});
......@@ -6,6 +6,7 @@ import { installSession, abandonHostEntry, type SessionTokens } from '@/lib/auth
import { api, type Me } from '@/lib/api';
import { currentHostContext } from '@/lib/auth-session';
import { pendingHostEntry } from '@/lib/host-entry';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
function Entry() {
......@@ -45,15 +46,21 @@ function Entry() {
} catch (e) { setError(e instanceof Error ? e.message : '进入失败,请从宿主重新打开。'); }
})();
}, [code, selectedSid, router, attempt]);
if (!error) return <EntryLoading />;
return <main className="flex min-h-screen items-center justify-center bg-slate-50 p-6">
<section className="w-full max-w-sm rounded-xl bg-white p-6 text-center">
<h1 className="text-lg font-semibold">FridayAI</h1>
<p className="mt-3 text-sm text-slate-500">{error ?? '正在验证宿主授权…'}</p>
<p className="mt-3 text-sm text-slate-500">{error}</p>
{error && code ? <Button className="mt-5" onClick={() => { started.current = false; setError(null); setAttempt((v) => v + 1); }}>重试当前进入</Button> : null}
{error ? <p className="mt-3 text-xs text-slate-400">授权过期或被撤销时,请关闭此页,从宿主原有入口重新进入。</p> : null}
</section>
</main>;
}
function EntryLoading() {
return <main className="flex min-h-screen items-center justify-center bg-slate-50" role="status" aria-label="加载中">
<Loader2 className="h-6 w-6 animate-spin text-slate-400" aria-hidden="true" />
</main>;
}
export default function EmbedEntry() {
return <Suspense fallback={<p className="p-6 text-sm text-slate-500">正在加载…</p>}><Entry /></Suspense>;
return <Suspense fallback={<EntryLoading />}><Entry /></Suspense>;
}
......@@ -256,20 +256,21 @@ export function doctorRecommendations(p: PatientProfile | null): TreatmentLine[]
*/
export function lastContact(
p: PatientProfile | null,
now = new Date(),
): { date: string; days: number; label: string } | null {
if (!p) return null;
let best: { at: number; iso: string; type: string } | null = null;
for (const f of p.facts) {
if (f.kind !== 'actual') continue;
const iso = factAt(f);
const iso = f.occurredAt;
if (!iso) continue;
const t = Date.parse(iso);
if (!Number.isFinite(t)) continue;
if (!Number.isFinite(t) || t > now.getTime()) continue;
if (!best || t > best.at) best = { at: t, iso, type: f.type };
}
if (!best) return null;
const d = new Date(best.at);
const today = new Date();
const today = now;
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
const thatDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
return {
......
......@@ -72,12 +72,14 @@ icon: FileJson
#### 专属客服(待联调)
- 来源:`customer.customer_follow_person`,`is_default = 1`。
- 关联:`tenant_id` + `customer_id`;多门店存在不同客服时,先确认有效归属,不任取一条。
- 归属:同一品牌内,取患者**最近一次实际就诊门店的当前有效默认专属客服**;按实际就诊时间确定门店,排除未来预约、取消及无效就诊,不取最近回访执行人。
- 关联:按 `tenant_id` + `customer_id` + 上述门店匹配客服关系,取 `is_default = 1`;不得跨品牌或回退取其他门店客服。
- 待确认:无有效就诊记录、最近就诊门店无法唯一确定,或该门店存在多个有效默认客服时,省略两个客服字段并记录待确认,不任取一条。
- 字段:`follow_people_id`(字符串用户 ID)、`follow_people`(姓名),来自同一条记录,inline 到 `customer_basic_info`。
- 变更:新增、换人、改名、取消时重推患者主档;关系表单独变更也须触发,不能仅依赖主档更新时间。
- 取消:`follow_people_id` 和 `follow_people` 同时显式传 `null`,清除原客服 ID 和姓名;不传字段保持原值。单字段 `null` 不代表取消。
- 变更:最近实际就诊门店变化,或该门店客服新增、换人、改名、取消时,重推患者主档;就诊记录更正、取消及关系表单独变更也须触发,不能仅依赖主档更新时间。
- 取消:确认所选门店未分配或已取消专属客服时,`follow_people_id` 和 `follow_people` 同时显式传 `null`,清除原客服 ID 和姓名;不传字段保持原值。单字段 `null` 不代表取消。
- 补推:接入完成后,按当前关系分批补推历史患者主档。
- 验收:首次分配、换人、取消、重复推送、跨品牌及多门店归属。
- 验收:首次分配、换人、取消、跨店就诊、未来预约不改变归属、就诊更正、无就诊及多默认客服、重复推送、跨品牌隔离。
#### 跟进中患者不进召回池 —— `has_active_complex_case`(PAC 侧已就绪,待宿主推)
......
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