Commit b5fdb1b9 by luoqi

fix(web): 已选筛选 chip 显示原始 code 而非中文;MCP 维度名同样漏改

现象:上次时间已选后显示「18m_plus ×」「12_18m ×」而不是「18 个月以上 ×」「12-18 个月 ×」。

根因:消费方按 `d.key` 查维度,而 source='patient' 的三个维度(上次时间 / 上次医生 / 偏好医生)
key 是空串、筛选串用的是 `id` —— 查不到维度 → 退回显示 value 本身。
加 `id` 时改了组装侧(plan.service)和面板渲染,漏了这两处消费点:

- patient-picker-rail 的已选 chip:改用 personaTagDimId 查维度;
  顺带把 `kv.split(':')` 改成按**首个冒号**拆(取值理论上可含冒号,如医生姓名)。
- mcp-server.factory 的 personaTags 说明:同样按 key 生成,会给 LLM 一个**空维度名**,
  它照着发就永远筛不出人。改用 personaTagDimId;开集维度(医生)options 为空,
  提示改成"自由文本 + 指向 GET /pac/v1/plans/doctors",免得 LLM 瞎猜姓名。

测试:persona-spec-drift 补一条 —— 闭集维度的每个取值都必须能按 **id** 查回中文,
锁住"用 id 一定查得回来"这个不变量。656 tests / 42 suites 全过。

本地实测:已选 chip 显示「0-3 个月 / 12-18 个月 / 18 个月以上」,
hover 提示「上次时间:0-3 个月(点击移除)」;请求带
personaTags=last_visit_bucket:0_3m,… 返回 200。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent d05d4b0f
...@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; ...@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
// clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明 // clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod'; import { z } from 'zod';
import { PERSONA_TAG_FILTER_DIMS } from '@pac/types'; import { PERSONA_TAG_FILTER_DIMS, personaTagDimId } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { PatientService } from '../patient/patient.service'; import { PatientService } from '../patient/patient.service';
import { PersonaService } from '../persona/persona.service'; import { PersonaService } from '../persona/persona.service';
...@@ -20,8 +20,13 @@ function jsonResult(data: unknown) { ...@@ -20,8 +20,13 @@ function jsonResult(data: unknown) {
* 格式给 LLM 看:`key(中文): code=中文 / code=中文 …`,一维一行。 * 格式给 LLM 看:`key(中文): code=中文 / code=中文 …`,一维一行。
* 入参格式:"key:value" 逗号串;同维多选 OR,跨维 AND(如 "rfm:important_value,urgency_level:urgent")。 * 入参格式:"key:value" 逗号串;同维多选 OR,跨维 AND(如 "rfm:important_value,urgency_level:urgent")。
*/ */
const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map( const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map((d) =>
(d) => `${d.key}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`, // ⚠️ 用 personaTagDimId 不用 d.key:source='patient' 的维度 key 为空串、筛选串用 id,
// 用 key 会给 LLM 一个空维度名,它照着发就永远筛不出人。
// 开集维度(医生)options 为空 → 提示取值是自由文本,并指向名单接口,免得 LLM 瞎猜姓名。
d.dynamic
? `${personaTagDimId(d)}(${d.nameZh}): <医生姓名,精确匹配;名单见 GET /pac/v1/plans/doctors>`
: `${personaTagDimId(d)}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`,
).join('\n'); ).join('\n');
const PERSONA_TAGS_DESC = const PERSONA_TAGS_DESC =
......
...@@ -110,6 +110,20 @@ describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => { ...@@ -110,6 +110,20 @@ describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => {
} }
}); });
test('⭐ 闭集维度的每个取值都要能查回中文 —— 否则已选 chip 显示成原始 code', () => {
// 2026-07-29 线上现象:已选标签显示成「18m_plus ×」而不是「18 个月以上 ×」。
// 根因是消费方按 d.key 查维度,而 source='patient' 的维度 key 为空串、筛选串用的是 id,
// 查不到维度 → 退回显示 value 本身。这里锁住"用 id 一定查得回来"。
for (const d of PERSONA_TAG_FILTER_DIMS) {
if (d.dynamic) continue; // 开集维度取值本身就是中文(医生姓名),无需查表
const dimId = personaTagDimId(d);
for (const o of d.options) {
const found = PERSONA_TAG_FILTER_DIMS.find((x) => personaTagDimId(x) === dimId);
expect(found?.options.find((x) => x.value === o.value)?.zh).toBeTruthy();
}
}
});
test('⭐ 维度标识必须唯一 —— 同一特征出多个可筛字段时靠它区分', () => { test('⭐ 维度标识必须唯一 —— 同一特征出多个可筛字段时靠它区分', () => {
// visit_recency 一个特征出三个字段(bucket / lastDoctor / preferredDoctor), // visit_recency 一个特征出三个字段(bucket / lastDoctor / preferredDoctor),
// 只靠 key 分不开;重复的 id 会让筛选串指向错的 dataPath,筛出错的人。 // 只靠 key 分不开;重复的 id 会让筛选串指向错的 dataPath,筛出错的人。
......
...@@ -267,8 +267,15 @@ export function PatientPickerRail({ ...@@ -267,8 +267,15 @@ export function PatientPickerRail({
{tags.size > 0 && ( {tags.size > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{[...tags].map((kv) => { {[...tags].map((kv) => {
const [k, v] = kv.split(':'); // ⚠️ 按**首个冒号**拆,且用 personaTagDimId 查维度(不是 d.key):
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === k); // source='patient' 的维度 key 为空串、筛选串用的是 id,按 key 查会查不到 →
// 退化成显示原始 code(「18m_plus」而不是「18 个月以上」)。
// 取值本身可能含冒号(医生姓名理论上),故不用 split 全拆。
const i = kv.indexOf(':');
const k = kv.slice(0, i);
const v = kv.slice(i + 1);
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => personaTagDimId(d) === k);
// 开集维度(医生)options 为空 → 取值本身就是中文姓名,直接显示
const opt = dim?.options.find((o) => o.value === v); const opt = dim?.options.find((o) => o.value === v);
return ( return (
<button <button
......
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