Commit ab0d4595 by luoqi

feat(plan): 医生筛选做成可搜索输入框(方案 A:前端模糊 + 后端等值)

业务要「上次医生 / 偏好医生」筛选。医生是开集(单宿主几百个),chip 平铺放不下。

 核心取舍:**模糊只发生在前端**。
  输入框在已加载的医生名单里做子串匹配,选中后发**精确姓名** → 后端仍是等值查询,
  吃得到偏索引。若改成后端 ILIKE '%x%',这三条索引立刻失效、退回全分区扫 ——
  就是 2026-07-29 那个「圈人 20 秒」。要真·后端模糊得先上 pg_trgm + GIN,
  并确认托管 PG 允许装扩展(见迁移注释)。
  附带好处:客服不用记全名、不会因打错字得到空结果还以为"真没人"。

改动:
- PersonaTagFilterDim 加两个字段:
  · `id?` —— 筛选串 `id:value` 的维度标识。**同一特征出多个可筛字段时必填**:
    visit_recency 一个特征出 bucket / lastDoctor / preferredDoctor 三个,只靠 key 分不开。
    不填回落 key,存量维度零影响。新增 personaTagDimId() 统一取值。
  · `dynamic?` —— 取值是开集,跳过闭集校验(维度 id 仍校验,取值走参数化 equals,无注入面)。
- plan.service 的筛选组装改为**按维度 id 分组**(原按特征 key 分组,三个 visit_recency
  维度会被串成一条 OR,筛出错的人)。
- 新增 GET /pac/v1/plans/doctors:本 scope 医生名单(visit_recency 的 lastDoctor ∪
  preferredDoctor 去重),Redis 缓存 6h —— 医生变动以月计,不该每次开面板都跑 DISTINCT 聚合。
  ️ 路由声明在 @Get(':id') **之前**,否则 'doctors' 会被当成 planId。
- 前端 DoctorPicker:已选可点掉;输入为空**不铺全量名单**(几百个会把面板撑爆);
  名单拉取失败则输入框仍可用,只是没候选。
- 迁移 20260729080000 补两条医生索引(与 bucket 同形态:btree(表达式, persona_id),
  第二列让内层 EXISTS 走 index-only scan)。

测试:persona-spec-drift 补两条 ——
  · 开集维度 options 必须**空**(留样例值会让前端误以为是闭集,把没列出的医生筛没了);
  ·  维度 id 必须唯一(重复 id 会指向错的 dataPath,筛出错的人)。
655 tests / 42 suites 全过;types / service / web typecheck 通过;
本地 GET /plans/doctors 实测 200。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent c241da85
-- CreateIndex —— 新筛选维度「上次时间」(visit_recency.bucket)的偏索引
-- CreateIndex —— 新筛选维度的偏索引:上次时间 / 上次医生 / 偏好医生
--
-- 与 20260728020000 那 14 条同一套路数、同一理由,不重复长篇:
-- Prisma 把画像筛选编译成 `key = 'visit_recency' AND (data #> '{bucket}') = '"0_3m"'`,
-- 没有这条索引,planner 只能按 key 捞出**全部** visit_recency 行再回堆过滤 jsonb。
-- 2026-07-29 生产实测过后果:禁忌维度 190,210 行里只中 2,156 行,读 1.3GB 堆、单次筛人 20+ 秒。
-- 与 20260728020000 那 14 条同一套路数、同一理由,不重复长篇:
-- Prisma 把画像筛选编译成 `key = 'visit_recency' AND (data #> '{X}') = '"…"'`,
-- 没有索引,planner 只能按 key 捞出**全部** visit_recency 行再回堆过滤 jsonb。
-- 2026-07-29 生产实测过后果:维度 190,210 行里只中 2,156 行,读 1.3GB 堆、单次筛人 20+ 秒。
--
-- ⚠️ **加筛选维度必须同时加索引** —— 这是 persona-tag-filters.ts 顶部写死的纪律,
-- 本条就是「上次时间」维度的那一条。漏了它,新维度一上线就是 20 秒起步。
-- ⚠️ **加筛选维度必须同时加索引** —— persona-tag-filters.ts 顶部写死的纪律,这三条就是它的兑现。
--
-- 形态:标量维度(equals)→ btree (表达式, persona_id)。
-- 第二列 persona_id 不是排序用,是让内层 EXISTS 走 **index-only scan**:
-- 它只 SELECT persona_id,索引自带就不用回堆(详见 20260728020000 的实测数据)。
-- 形态:全是标量等值 → btree (表达式, persona_id)。
-- 第二列 persona_id 不是排序用,是让内层 EXISTS 走 **index-only scan**
-- (它只 SELECT persona_id,索引自带就不用回堆;实测数据见 20260728020000)。
--
-- 【为什么不是 CONCURRENTLY】本文件只有 1 条语句,本可以 CONCURRENTLY;但 visit_recency
-- 是**新特征**,建索引时表里一行都没有(要等下一轮画像重算才写入),普通 CREATE INDEX
-- 瞬间完成、锁不到任何东西。等重算铺开后再想加别的维度索引,才需要 CONCURRENTLY + 手工先建。
-- ⭐ 医生两条同样是**等值**索引,不是模糊匹配索引 —— 这是刻意的:
-- 前端输入框在 /plans/doctors 拉到的名单上做客户端模糊,选中后发**精确姓名**。
-- 若改成后端 ILIKE '%x%',这两条索引立刻失效、退回全分区扫。要做真·后端模糊
-- 得先上 pg_trgm + GIN,并确认托管 PG 允许装扩展。
--
-- 【为什么不用 CONCURRENTLY】visit_recency 是新特征,建索引时表里一行都没有
-- (要等下一轮画像重算才写入),普通 CREATE INDEX 瞬间完成、锁不到任何东西。
-- 等数据铺开后再加别的维度索引,才需要 CONCURRENTLY + 部署前手工先建。
CREATE INDEX IF NOT EXISTS "persona_features_visit_recency_bucket_idx"
ON "persona_features" (((data #> '{bucket}'::text[])), "persona_id") WHERE "key" = 'visit_recency';
CREATE INDEX IF NOT EXISTS "persona_features_visit_recency_last_doctor_idx"
ON "persona_features" (((data #> '{lastDoctor}'::text[])), "persona_id") WHERE "key" = 'visit_recency';
CREATE INDEX IF NOT EXISTS "persona_features_visit_recency_pref_doctor_idx"
ON "persona_features" (((data #> '{preferredDoctor}'::text[])), "persona_id") WHERE "key" = 'visit_recency';
......@@ -74,6 +74,23 @@ export class PlanController {
return this.plans.counts(scope, user.permissions);
}
/**
* 「上次医生 / 偏好医生」筛选用的候选名单。
*
* ⚠️ 必须放在 `@Get(':id')` **之前** —— 否则 'doctors' 会被当成 planId 吃掉(Nest 按声明顺序匹配)。
*/
@Get('doctors')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({
summary: '本 scope 的医生名单(筛选面板的可搜索输入框用;结果缓存)',
description:
'取自 persona_features.visit_recency 的 lastDoctor / preferredDoctor 去重。' +
'前端在这个名单上做客户端模糊匹配,**选中后发精确姓名** —— 等值查询才吃得到偏索引。',
})
doctors(@TenantScope() scope: TenantScopeContext) {
return this.plans.doctorOptions(scope);
}
@Get(':id')
@ZodResponse({ status: 200, type: PlanDetailResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN)
......
......@@ -17,9 +17,12 @@ import {
type PlanDetailResponse,
type PlanReasonBrief,
PersonaFeatureKey,
personaTagDimId,
type PersonaTagFilterDim,
} from '@pac/types';
import { calcAge, maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder';
import { PERSONA_TAG_FILTER_DIMS, parsePersonaTags } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
......@@ -61,6 +64,7 @@ export class PlanService {
constructor(
private readonly prisma: PrismaService,
private readonly executions: ExecutionService,
private readonly redis: RedisService,
) {}
// ─────────────────────────────────────────────
......@@ -212,6 +216,45 @@ export class PlanService {
return out;
}
/**
* 「上次医生 / 偏好医生」筛选的候选名单 —— 本 scope 内出现过的医生姓名(去重、按姓名排序)。
*
* 为什么要这个接口:医生是**开集**(单宿主几百个),chip 平铺的面板放不下。
* 前端把名单一次拉走、在输入框里做客户端模糊匹配,**选中后发精确姓名**;
* 后端仍是等值查询 → 吃得到 visit_recency 的偏索引。
* (做成后端 ILIKE 模糊反而会退化成全分区扫,就是 2026-07-29 那个「圈人 20 秒」。)
*
* 口径:只统计**当前版**画像;不按 clinicIds 再切 —— 医生名单是选择器的候选集,
* 多几个不属于本诊所的名字只是选了筛不出人,而漏名字会让客服以为"这医生没数据"。
* 真正的数据边界由列表查询自己的 scope 保证,这里不是权限闸。
*
* 缓存 6 小时:医生名单变动以月计,而这是个 DISTINCT 聚合,不该每次开筛选面板都跑。
*/
async doctorOptions(scope: TenantScopeContext): Promise<{ doctors: string[] }> {
const cacheKey = `pac:plan:doctors:${scope.hostId}:${scope.tenantId}`;
const cached = await this.redis.get(cacheKey).catch(() => null);
if (cached) return { doctors: JSON.parse(cached) as string[] };
const rows = await this.prisma.$queryRaw<Array<{ name: string }>>`
SELECT DISTINCT name FROM (
SELECT nullif(f.data ->> 'lastDoctor', '') AS name
FROM persona_features f
JOIN personas p ON p.id = f.persona_id AND p.superseded_at IS NULL
WHERE f.key = 'visit_recency' AND p.host_id = ${scope.hostId}::uuid AND p.tenant_id = ${scope.tenantId}
UNION ALL
SELECT nullif(f.data ->> 'preferredDoctor', '')
FROM persona_features f
JOIN personas p ON p.id = f.persona_id AND p.superseded_at IS NULL
WHERE f.key = 'visit_recency' AND p.host_id = ${scope.hostId}::uuid AND p.tenant_id = ${scope.tenantId}
) t
WHERE name IS NOT NULL
ORDER BY name
`;
const doctors = rows.map((r) => r.name);
await this.redis.setEx(cacheKey, JSON.stringify(doctors), 6 * 3600).catch(() => undefined);
return { doctors };
}
// ─────────────────────────────────────────────
// buildListWhere — list / queueStats 共用的 where 构造(view + 显式 filter + clinic 隔离 + persona 圈人)
// ─────────────────────────────────────────────
......@@ -295,17 +338,21 @@ export class PlanService {
// 同一维度多选 = OR(该 feature 的 data 值命中任一);跨维度 = AND(每个维度各自 some)。
// 维度/取值/data 路径以 PERSONA_TAG_FILTER_DIMS 为单一真理源(非法 key/value 静默丢弃)。
if (query.personaTags) {
const byKey = new Map<string, string[]>();
for (const { key, value } of parsePersonaTags(query.personaTags)) {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === key);
if (!dim || !dim.options.some((o) => o.value === value)) continue;
const arr = byKey.get(key) ?? [];
arr.push(value);
byKey.set(key, arr);
// ⚠️ 按**维度 id** 分组,不是按 persona 特征 key —— visit_recency 一个特征出三个可筛字段
// (上次时间 / 上次医生 / 偏好医生),按 key 分组会把它们串成一条 OR,筛出错的人。
const byDim = new Map<string, { dim: PersonaTagFilterDim; values: string[] }>();
for (const { key: dimId, value } of parsePersonaTags(query.personaTags)) {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => personaTagDimId(d) === dimId);
if (!dim) continue;
// 开集维度(医生姓名)不做闭集校验;闭集维度非法取值静默丢弃,行为不变。
// 两者取值都只进参数化 equals / array_contains,无注入面。
if (!dim.dynamic && !dim.options.some((o) => o.value === value)) continue;
const cur = byDim.get(dimId) ?? { dim, values: [] };
cur.values.push(value);
byDim.set(dimId, cur);
}
const personaConds: Prisma.PatientWhereInput[] = [];
for (const [key, values] of byKey) {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === key)!;
for (const { dim, values } of byDim.values()) {
const valueConds = values.map((v) =>
dim.isArray
? { data: { path: [dim.dataPath], array_contains: v } }
......@@ -315,7 +362,7 @@ export class PlanService {
personas: {
some: {
supersededAt: null,
features: { some: { key, OR: valueConds } },
features: { some: { key: dim.key, OR: valueConds } },
},
},
});
......
import { Test } from '@nestjs/testing';
import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, PERSONA_FEATURE_META } from '@pac/types';
import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS,
personaTagDimId, PERSONA_FEATURE_META } from '@pac/types';
import {
FeatureRegistry,
FEATURE_EXTRACTOR_PROVIDERS,
......@@ -92,10 +93,23 @@ describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => {
expect(orphan).toEqual([]);
});
test('圈人维度的取值不能为空', () => {
test('闭集维度的取值不能为空;开集维度(dynamic)不许留残缺 options', () => {
for (const d of PERSONA_TAG_FILTER_DIMS) {
if (d.dynamic) {
// 开集维度(如医生姓名)取值由接口下发,options 必须**空**——
// 留几个样例值会让前端以为是闭集、把没列出的医生筛没了。
expect(d.options).toHaveLength(0);
continue;
}
expect(d.options.length).toBeGreaterThan(0);
for (const o of d.options) expect(o.value.length).toBeGreaterThan(0);
}
});
test('⭐ 维度标识必须唯一 —— 同一特征出多个可筛字段时靠它区分', () => {
// visit_recency 一个特征出三个字段(bucket / lastDoctor / preferredDoctor),
// 只靠 key 分不开;重复的 id 会让筛选串指向错的 dataPath,筛出错的人。
const ids = PERSONA_TAG_FILTER_DIMS.map(personaTagDimId);
expect(new Set(ids).size).toBe(ids.length);
});
});
......@@ -14,6 +14,7 @@ import {
type ExecutionOutcomeGroup,
type PlanListItem,
potentialTreatmentCardLabel,
personaTagDimId,
} from '@pac/types';
import { cn, formatGender } from '@/lib/utils';
import { actionTemplate } from '@/lib/action-url';
......@@ -355,6 +356,19 @@ function PersonaTagFilter({
}) {
/// 当前悬停选项的说明,渲染在面板底部固定行(见下方注释:为什么不用浮层)
const [hint, setHint] = useState<string | null>(null);
/// 医生名单(开集维度的候选)—— 面板挂载时拉一次,失败就当空(输入框仍可用,只是没候选)。
/// 后端缓存 6 小时,这里不再本地缓存。
const [doctors, setDoctors] = useState<string[]>([]);
useEffect(() => {
let alive = true;
plansApi
.doctors()
.then((r) => alive && setDoctors(r.doctors))
.catch(() => undefined);
return () => {
alive = false;
};
}, []);
return (
<Popover>
<PopoverTrigger asChild>
......@@ -391,9 +405,17 @@ function PersonaTagFilter({
<div className="px-1 py-0.5 text-[10.5px] font-medium uppercase tracking-wide text-slate-400">
{dim.nameZh}
</div>
{dim.dynamic ? (
<DoctorPicker
dimId={personaTagDimId(dim)}
doctors={doctors}
selected={selected}
onToggle={onToggle}
/>
) : (
<div className="flex flex-wrap gap-1 px-1 pb-1">
{dim.options.map((o) => {
const kv = `${dim.key}:${o.value}`;
const kv = `${personaTagDimId(dim)}:${o.value}`;
const on = selected.has(kv);
return (
<button
......@@ -418,6 +440,7 @@ function PersonaTagFilter({
);
})}
</div>
)}
</div>
))}
</div>
......@@ -558,6 +581,74 @@ function PotentialTreatmentChips({ labels }: { labels: string[] }) {
);
}
/**
* 开集维度(医生)的可搜索输入框。
*
* ⭐ 模糊只发生在**前端**:在已加载的名单里做子串匹配,选中后发**精确姓名**。
* 后端不做 ILIKE —— 那会让 visit_recency 的偏索引失效,退回全分区扫
* (2026-07-29 的「圈人 20 秒」就是这么来的)。
* 已选中的显示在上方,可点掉;输入为空时不铺全量名单(几百个医生会把面板撑爆)。
*/
function DoctorPicker({
dimId,
doctors,
selected,
onToggle,
}: {
dimId: string;
doctors: string[];
selected: Set<string>;
onToggle: (kv: string) => void;
}) {
const [q, setQ] = useState('');
const picked = [...selected].filter((kv) => kv.startsWith(`${dimId}:`));
const matches = q.trim()
? doctors.filter((d) => d.includes(q.trim()) && !selected.has(`${dimId}:${d}`)).slice(0, 8)
: [];
return (
<div className="space-y-1 px-1 pb-1">
{picked.length > 0 && (
<div className="flex flex-wrap gap-1">
{picked.map((kv) => (
<button
key={kv}
type="button"
onClick={() => onToggle(kv)}
title="点击移除"
className="rounded-full border border-teal-300 bg-teal-50 px-2 py-0.5 text-[11px] font-medium text-teal-700"
>
{kv.slice(dimId.length + 1)} ×
</button>
))}
</div>
)}
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={doctors.length ? '输入医生姓名搜索' : '暂无医生名单'}
className="w-full rounded border border-slate-200 px-1.5 py-0.5 text-[11px] outline-none placeholder:text-slate-300 focus:border-teal-300"
/>
{matches.length > 0 && (
<div className="flex flex-wrap gap-1">
{matches.map((d) => (
<button
key={d}
type="button"
onClick={() => {
onToggle(`${dimId}:${d}`);
setQ('');
}}
className="rounded-full border border-slate-100 bg-white px-2 py-0.5 text-[11px] text-slate-600 hover:border-teal-200 hover:bg-teal-50/40"
>
{d}
</button>
))}
</div>
)}
</div>
);
}
// ── 优先级(与列表页 PriorityBar 同款:五格条 + 10 分制)────────
function PriorityBar({ score }: { score: number }) {
const pct = Math.max(0, Math.min(1, score / 100));
......
......@@ -31,6 +31,9 @@ export const plansApi = {
},
}),
/** 「上次医生 / 偏好医生」筛选的候选名单(后端缓存 6h;前端在它上面做客户端模糊匹配)*/
doctors: () => api.get<{ doctors: string[] }>('/pac/v1/plans/doctors'),
/** KPI / Tab badge 聚合计数 — 一次拉齐(替代之前 5 个并发 list 请求)*/
counts: () => api.get<PlanCountsResponse>('/pac/v1/plans/counts'),
......
......@@ -33,6 +33,15 @@ export interface PersonaTagOption {
}
export interface PersonaTagFilterDim {
/**
* 筛选串(`id:value`)里用的**维度标识**,必须全局唯一。
*
* 不填 = 等于 key(绝大多数维度:一个特征只出一个可筛字段,两者同名最省事)。
* 必须填的场景:**同一个 persona 特征出多个可筛字段** —— 如 visit_recency 同时提供
* 上次时间(bucket)/ 上次医生(lastDoctor)/ 偏好医生(preferredDoctor),
* 三者 key 相同、dataPath 不同,只靠 key 分不开。
*/
id?: string;
/** persona_features.key */
key: string;
nameZh: string;
......@@ -49,9 +58,24 @@ export interface PersonaTagFilterDim {
* · 业务口径反复过不止一次,删了重写的成本远高于留一个 flag。
*/
hidden?: boolean;
/**
* true = 取值是**开集**(如医生姓名),不做闭集校验。
*
* 校验仍在:维度 id 必须已知,取值走参数化 equals(无注入面);只是不再要求
* 取值出现在 options 里。options 此时留空,由前端从 /plans/doctors 之类的接口拉。
* ⚠️ 开集维度**必须走等值**(不是模糊匹配)—— 等值才能吃到偏索引;
* 模糊匹配会退化成全分区扫,就是 2026-07-29 那个「圈人 20 秒」。
* "模糊"体验交给前端:输入框在已加载的候选列表里做客户端匹配,选中后发精确值。
*/
dynamic?: boolean;
options: PersonaTagOption[];
}
/// 维度的筛选标识(`id:value` 串里用的);未显式给 id 的维度回落到 key
export function personaTagDimId(dim: PersonaTagFilterDim): string {
return dim.id ?? dim.key;
}
export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
// ══════════════ Tier1 · 治疗维度 —— 客服最先问的是「这人有什么可谈」 ══════════════
{
......@@ -90,12 +114,27 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
dataPath: 'bucket',
options: VISIT_RECENCY_BUCKETS.map((b) => ({ value: b.value, zh: b.zh, hint: b.hint })),
},
// 「上次医生 / 偏好医生」数据已就位(同一个 visit_recency 特征的 lastDoctor / preferredDoctor),
// 但**没做成筛选维度**:医生是高基数(单宿主几百个),PersonaTagFilterDim 的 options 是静态闭集,
// 平铺 chip 的面板放不下 —— 需要换成可搜索下拉,是另一个 UI 组件的活。
// 数据侧已经能筛(dataPath 直接指 lastDoctor / preferredDoctor),接 UI 时补两条 dim 即可。
// 「上次医生 / 偏好医生」是**开集维度**(单宿主几百个医生),取值不做闭集校验,
// options 留空 —— 前端从 GET /pac/v1/plans/doctors 拉本 scope 的医生名单,
// 输入框做客户端模糊匹配,**选中后发精确姓名**(等值查询才吃得到偏索引,见 dynamic 注释)。
// 注:preferredDoctor 现在是拿「主治医生」(病历里出现频次最高的医生)代替 ——
// DW fact_client_out.favor_doctor_id 才是真·偏好医生,PAC 尚未摄入。
// DW fact_client_out.favor_doctor_id 才是真·偏好医生,PAC 尚未摄入,接入后换值即可,前端不用改。
{
id: 'visit_doctor_last',
key: 'visit_recency',
nameZh: '上次医生',
dataPath: 'lastDoctor',
dynamic: true,
options: [],
},
{
id: 'visit_doctor_preferred',
key: 'visit_recency',
nameZh: '偏好医生',
dataPath: 'preferredDoctor',
dynamic: true,
options: [],
},
// ══════════════ Tier3 · 属性维度 —— 定语气与话术分寸,不决定要不要打 ══════════════
{
......
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