Commit a7aea646 by luoqi

feat(mcp): list_recall_queue 透传画像圈人/病种筛选 + 新增 recall_queue_stats

「今日推荐」开放能力(Tier 1,纯读、复用已有后端逻辑):
- 从 plan.service.list 抽出 buildListWhere,list / queueStats 共用同一套
  view + 显式 filter + clinic 隔离 + persona 圈人逻辑(零行为变化)。
- list_recall_queue 入参透传 personaTags(14 维画像圈人,字典自 PERSONA_TAG_FILTER_DIMS
  生成)+ scenario / status / keyword / phoneVerified / sort(后端早已支持,只是没暴露)。
- 新增 plan.service.queueStats + MCP recall_queue_stats:总量 + 优先级分档(高≥70/中/低)
  + 病种(sub_scenario)分布,供 LLM 先出摘要再取明细。

后续 CS 知识/习惯数据成熟后,可作为「今日推荐」的参考输入接入(预留)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent abf327ab
......@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
// clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { PERSONA_TAG_FILTER_DIMS } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { PatientService } from '../patient/patient.service';
import { PersonaService } from '../persona/persona.service';
......@@ -14,6 +15,19 @@ function jsonResult(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
/**
* personaTags 圈人字典(从 PERSONA_TAG_FILTER_DIMS 生成,单一真理源,自动同步)。
* 格式给 LLM 看:`key(中文): code=中文 / code=中文 …`,一维一行。
* 入参格式:"key:value" 逗号串;同维多选 OR,跨维 AND(如 "rfm:important_value,urgency_level:urgent")。
*/
const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map(
(d) => `${d.key}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`,
).join('\n');
const PERSONA_TAGS_DESC =
'画像圈人(可选):"key:value" 逗号串,同维多选 OR、跨维 AND。可用维度与取值:\n' +
PERSONA_TAGS_HELP;
function maskPhone(phone: string | null): string | null {
if (!phone) return null;
const d = phone.replace(/\D/g, '');
......@@ -146,27 +160,75 @@ export class McpServerFactory {
'list_recall_queue',
{
description:
'召回工作台:列出待跟进的召回计划(view=pool 未分配召回池 / mine 我名下)。回答"现在该联系谁"。',
'召回工作台:列出待跟进的召回计划(view=pool 未分配召回池 / mine 我名下),按优先级排序。回答"现在该联系谁 / 今日推荐"。' +
'\n支持画像圈人(personaTags)+ 病种(scenario)+ 关键字(keyword:姓名/手机/患者号)+ 真实号(phoneVerified)筛选。' +
'\n' +
PERSONA_TAGS_DESC,
inputSchema: {
view: z.enum(['pool', 'mine']).optional(),
view: z.enum(['pool', 'mine']).optional().describe('pool=未分配召回池(默认) / mine=我名下'),
personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
scenario: z
.string()
.optional()
.describe('召回场景过滤,如 treatment_initiation_recall(启治召回)'),
status: z.enum(['active', 'assigned', 'completed', 'abandoned']).optional(),
keyword: z.string().optional().describe('姓名 / 手机 / 患者号 模糊匹配'),
phoneVerified: z.boolean().optional().describe('只看真实号码(已核实)'),
sort: z
.enum(['priority_desc', 'priority_asc', 'created_desc'])
.optional()
.describe('排序,默认 priority_desc'),
clinicId: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
},
async ({ view, clinicId, limit }) => {
async ({ view, personaTags, scenario, status, keyword, phoneVerified, sort, clinicId, limit }) => {
// 注:schema 的诊所过滤字段是 targetClinicId(不是 clinicId);以前 cast 成 any 传 clinicId
// 被静默忽略。这里类型化构造,过滤真正生效。
const query: ListPlansQueryDto = {
view: view ?? 'pool',
sort: 'priority_desc',
sort: sort ?? 'priority_desc',
page: 1,
pageSize: limit ?? 20,
...(clinicId ? { targetClinicId: clinicId } : {}),
...(personaTags ? { personaTags } : {}),
...(scenario ? { scenario } : {}),
...(status ? { status } : {}),
...(keyword ? { keyword } : {}),
...(phoneVerified !== undefined ? { phoneVerified } : {}),
};
return jsonResult(await this.plans.list(scope, query, permissions));
},
);
server.registerTool(
'recall_queue_stats',
{
description:
'召回池数字概览:总量 + 优先级分档(高≥70 / 中 40-69 / 低<40)+ 病种分布。' +
'"今日推荐"先用它给摘要(如"今天 320 条,缺牙 80 / 牙周 60,高优 45"),再用 list_recall_queue 取明细。' +
'支持同样的 view / personaTags / scenario / clinicId 圈定范围。',
inputSchema: {
view: z.enum(['pool', 'mine']).optional().describe('pool=召回池(默认) / mine=我名下'),
personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
scenario: z.string().optional(),
clinicId: z.string().optional(),
},
},
async ({ view, personaTags, scenario, clinicId }) => {
const query: ListPlansQueryDto = {
view: view ?? 'pool',
sort: 'priority_desc',
page: 1,
pageSize: 1, // stats 不取明细行
...(clinicId ? { targetClinicId: clinicId } : {}),
...(personaTags ? { personaTags } : {}),
...(scenario ? { scenario } : {}),
};
return jsonResult(await this.plans.queueStats(scope, query, permissions));
},
);
return server;
}
......
......@@ -9,6 +9,7 @@ import type { Prisma } from '@prisma/client';
import {
Permission,
planScenarioLabel,
subLabelZh,
applyLiveDays,
type ListPlansResponse,
type PlanCountsResponse,
......@@ -38,6 +39,16 @@ import { ExecutionService } from './execution.service';
const RECYCLE_TIMEOUT_HOURS = 24; // assignment 后 24h 未结案自动回收(后续接 tenant 配置)
/** 召回池数字概览(queueStats 返回;MCP recall_queue_stats / 工作台 KPI 共用)。 */
export interface PlanQueueStatsResponse {
view: string;
total: number;
/** 优先级分档:high ≥70 / mid 40–69 / low <40 */
byPriority: { high: number; mid: number; low: number };
/** 按病种(sub_scenario)分布,信号(reason)条数倒序 */
byScenario: Array<{ scenario: string; subScenario: string; label: string; signals: number }>;
}
@Injectable()
export class PlanService {
private readonly logger = new Logger(PlanService.name);
......@@ -56,6 +67,82 @@ export class PlanService {
query: ListPlansQueryDto,
permissions: readonly string[],
): Promise<ListPlansResponse> {
const where = this.buildListWhere(scope, query, permissions);
// W3 末:服务端 sort —— 替代前端 .sort,跨页排序正确
const orderBy: Prisma.FollowupPlanOrderByWithRelationInput[] =
query.sort === 'priority_asc'
? [{ priorityScore: 'asc' }, { createdAt: 'asc' }]
: query.sort === 'created_desc'
? [{ createdAt: 'desc' }]
: [{ priorityScore: 'desc' }, { createdAt: 'asc' }]; // priority_desc 默认
const skip = (query.page - 1) * query.pageSize;
const [total, rows] = await Promise.all([
this.prisma.followupPlan.count({ where }),
this.prisma.followupPlan.findMany({
where,
include: { reasons: { orderBy: { priorityScore: 'desc' } } },
orderBy,
skip,
take: query.pageSize,
}),
]);
// 批量拉患者简介(列表展示用,避免每行单查)
const patientIds = Array.from(new Set(rows.map((r) => r.patientId)));
const patients = patientIds.length > 0
? await this.prisma.patient.findMany({
where: { id: { in: patientIds } },
select: {
id: true,
externalId: true,
name: true,
phone: true,
phoneVerified: true,
gender: true,
birthDate: true,
},
})
: [];
const patientById = new Map(patients.map((p) => [p.id, p]));
return {
items: rows.map((p) => {
const base = serializePlan(p);
const patient = patientById.get(p.patientId);
return {
...base,
scenarioLabel: planScenarioLabel(base.scenario),
patient: {
externalId: patient?.externalId ?? '',
/// name 原值透出(权限内可见);nameMasked 保留作脱敏视图兜底
name: patient?.name ?? null,
nameMasked: maskName(patient?.name ?? null),
phoneMasked: maskPhone(patient?.phone ?? null),
phoneVerified: patient?.phoneVerified ?? false,
gender: patient?.gender ?? null,
age: patient?.birthDate ? calcAge(patient.birthDate) : null,
},
/// reasons 透 signals JSON,前端 ReasonLine 用字典翻译富文本(跟详情页同源)
reasons: p.reasons.map(toPlanReasonBrief),
};
}),
page: query.page,
pageSize: query.pageSize,
total,
};
}
// ─────────────────────────────────────────────
// buildListWhere — list / queueStats 共用的 where 构造(view + 显式 filter + clinic 隔离 + persona 圈人)
// ─────────────────────────────────────────────
private buildListWhere(
scope: TenantScopeContext,
query: ListPlansQueryDto,
permissions: readonly string[],
): Prisma.FollowupPlanWhereInput {
const where: Prisma.FollowupPlanWhereInput = {
hostId: scope.hostId,
tenantId: scope.tenantId,
......@@ -154,68 +241,60 @@ export class PlanService {
where.patient = patientWhere;
}
// W3 末:服务端 sort —— 替代前端 .sort,跨页排序正确
const orderBy: Prisma.FollowupPlanOrderByWithRelationInput[] =
query.sort === 'priority_asc'
? [{ priorityScore: 'asc' }, { createdAt: 'asc' }]
: query.sort === 'created_desc'
? [{ createdAt: 'desc' }]
: [{ priorityScore: 'desc' }, { createdAt: 'asc' }]; // priority_desc 默认
return where;
}
const skip = (query.page - 1) * query.pageSize;
const [total, rows] = await Promise.all([
// ─────────────────────────────────────────────
// queueStats — 召回池数字概览(总量 + 优先级分档 + 病种 breakdown)。
// 复用 buildListWhere,所以 list 的全部筛选(view / scenario / personaTags 圈人 / clinic)都生效。
// "今日推荐" 的聚合输入:LLM 可先看 stats 给摘要,再用 list_recall_queue 取明细。
// 注:byScenario 计的是 reason(信号)条数,非去重患者数(一患者多牙位 = 多 reason)。
// ─────────────────────────────────────────────
async queueStats(
scope: TenantScopeContext,
query: ListPlansQueryDto,
permissions: readonly string[],
): Promise<PlanQueueStatsResponse> {
// 默认 pool 视图(今日推荐场景);调用方可显式传 mine。
const where = this.buildListWhere(
scope,
{ ...query, view: query.view ?? 'pool' } as ListPlansQueryDto,
permissions,
);
const [total, high, mid, low, reasonGroups] = await Promise.all([
this.prisma.followupPlan.count({ where }),
this.prisma.followupPlan.findMany({
where,
include: { reasons: { orderBy: { priorityScore: 'desc' } } },
orderBy,
skip,
take: query.pageSize,
this.prisma.followupPlan.count({ where: { ...where, priorityScore: { gte: 70 } } }),
this.prisma.followupPlan.count({ where: { ...where, priorityScore: { gte: 40, lt: 70 } } }),
this.prisma.followupPlan.count({ where: { ...where, priorityScore: { lt: 40 } } }),
this.prisma.planReason.groupBy({
by: ['scenario', 'subKey'],
where: { plan: where },
_count: { _all: true },
}),
]);
// 批量拉患者简介(列表展示用,避免每行单查)
const patientIds = Array.from(new Set(rows.map((r) => r.patientId)));
const patients = patientIds.length > 0
? await this.prisma.patient.findMany({
where: { id: { in: patientIds } },
select: {
id: true,
externalId: true,
name: true,
phone: true,
phoneVerified: true,
gender: true,
birthDate: true,
},
})
: [];
const patientById = new Map(patients.map((p) => [p.id, p]));
// 按 sub_scenario(sub_key 的 '@' 前缀)归并,跨牙位累加信号数。
const bucket = new Map<string, { scenario: string; subScenario: string; label: string; signals: number }>();
for (const g of reasonGroups) {
const subScenario = (g.subKey ?? '').split('@')[0] || g.scenario;
const k = `${g.scenario}::${subScenario}`;
const prev = bucket.get(k);
bucket.set(k, {
scenario: g.scenario,
subScenario,
label: subLabelZh(g.scenario, subScenario),
signals: (prev?.signals ?? 0) + g._count._all,
});
}
const byScenario = [...bucket.values()].sort((a, b) => b.signals - a.signals);
return {
items: rows.map((p) => {
const base = serializePlan(p);
const patient = patientById.get(p.patientId);
return {
...base,
scenarioLabel: planScenarioLabel(base.scenario),
patient: {
externalId: patient?.externalId ?? '',
/// name 原值透出(权限内可见);nameMasked 保留作脱敏视图兜底
name: patient?.name ?? null,
nameMasked: maskName(patient?.name ?? null),
phoneMasked: maskPhone(patient?.phone ?? null),
phoneVerified: patient?.phoneVerified ?? false,
gender: patient?.gender ?? null,
age: patient?.birthDate ? calcAge(patient.birthDate) : null,
},
/// reasons 透 signals JSON,前端 ReasonLine 用字典翻译富文本(跟详情页同源)
reasons: p.reasons.map(toPlanReasonBrief),
};
}),
page: query.page,
pageSize: query.pageSize,
view: query.view ?? 'pool',
total,
byPriority: { high, mid, low },
byScenario,
};
}
......
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