Commit c31e0b63 by luoqi

merge: fact-writer 四时间锚(worktree 会话产出)

parents 8ebe45ab 2cfc043c
# PAC design-sync 备注
## 仓库形态
- 无独立设计系统包、无 Storybook → **package 形态**。同步入口是 `apps/pac-web/src/ds/index.ts`(为 design-sync 新建的显式 re-export 桶),收录 `ui/` 14 个 shadcn 基础件 + `plan-detail/shared.tsx` 5 个工作台业务原语(Chip/PriorityBar/AIStamp/SidebarCard/MD)。
- `pkg=@pac/web`,`globalName=PAC`,`--entry apps/pac-web/src/ds/index.ts`,`--node-modules apps/pac-web/node_modules`
- 组件清单以 `componentSrcMap`(19 条 pin)为准,不靠 .d.ts 自动发现(app 是应用不是库,无 dist/无导出类型树)。
## Tailwind v4 CSS 管线(关键)
- 无 tailwind.config.js;v4 用 `@import "tailwindcss"` + `@theme`,globals.css 在 `apps/pac-web/src/app/globals.css`
- design-sync 用独立入口 `.design-sync/tailwind-entry.css`(@import globals + @source 扫 pac-web/src 和 previews),CLI(`@tailwindcss/cli`,装在 .ds-sync)编译到 `apps/pac-web/.ds-css/tailwind.css`(gitignored),cfg.cssEntry 指它。
- **重建顺序**:先跑 buildCmd(tailwindcss 编 CSS)→ 再 package-build。CSS 要扫到预览里的工具类,所以改了 previews/*.tsx 后必须重编 CSS 再 build,否则新类名不进产物。
## 组件排除
- `MD` 被 isComponentName 过滤掉(两字母全大写疑似常量/枚举)→ 只同步了 18/19。MD 是轻量 markdown 渲染器,设计系统里价值低,不救,接受 18。ds/index.ts 仍导出它(bundle 里有,只是无卡片)。
## 字体
- CSS 引用 "Microsoft YaHei"/"PingFang" 中文字体族,不 ship woff2(宿主/系统提供)→ 用 `runtimeFontPrefixes: ["Microsoft YaHei","PingFang"]` 抑制 [FONT_MISSING]。设计系统面板会用系统中文字体渲染,视觉无碍。
## 浮层卡片模式(cfg.overrides)
- Dialog/AlertDialog/Popover/HoverCard/DropdownMenu/Toaster → cardMode:single + 固定 viewport(open 态在卡内渲染,不逃逸)。Calendar → cardMode:column。
## 已知 render warns
- 无(本次跳过机检,--no-render-check;18 卡自检 iframe 均非空,截图确认令牌色/字体/Radix 结构正常)。
## Re-sync 风险
- **机检从未跑过**(用户选跳过 playwright)。下次若装了 chromium,去掉 --no-render-check 做一次真机检,可能暴露自检漏掉的问题。
- previews/*.tsx 里的示例文案是手写业务场景(黄莉/杭州高德诊所等),与真实数据无耦合,改 API 不影响;但组件 props 变更(如 Chip tone 增删)要同步改预览。
- ds/index.ts 是 design-sync 专属桶文件,不被 pac-web 应用引用——新增组件进设计系统需手动在此 re-export + componentSrcMap 登记。
- CSS 走 Tailwind CLI 全量扫描 apps/pac-web/src:若 pac-web 源码大改,产物类名集会变(通常无害,确定性重建即可)。
{
"projectId": "e75dbe4d-5024-46ff-8c82-9d0634f44a01",
"shape": "package",
"pkg": "@pac/web",
"globalName": "PAC",
"tsconfig": "tsconfig.json",
"cssEntry": ".ds-css/tailwind.css",
"buildCmd": ".ds-sync/node_modules/.bin/tailwindcss -i .design-sync/tailwind-entry.css -o apps/pac-web/.ds-css/tailwind.css --minify",
"componentSrcMap": {
"AlertDialog": "src/components/ui/alert-dialog.tsx",
"Badge": "src/components/ui/badge.tsx",
"Button": "src/components/ui/button.tsx",
"Calendar": "src/components/ui/calendar.tsx",
"Card": "src/components/ui/card.tsx",
"DatePicker": "src/components/ui/date-picker.tsx",
"Dialog": "src/components/ui/dialog.tsx",
"DropdownMenu": "src/components/ui/dropdown-menu.tsx",
"HoverCard": "src/components/ui/hover-card.tsx",
"Input": "src/components/ui/input.tsx",
"Popover": "src/components/ui/popover.tsx",
"Select": "src/components/ui/select.tsx",
"Tabs": "src/components/ui/tabs.tsx",
"Toaster": "src/components/ui/toaster.tsx",
"Chip": "src/components/plan-detail/shared.tsx",
"PriorityBar": "src/components/plan-detail/shared.tsx",
"AIStamp": "src/components/plan-detail/shared.tsx",
"SidebarCard": "src/components/plan-detail/shared.tsx",
"MD": "src/components/plan-detail/shared.tsx"
},
"runtimeFontPrefixes": [
"Microsoft YaHei",
"PingFang"
],
"overrides": {
"Dialog": {
"cardMode": "single",
"viewport": "560x420"
},
"AlertDialog": {
"cardMode": "single",
"viewport": "560x400"
},
"Popover": {
"cardMode": "single",
"viewport": "440x300"
},
"HoverCard": {
"cardMode": "single",
"viewport": "460x300"
},
"DropdownMenu": {
"cardMode": "single",
"viewport": "400x320"
},
"Toaster": {
"cardMode": "single",
"viewport": "480x320"
},
"Calendar": {
"cardMode": "column"
}
},
"readmeHeader": ".design-sync/conventions.md"
}
\ No newline at end of file
# PAC 设计系统 — 用法约定
PAC「疗效保障」是嵌进牙科宿主系统的**临床召回客服工作台**。栈:React 19 + Tailwind v4 + shadcn/ui(基于 Radix)。风格克制、信息密集、teal 品牌色主导、以边框而非阴影分隔。组件从 `window.PAC.*` 取,全部真实可用。
## 包裹与主题
**无需 Provider。** 所有组件靠 `styles.css` 里的 CSS 变量(`:root` 定义)取色,不依赖运行时主题上下文——直接用即可,唯一要求是页面加载了 `styles.css``Toaster` 例外:它是全局 toast 容器,每页**挂一次**(如根布局),再用 `toast()` / `toast.success()` / `toast.error()` 触发,别在每个组件里重复挂。
## 样式惯用法(两套并存,别混造)
这是 **Tailwind 工具类**系统,不是 props-样式或 CSS-modules。给你自己的布局胶水写工具类;组件外观由组件自身负责,你只需传对 variant/props。
**语义 CSS 变量(shadcn 层,组件内部用的角色色)**——改主题只动这些,组件不用改:
| 变量 | 角色 |
|---|---|
| `--primary` / `--primary-foreground` | 主交互色(= teal-600)/ 其上文字 |
| `--background` / `--foreground` | 页面底 / 主文字 |
| `--muted` / `--muted-foreground` | 弱化底 / 次要文字 |
| `--border` | 分隔线(PAC 特意加深) |
| `--card` `--popover` `--destructive` `--ring` | 卡片/浮层底、危险色、焦点环 |
**原始色阶工具类(你写布局时直接用的品牌调色板)**,真实存在于产物:
| 族 | 例(真实类名) | 用途 |
|---|---|---|
| 品牌 teal | `bg-teal-50` `text-teal-700` `ring-teal-200` `bg-teal-600` | 主色、选中态、标签 |
| 中性 slate | `text-slate-900` `text-slate-500` `border-slate-100` `bg-slate-50` | ~90% 界面 |
| 语义强调 | `text-rose-600`(危险)`text-amber-700`(进行中)`text-emerald-700`(成功)`text-sky-700`(待认领)`text-indigo-500`(AI) | 状态色 |
**尺寸口径**:PAC 偏小字密排——正文常 `text-[11px]`~`text-[13px]`,间距 `gap-1.5`/`gap-2``p-2`/`p-3`。别用大号松散排版。
## 真相在哪
- 样式:读 `styles.css`(它 `@import``_ds_bundle.css` 组件样式 + Tailwind 工具层),里面有全部 `--*` 变量与工具类的真实定义。
- 每个组件读 `components/<组>/<Name>/<Name>.prompt.md`(用法+示例)与 `<Name>.d.ts`(props 契约)。
- 分组:`actions`(Button/DropdownMenu)、`display`(Badge/Card/Tabs)、`forms`(Input/Select/Calendar/DatePicker)、`overlay`(Dialog/AlertDialog/Popover/HoverCard/Toaster)、`plan-detail`(工作台业务原语:Chip/PriorityBar/AIStamp/SidebarCard/MD)。
## 一个惯用片段
```tsx
import { Button, Card, CardHeader, CardTitle, CardContent, CardFooter, Chip } from '@pac/web';
// 召回池患者卡:库组件搭骨架,工具类做布局胶水
<Card className="w-[340px]">
<CardHeader>
<CardTitle className="text-base">
黄莉 <span className="ml-1 text-sm font-normal text-slate-500">女 · 51 岁</span>
</CardTitle>
</CardHeader>
<CardContent className="text-[12.5px] leading-relaxed text-slate-700">
36/46 缺牙拖月未治,重要价值新客,建议尽快邀约种植评估。
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip tone="teal" size="xs">重要价值</Chip>
<Chip tone="rose" size="xs" icon>应治未治</Chip>
</div>
</CardContent>
<CardFooter className="gap-2">
<Button size="sm">认领</Button>
<Button size="sm" variant="outline">查看详情</Button>
</CardFooter>
</Card>
```
**业务原语 Chip 的 tone**:`teal`/`slate`/`amber`/`rose`/`indigo`/`emerald`/`sky`/`violet`(不是 shadcn 的 variant,是 PAC 自有色板);`size``xs`/`sm`;`icon` 加圆点。**Button 的 variant**:`default`/`secondary`/`outline`/`ghost`/`destructive`/`link`,`size`:`sm`/`default`/`lg`/`icon`
import { AIStamp } from '@pac/web';
export const Sources = () => (
<div className="flex flex-col items-start gap-2 p-3">
<AIStamp relative="刚刚" source="agent" />
<AIStamp relative="5 分钟前" source="template_fallback" />
<AIStamp relative="2 小时前" source="manual" />
</div>
);
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@pac/web';
export const AbandonConfirm = () => (
<AlertDialog open>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>放弃跟进该患者?</AlertDialogTitle>
<AlertDialogDescription>
放弃后本条召回将关闭并进入 90 天冷静期,期间不再推荐。此操作可由主管恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>再想想</AlertDialogCancel>
<AlertDialogAction className="bg-rose-600 hover:bg-rose-700">确认放弃</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
import { Badge } from '@pac/web';
export const Variants = () => (
<div className="flex flex-wrap items-center gap-2 p-2">
<Badge>默认</Badge>
<Badge variant="secondary">次要</Badge>
<Badge variant="destructive">警示</Badge>
<Badge variant="outline">描边</Badge>
</div>
);
export const Statuses = () => (
<div className="flex flex-wrap items-center gap-2 p-2">
<Badge variant="secondary">待认领</Badge>
<Badge>进行中</Badge>
<Badge variant="outline">已完成</Badge>
<Badge variant="destructive">已放弃</Badge>
</div>
);
import { Button } from '@pac/web';
import { PhoneCall, RefreshCw } from 'lucide-react';
export const Variants = () => (
<div className="flex flex-wrap items-center gap-2 p-2">
<Button>认领患者</Button>
<Button variant="secondary">查看档案</Button>
<Button variant="outline">新建预约</Button>
<Button variant="ghost">取消</Button>
<Button variant="destructive">放弃跟进</Button>
<Button variant="link">查看全部</Button>
</div>
);
export const Sizes = () => (
<div className="flex flex-wrap items-center gap-2 p-2">
<Button size="sm">小按钮</Button>
<Button>默认</Button>
<Button size="lg">大按钮</Button>
<Button size="icon" aria-label="拨打"><PhoneCall className="h-4 w-4" /></Button>
</div>
);
export const WithIcon = () => (
<div className="flex flex-wrap items-center gap-2 p-2">
<Button><PhoneCall className="h-4 w-4" /> 拨打电话</Button>
<Button variant="outline"><RefreshCw className="h-4 w-4" /> 刷新数据</Button>
</div>
);
export const Disabled = () => (
<div className="flex flex-wrap items-center gap-2 p-2">
<Button disabled>提交中…</Button>
<Button variant="outline" disabled>不可用</Button>
</div>
);
import { Calendar } from '@pac/web';
export const SingleSelect = () => (
<div className="p-2">
<Calendar mode="single" selected={new Date(2026, 6, 15)} defaultMonth={new Date(2026, 6, 1)} />
</div>
);
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@pac/web';
export const PatientCard = () => (
<Card className="w-[340px]">
<CardHeader>
<CardTitle>黄莉 <span className="ml-1 text-sm font-normal text-slate-500">女 · 51 岁</span></CardTitle>
<CardDescription>杭州高德诊所 · 病历号 ZJ0A034947</CardDescription>
</CardHeader>
<CardContent className="text-sm text-slate-700">
36/46 缺牙及多颗坏牙拖月未治,重要价值新客,建议尽快邀约种植评估。
</CardContent>
<CardFooter className="gap-2">
<Button size="sm">认领</Button>
<Button size="sm" variant="outline">查看详情</Button>
</CardFooter>
</Card>
);
export const Simple = () => (
<Card className="w-[300px]">
<CardHeader>
<CardTitle className="text-base">本周召回统计</CardTitle>
</CardHeader>
<CardContent className="text-sm text-slate-600">
已完成 42 通回访,转化新预约 11 个。
</CardContent>
</Card>
);
import { Chip } from '@pac/web';
export const Tones = () => (
<div className="flex flex-wrap items-center gap-2 p-2">
<Chip tone="teal">重要价值</Chip>
<Chip tone="amber">进行中</Chip>
<Chip tone="rose">应治未治</Chip>
<Chip tone="emerald">已完成</Chip>
<Chip tone="sky">待认领</Chip>
<Chip tone="indigo">AI 生成</Chip>
<Chip tone="violet">深度分析</Chip>
<Chip tone="slate">默认</Chip>
</div>
);
export const WithIconDot = () => (
<div className="flex flex-wrap items-center gap-2 p-2">
<Chip tone="rose" icon>应治未治</Chip>
<Chip tone="amber" icon>高优先级</Chip>
<Chip tone="emerald" icon size="xs">小号带点</Chip>
</div>
);
import { DatePicker } from '@pac/web';
export const WithValue = () => (
<div className="w-[260px] p-2">
<DatePicker value={new Date(2026, 6, 15)} onChange={() => {}} placeholder="选择回访日期" />
</div>
);
export const Empty = () => (
<div className="w-[260px] p-2">
<DatePicker onChange={() => {}} placeholder="选择回访日期" />
</div>
);
import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@pac/web';
export const AppointmentConfirm = () => (
<Dialog open>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>确认新建预约</DialogTitle>
<DialogDescription>为 黄莉(ZJ0A034947)预约种植评估,2026-07-22 下午。</DialogDescription>
</DialogHeader>
<div className="text-sm text-slate-600">预约成功后本次召回任务将自动归档。</div>
<DialogFooter>
<Button variant="outline">取消</Button>
<Button>确认预约</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@pac/web';
export const RowActions = () => (
<div className="flex h-[260px] items-start justify-center p-2">
<DropdownMenu defaultOpen modal={false}>
<DropdownMenuTrigger asChild><Button variant="outline" size="sm">操作</Button></DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>患者操作</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>认领</DropdownMenuItem>
<DropdownMenuItem>查看档案</DropdownMenuItem>
<DropdownMenuItem>新建预约</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-rose-600">放弃跟进</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@pac/web';
export const PatientSummary = () => (
<div className="flex h-[220px] items-start justify-center p-2">
<HoverCard open>
<HoverCardTrigger asChild>
<span className="cursor-help font-medium text-teal-700 underline decoration-dotted">黄莉</span>
</HoverCardTrigger>
<HoverCardContent className="w-72 text-sm">
<p className="font-semibold">黄莉 · 女 · 51 岁</p>
<p className="mt-1 text-slate-600">重要价值新客,有种植、修复、拔牙及补牙四项应治未治项目待跟进,偏好工作日下午就诊。</p>
</HoverCardContent>
</HoverCard>
</div>
);
import { Input } from '@pac/web';
export const States = () => (
<div className="flex w-[300px] flex-col gap-3 p-2">
<Input placeholder="姓名 / 手机 / 病历号" />
<Input defaultValue="黄莉" />
<Input disabled placeholder="不可编辑" />
</div>
);
export const Types = () => (
<div className="flex w-[300px] flex-col gap-3 p-2">
<Input type="search" placeholder="搜索患者…" />
<Input type="tel" placeholder="联系电话" defaultValue="138****5678" />
</div>
);
import { Button, Popover, PopoverContent, PopoverTrigger } from '@pac/web';
export const FilterPopover = () => (
<div className="flex h-[240px] items-start justify-center p-2">
<Popover defaultOpen>
<PopoverTrigger asChild><Button variant="outline" size="sm">画像标签</Button></PopoverTrigger>
<PopoverContent className="w-56">
<p className="mb-2 text-xs font-medium text-slate-500">按画像圈人</p>
<div className="flex flex-wrap gap-1.5 text-xs">
<span className="rounded-full border border-teal-300 bg-teal-50 px-2 py-0.5 text-teal-700">重要价值</span>
<span className="rounded-full border border-slate-200 px-2 py-0.5 text-slate-600">种植意向</span>
<span className="rounded-full border border-slate-200 px-2 py-0.5 text-slate-600">近一年到诊</span>
</div>
</PopoverContent>
</Popover>
</div>
);
import { PriorityBar } from '@pac/web';
export const Levels = () => (
<div className="flex flex-col gap-3 p-3">
<PriorityBar score={15} label="低" />
<PriorityBar score={45} label="中" />
<PriorityBar score={68} label="高" />
<PriorityBar score={92} label="紧急" />
</div>
);
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@pac/web';
export const ClinicSelect = () => (
<div className="w-[260px] p-2">
<Select defaultValue="gaode">
<SelectTrigger><SelectValue placeholder="选择诊所" /></SelectTrigger>
<SelectContent>
<SelectItem value="gaode">杭州高德诊所</SelectItem>
<SelectItem value="dasha">杭州大厦诊所</SelectItem>
<SelectItem value="chaoyang">北京朝阳公园诊所</SelectItem>
</SelectContent>
</Select>
</div>
);
export const Placeholder = () => (
<div className="w-[260px] p-2">
<Select>
<SelectTrigger><SelectValue placeholder="全部诊所" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">全部诊所</SelectItem>
</SelectContent>
</Select>
</div>
);
import { Chip, SidebarCard } from '@pac/web';
export const InfoCard = () => (
<div className="w-[300px] p-2">
<SidebarCard
title="画像标签"
meta="11 项"
action={<button className="text-[10.5px] text-teal-700 hover:underline">详情 →</button>}
>
<p className="text-[12.5px] leading-relaxed text-slate-700">
重要价值新客且为银行私行权益客户,有种植、修复、拔牙及补牙四项应治未治项目待跟进。
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip tone="teal" size="xs">重要价值</Chip>
<Chip tone="rose" size="xs">应治未治</Chip>
</div>
</SidebarCard>
</div>
);
export const Collapsed = () => (
<div className="w-[300px] p-2">
<SidebarCard title="治疗历史" meta="6 项" defaultOpen={false}>
<p className="text-sm">折叠时不可见的内容。</p>
</SidebarCard>
</div>
);
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@pac/web';
export const RecallTabs = () => (
<Tabs defaultValue="pool" className="w-[360px] p-2">
<TabsList>
<TabsTrigger value="pool">召回池</TabsTrigger>
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="done">已完成</TabsTrigger>
</TabsList>
<TabsContent value="pool" className="text-sm text-slate-600 p-2">
997 位待认领患者,按优先级排序。
</TabsContent>
<TabsContent value="mine" className="text-sm text-slate-600 p-2">我认领的 2 位患者。</TabsContent>
<TabsContent value="done" className="text-sm text-slate-600 p-2">本周已完成 42 通回访。</TabsContent>
</Tabs>
);
import { Toaster, toast } from '@pac/web';
import { useEffect } from 'react';
export const Toasts = () => {
useEffect(() => {
toast.success('已认领 黄莉');
toast.error('提交失败,请重试');
toast('已同步该患者最新资料');
}, []);
return (
<div className="h-[280px]">
<Toaster position="top-center" />
</div>
);
};
/* design-sync 专用 Tailwind 编译入口 — 产出完整静态样式表给设计系统 bundle。
* @source 显式罗列扫描面:pac-web 源码(组件自身的类)+ 预览源码(卡片布局胶水类)。 */
@import "../apps/pac-web/src/app/globals.css";
@source "../apps/pac-web/src";
@source "./previews";
...@@ -38,3 +38,11 @@ docs/.obsidian/ ...@@ -38,3 +38,11 @@ docs/.obsidian/
# Next.js 自动生成(dev/build 间路径翻转,build 每次重生成) # Next.js 自动生成(dev/build 间路径翻转,build 每次重生成)
apps/pac-web/next-env.d.ts apps/pac-web/next-env.d.ts
# design-sync(暂存脚本/构建产物/机器状态——.design-sync 下未忽略的才入库)
.ds-sync/
ds-bundle/
.design-sync/.cache/
.design-sync/learnings/
.design-sync/node_modules
apps/pac-web/.ds-css/
...@@ -195,7 +195,7 @@ export class PlanEngineService { ...@@ -195,7 +195,7 @@ export class PlanEngineService {
patientIds, patientIds,
now, now,
); );
const EMPTY_SNOOZE = new Set<string>(); const EMPTY_SNOOZE = new Map<string, Date>();
const logRows: Prisma.PlanGenerationLogCreateManyInput[] = []; const logRows: Prisma.PlanGenerationLogCreateManyInput[] = [];
const concurrency = Math.max(1, Number(process.env.PAC_PLAN_BATCH_CONCURRENCY) || 8); const concurrency = Math.max(1, Number(process.env.PAC_PLAN_BATCH_CONCURRENCY) || 8);
const entries = [...hitsByPatient.entries()]; const entries = [...hitsByPatient.entries()];
...@@ -209,7 +209,7 @@ export class PlanEngineService { ...@@ -209,7 +209,7 @@ export class PlanEngineService {
hits, hits,
prefetched: { prefetched: {
latest: latestByPatient.get(patientId) ?? null, latest: latestByPatient.get(patientId) ?? null,
snoozedKeys: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE, snoozedAnchors: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE,
personaId: personaByPatient.get(patientId) ?? null, personaId: personaByPatient.get(patientId) ?? null,
}, },
}); });
...@@ -301,7 +301,7 @@ export class PlanEngineService { ...@@ -301,7 +301,7 @@ export class PlanEngineService {
hits: ScenarioHitWithKey[]; hits: ScenarioHitWithKey[];
prefetched?: { prefetched?: {
latest: PlanWithReasons | null; latest: PlanWithReasons | null;
snoozedKeys: Set<string>; snoozedAnchors: Map<string, Date>;
personaId: string | null; personaId: string | null;
}; };
}): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> { }): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> {
...@@ -327,15 +327,23 @@ export class PlanEngineService { ...@@ -327,15 +327,23 @@ export class PlanEngineService {
// 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。 // 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
// 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。 // 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
// 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。 // 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
const snoozedKeys = prefetched const snoozedAnchors = prefetched
? prefetched.snoozedKeys ? prefetched.snoozedAnchors
: await this.fetchSnoozedSignalKeys(scope, patientId, scope.now); : await this.fetchSnoozedSignalKeys(scope, patientId, scope.now);
const usableHits = const usableHits =
snoozedKeys.size === 0 snoozedAnchors.size === 0
? hits ? hits
: hits.filter((h) => !snoozedKeys.has(`${h.scenarioKey}|${h.subKey ?? ''}`)); : hits.filter((h) => {
const anchor = snoozedAnchors.get(`${h.scenarioKey}|${h.subKey ?? ''}`);
if (!anchor) return true; // 不在抑制集 → 放行
// ⭐ 时间锚逃逸:cluster 内存在"结案之后新发"的同类信号 → 视为新机会,放行复活。
// ("永久"抑制只对结案时已知的问题永久 —— 外院种植失败回院再诊断/多年后新发同类
// @whole 病种,不该被老 snooze 压死。)latest 缺省(旧 scenario 未提供)→ 维持全压。
const latest = h.latestSignalOccurredAt;
return latest != null && latest > anchor;
});
if (usableHits.length === 0) { if (usableHits.length === 0) {
// 当前所有活信号都在冷静期内(= 刚结案那批)→ 不生成新 plan // 当前所有活信号都在冷静期内(= 刚结案那批,且无结案后新发)→ 不生成新 plan
return 'suppressed'; return 'suppressed';
} }
...@@ -448,19 +456,22 @@ export class PlanEngineService { ...@@ -448,19 +456,22 @@ export class PlanEngineService {
} }
/** /**
* 信号级抑制集 —— 该患者所有"终态(completed/abandoned)+ 冷静期未到期(snoozedUntil>now)"plan * 信号级抑制集(带时间锚)—— 该患者所有"终态(completed/abandoned)+ 冷静期未到期
* 覆盖的召回理由 (scenario, subKey) 并集 * (snoozedUntil>now)"plan 覆盖的召回理由 (scenario, subKey) → **结案锚点** Map
* *
* 语义:这些信号最近被召回处理过(成功转化/拒绝/外院/熔断…),在冷静期内不重复召回; * 语义:这些信号最近被召回处理过(成功转化/拒绝/外院/熔断…),在冷静期内不重复召回;
* 但**不在此集合里的全新诊断不受影响**,照常生成召回(这是信号级抑制的核心,跟召回算法同粒度)。 * 但**不在此集合里的全新诊断不受影响**,照常生成召回(这是信号级抑制的核心,跟召回算法同粒度)。
* 多次结案累积正确:取全部未到期终态 plan 的并集,而非只看最新一条。 * ⭐ 时间锚(2026-07 修):同 key 也不一刀切 —— 结案锚点之后**新发**的同类信号(hit 的
* 返回 key 格式 `${scenario}|${subKey ?? ''}`,跟 upsertPlan 过滤口径一致。 * latestSignalOccurredAt > anchor)放行复活。修复的坑:外院/无效的"永久"snooze 会把
* 结案后多年新发的同牙位/@whole 同类诊断也永久压死(粒度只到 key,无时间维度)。
* 多次结案累积正确:取全部未到期终态 plan 的并集;同 key 多次结案取**最新**锚点。
* key 格式 `${scenario}|${subKey ?? ''}`,跟 upsertPlan 过滤口径一致。
*/ */
private async fetchSnoozedSignalKeys( private async fetchSnoozedSignalKeys(
scope: ScenarioScope, scope: ScenarioScope,
patientId: string, patientId: string,
now: Date, now: Date,
): Promise<Set<string>> { ): Promise<Map<string, Date>> {
const terminalPlans = await this.prisma.followupPlan.findMany({ const terminalPlans = await this.prisma.followupPlan.findMany({
where: { where: {
hostId: scope.hostId, hostId: scope.hostId,
...@@ -469,15 +480,20 @@ export class PlanEngineService { ...@@ -469,15 +480,20 @@ export class PlanEngineService {
status: { in: ['completed', 'abandoned'] }, status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now }, snoozedUntil: { gt: now },
}, },
select: { reasons: { select: { scenario: true, subKey: true } } }, select: {
updatedAt: true,
reasons: { select: { scenario: true, subKey: true } },
// 结案锚点 = 结案那次 execution 的 createdAt(事件表,不可变)。
// ⚠️ 不能用 plan.updatedAt 当首选锚:召回反馈(👍/👎)等后续写操作会顶后 updatedAt,
// 把"结案后、反馈前"新发的信号误压。updatedAt 仅作无 execution 时的兜底(理论不发生)。
executions: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
},
}); });
const keys = new Set<string>(); return buildSnoozeAnchors(terminalPlans);
for (const plan of terminalPlans) {
for (const r of plan.reasons) {
keys.add(`${r.scenario}|${r.subKey ?? ''}`);
}
}
return keys;
} }
/** /**
...@@ -492,11 +508,11 @@ export class PlanEngineService { ...@@ -492,11 +508,11 @@ export class PlanEngineService {
now: Date, now: Date,
): Promise<{ ): Promise<{
latestByPatient: Map<string, PlanWithReasons>; latestByPatient: Map<string, PlanWithReasons>;
snoozedByPatient: Map<string, Set<string>>; snoozedByPatient: Map<string, Map<string, Date>>;
personaByPatient: Map<string, string>; personaByPatient: Map<string, string>;
}> { }> {
const latestByPatient = new Map<string, PlanWithReasons>(); const latestByPatient = new Map<string, PlanWithReasons>();
const snoozedByPatient = new Map<string, Set<string>>(); const snoozedByPatient = new Map<string, Map<string, Date>>();
const personaByPatient = new Map<string, string>(); const personaByPatient = new Map<string, string>();
const CHUNK = 2000; const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) { for (let i = 0; i < patientIds.length; i += CHUNK) {
...@@ -510,7 +526,7 @@ export class PlanEngineService { ...@@ -510,7 +526,7 @@ export class PlanEngineService {
for (const p of plans) { for (const p of plans) {
if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p); if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p);
} }
// snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason 并集) // snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason → 结案锚点)
const terminal = await this.prisma.followupPlan.findMany({ const terminal = await this.prisma.followupPlan.findMany({
where: { where: {
hostId: scope.hostId, hostId: scope.hostId,
...@@ -519,13 +535,25 @@ export class PlanEngineService { ...@@ -519,13 +535,25 @@ export class PlanEngineService {
status: { in: ['completed', 'abandoned'] }, status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now }, snoozedUntil: { gt: now },
}, },
select: { patientId: true, reasons: { select: { scenario: true, subKey: true } } }, select: {
patientId: true,
updatedAt: true,
reasons: { select: { scenario: true, subKey: true } },
// 结案锚点 = 结案 execution.createdAt(不可变;updatedAt 会被召回反馈等后续写顶后,仅兜底)
executions: { orderBy: { createdAt: 'desc' }, take: 1, select: { createdAt: true } },
},
}); });
{
const plansByPatient = new Map<string, typeof terminal>();
for (const t of terminal) { for (const t of terminal) {
if (!t.patientId) continue; if (!t.patientId) continue;
const set = snoozedByPatient.get(t.patientId) ?? new Set<string>(); const arr = plansByPatient.get(t.patientId) ?? [];
for (const r of t.reasons) set.add(`${r.scenario}|${r.subKey ?? ''}`); arr.push(t);
snoozedByPatient.set(t.patientId, set); plansByPatient.set(t.patientId, arr);
}
for (const [pid, plans] of plansByPatient) {
snoozedByPatient.set(pid, buildSnoozeAnchors(plans));
}
} }
// active persona id(每患最新 active 版本) // active persona id(每患最新 active 版本)
const personas = await this.prisma.persona.findMany({ const personas = await this.prisma.persona.findMany({
...@@ -565,3 +593,30 @@ export interface EngineRunResult { ...@@ -565,3 +593,30 @@ export interface EngineRunResult {
plansClosed: number; plansClosed: number;
durationMs: number; durationMs: number;
} }
/**
* 终态 plan → (scenario|subKey) → 结案锚点 Map(纯函数,fetchSnoozedSignalKeys / prefetchForBatch 共用)。
* 锚点 = 结案 execution.createdAt(事件表,不可变);无 execution(理论不发生)兜底 plan.updatedAt。
* 同 key 多次结案取**最新**锚 —— 最近一次人为处置才是"此后新发才算新机会"的分界线。
*/
export function buildSnoozeAnchors(
terminalPlans: Array<{
updatedAt?: Date;
reasons: Array<{ scenario: string; subKey: string | null }>;
executions?: Array<{ createdAt: Date }>;
}>,
): Map<string, Date> {
// 锚点解析不到(旧数据/未 select 关联)→ 远未来哨兵:latest > anchor 永不成立 = 永不逃逸,
// 严格等价旧的"key 命中即压"行为(宁可多压,不误放)。
const FAR_FUTURE = new Date(8640000000000000);
const anchors = new Map<string, Date>();
for (const plan of terminalPlans) {
const anchor = plan.executions?.[0]?.createdAt ?? plan.updatedAt ?? FAR_FUTURE;
for (const r of plan.reasons) {
const key = `${r.scenario}|${r.subKey ?? ''}`;
const prev = anchors.get(key);
if (!prev || anchor > prev) anchors.set(key, anchor);
}
}
return anchors;
}
...@@ -52,6 +52,11 @@ export interface ScenarioHit { ...@@ -52,6 +52,11 @@ export interface ScenarioHit {
/// 子场景标识(reason 文本内已含,落 plan_reasons.breakdown.subKey 便于 metrics 检索) /// 子场景标识(reason 文本内已含,落 plan_reasons.breakdown.subKey 便于 metrics 检索)
subKey?: string; subKey?: string;
/// cluster 内**最新**信号发生时间(时间锚抑制逃逸用,与 signals.signalOccurredAt 的"最早"锚相反):
/// 终态抑制(外院/拒绝的"永久" snooze)只压"结案时已知的问题" —— 结案后新发的同类诊断
/// (同 subKey 新 fact)应放行复活。引擎按 latest > 结案锚点 判断逃逸;缺省(旧 scenario)= 不逃逸。
latestSignalOccurredAt?: Date | null;
/// **W3 末新增**:结构化召回信号(plan_reasons.signals JSON 落库)。 /// **W3 末新增**:结构化召回信号(plan_reasons.signals JSON 落库)。
/// DB 存原始 enum / canonical code(不语义化),前端用 @pac/types 字典翻译富文本渲染。 /// DB 存原始 enum / canonical code(不语义化),前端用 @pac/types 字典翻译富文本渲染。
/// 详见 packages/types/src/schemas/reason-signals.ts /// 详见 packages/types/src/schemas/reason-signals.ts
......
...@@ -481,6 +481,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -481,6 +481,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 同 sub_scenario 多牙位各 1 行(36/46 都需充填都进库)。 // 同 sub_scenario 多牙位各 1 行(36/46 都需充填都进库)。
// 全口诊断(K05 等无牙位)→ '@whole';前端 / plan-aggregate 用 signals.toothPosition 区分语义。 // 全口诊断(K05 等无牙位)→ '@whole';前端 / plan-aggregate 用 signals.toothPosition 区分语义。
subKey: `${subKey}@${(r.tooth ?? '').trim() || 'whole'}`, subKey: `${subKey}@${(r.tooth ?? '').trim() || 'whole'}`,
// 时间锚抑制逃逸:cluster 内最新信号时间(单 sig cluster = 自身时间)
latestSignalOccurredAt: r.cluster_latest_occurred_at ?? r.signal_occurred_at,
// 结构化召回信号(DB 存 raw enum / canonical code,前端字典翻译富文本) // 结构化召回信号(DB 存 raw enum / canonical code,前端字典翻译富文本)
// triggers 是 cluster 内全量(去重),前端按 type set 大小渲染 (诊断) / (医生建议) / (诊断+医生建议) // triggers 是 cluster 内全量(去重),前端按 type set 大小渲染 (诊断) / (医生建议) / (诊断+医生建议)
signals: { signals: {
...@@ -581,6 +583,8 @@ interface HitRow { ...@@ -581,6 +583,8 @@ interface HitRow {
cluster_has_recommendation?: boolean; // cluster 含至少 1 个 recommendation_record cluster_has_recommendation?: boolean; // cluster 含至少 1 个 recommendation_record
cluster_confidence?: number; // cluster 最优来源置信度(诊断1.0/建议0.8/影像0.5,取 max) cluster_confidence?: number; // cluster 最优来源置信度(诊断1.0/建议0.8/影像0.5,取 max)
cluster_triggers?: Array<{ type: string; code: string }>; // cluster 内 unique (type, code),给 signals.triggers cluster_triggers?: Array<{ type: string; code: string }>; // cluster 内 unique (type, code),给 signals.triggers
/// cluster 内**最新**信号时间(lead 锚最早=daysSince 口径;此字段锚最新=终态抑制时间锚逃逸口径)
cluster_latest_occurred_at?: Date;
} }
/// 单 sig 来源置信度(v3.1 三源标定):诊断 1.0 / 医生建议 0.8 / 影像AI 0.5。 /// 单 sig 来源置信度(v3.1 三源标定):诊断 1.0 / 医生建议 0.8 / 影像AI 0.5。
...@@ -634,6 +638,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] { ...@@ -634,6 +638,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
lead.cluster_has_recommendation = wholeMouth.some((x) => x.signal_type === 'recommendation_record'); lead.cluster_has_recommendation = wholeMouth.some((x) => x.signal_type === 'recommendation_record');
lead.cluster_confidence = Math.max(...wholeMouth.map(sourceConfidence)); lead.cluster_confidence = Math.max(...wholeMouth.map(sourceConfidence));
lead.cluster_triggers = uniqueTriggers(wholeMouth); lead.cluster_triggers = uniqueTriggers(wholeMouth);
lead.cluster_latest_occurred_at = latestOccurredAt(wholeMouth);
merged.push(lead); merged.push(lead);
} }
...@@ -673,6 +678,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] { ...@@ -673,6 +678,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
lead.cluster_has_recommendation = c.rows.some((x) => x.signal_type === 'recommendation_record'); lead.cluster_has_recommendation = c.rows.some((x) => x.signal_type === 'recommendation_record');
lead.cluster_confidence = Math.max(...c.rows.map(sourceConfidence)); lead.cluster_confidence = Math.max(...c.rows.map(sourceConfidence));
lead.cluster_triggers = uniqueTriggers(c.rows); lead.cluster_triggers = uniqueTriggers(c.rows);
lead.cluster_latest_occurred_at = latestOccurredAt(c.rows);
merged.push(lead); merged.push(lead);
} }
} }
...@@ -690,6 +696,17 @@ function triggerTypeOf(row: { signal_type: string; code_source: string | null }) ...@@ -690,6 +696,17 @@ function triggerTypeOf(row: { signal_type: string; code_source: string | null })
: 'diagnosis'; : 'diagnosis';
} }
/// cluster 内最新信号时间 —— 时间锚抑制逃逸用(lead 锚最早,此处锚最新,两口径并存各司其职)。
/// pg timestamptz 经 $queryRaw 已是 Date;防御性过滤无效值(理论上 ④ 闸已保证非空)。
function latestOccurredAt(rows: HitRow[]): Date | undefined {
let max: Date | undefined;
for (const r of rows) {
const d = r.signal_occurred_at;
if (d instanceof Date && !Number.isNaN(d.getTime()) && (!max || d > max)) max = d;
}
return max;
}
function uniqueTriggers(rows: HitRow[]): Array<{ type: string; code: string }> { function uniqueTriggers(rows: HitRow[]): Array<{ type: string; code: string }> {
const seen = new Set<string>(); const seen = new Set<string>();
const out: Array<{ type: string; code: string }> = []; const out: Array<{ type: string; code: string }> = [];
......
...@@ -14,6 +14,11 @@ function factVersionKey(sourceUnit: string, subjectId: string): string { ...@@ -14,6 +14,11 @@ function factVersionKey(sourceUnit: string, subjectId: string): string {
return `${sourceUnit}#${subjectId}`; return `${sourceUnit}#${subjectId}`;
} }
/** 时刻相等 — 双方皆缺(null/undefined)视为等;毫秒精度(列是 timestamptz(3))。 */
function sameInstant(a: Date | null | undefined, b: Date | null | undefined): boolean {
return (a?.getTime() ?? null) === (b?.getTime() ?? null);
}
/** /**
* FactWriter — patient_facts 版本流写入器 * FactWriter — patient_facts 版本流写入器
* *
...@@ -21,9 +26,9 @@ function factVersionKey(sourceUnit: string, subjectId: string): string { ...@@ -21,9 +26,9 @@ function factVersionKey(sourceUnit: string, subjectId: string): string {
* *
* 行为(对单 subject_id): * 行为(对单 subject_id):
* 1. 找当前 active 版本(partial UNIQUE 保证唯一) * 1. 找当前 active 版本(partial UNIQUE 保证唯一)
* 2. 计算 draft.content 的稳定 hash; * 2. 计算 draft.content 的稳定 hash;比较 hash + status + 时间锚(occurredAt/plannedFor/validFrom/validUntil)
* ┌─ 跟 active 一致 → 幂等(把当前 transactionId 追加到 active.transactionIds,如未存在) * ┌─ 一致 → 幂等(把当前 transactionId 追加到 active.transactionIds,如未存在)
* └─ 不一致 / 无 active → supersede 旧版本 → 插入新版本 version = old.version + 1(无则 1) * └─ 任一不同 / 无 active → supersede 旧版本 → 插入新版本 version = old.version + 1(无则 1)
* *
* 并发说明: * 并发说明:
* - W2 是单进程顺序写,不需要锁 * - W2 是单进程顺序写,不需要锁
...@@ -117,8 +122,9 @@ export class FactWriter { ...@@ -117,8 +122,9 @@ export class FactWriter {
const latestHash = this.hashContent(latest.content as Prisma.InputJsonValue); const latestHash = this.hashContent(latest.content as Prisma.InputJsonValue);
const sameContent = latestHash === newHash; const sameContent = latestHash === newHash;
const sameStatus = latest.status === draftStatus; const sameStatus = latest.status === draftStatus;
const sameTemporal = this.sameTemporalAnchors(latest, validatedDraft);
if (sameContent && sameStatus) { if (sameContent && sameStatus && sameTemporal) {
// 完全一致 — 幂等。把当前 transaction 加进证据数组(去重) // 完全一致 — 幂等。把当前 transaction 加进证据数组(去重)
if (!latest.transactionIds.includes(transactionId)) { if (!latest.transactionIds.includes(transactionId)) {
await tx.patientFact.update({ await tx.patientFact.update({
...@@ -140,7 +146,7 @@ export class FactWriter { ...@@ -140,7 +146,7 @@ export class FactWriter {
}; };
} }
// 有变化(content 或 status)— 如果 latest 是 active,supersede 它; // 有变化(content / status / 时间锚)— 如果 latest 是 active,supersede 它;
// 终态版本(cancelled/fulfilled/expired/invalidated/superseded)不动,只追新版本。 // 终态版本(cancelled/fulfilled/expired/invalidated/superseded)不动,只追新版本。
if (latest.status === FactStatus.ACTIVE) { if (latest.status === FactStatus.ACTIVE) {
await tx.patientFact.update({ await tx.patientFact.update({
...@@ -258,6 +264,10 @@ export class FactWriter { ...@@ -258,6 +264,10 @@ export class FactWriter {
hash: string; hash: string;
transactionIds: string[]; transactionIds: string[];
sourceUpdatedAt: Date | null; sourceUpdatedAt: Date | null;
occurredAt: Date | null;
plannedFor: Date | null;
validFrom: Date | null;
validUntil: Date | null;
}>(); }>();
for (const [k, latest] of latestBySubject.entries()) { for (const [k, latest] of latestBySubject.entries()) {
liveLatest.set(k, { liveLatest.set(k, {
...@@ -267,6 +277,10 @@ export class FactWriter { ...@@ -267,6 +277,10 @@ export class FactWriter {
hash: this.hashContent(latest.content as Prisma.InputJsonValue), hash: this.hashContent(latest.content as Prisma.InputJsonValue),
transactionIds: latest.transactionIds, transactionIds: latest.transactionIds,
sourceUpdatedAt: latest.sourceUpdatedAt ?? null, sourceUpdatedAt: latest.sourceUpdatedAt ?? null,
occurredAt: latest.occurredAt ?? null,
plannedFor: latest.plannedFor ?? null,
validFrom: latest.validFrom ?? null,
validUntil: latest.validUntil ?? null,
}); });
} }
...@@ -294,7 +308,12 @@ export class FactWriter { ...@@ -294,7 +308,12 @@ export class FactWriter {
continue; continue;
} }
if (live && live.hash === entry.hash && live.status === draftStatus) { if (
live &&
live.hash === entry.hash &&
live.status === draftStatus &&
this.sameTemporalAnchors(live, entry.draft)
) {
// 内容一致 + 状态一致 // 内容一致 + 状态一致
if (live.id && !live.transactionIds.includes(entry.transactionId)) { if (live.id && !live.transactionIds.includes(entry.transactionId)) {
toEvidenceAppend.push({ factId: live.id, transactionId: entry.transactionId }); toEvidenceAppend.push({ factId: live.id, transactionId: entry.transactionId });
...@@ -354,6 +373,10 @@ export class FactWriter { ...@@ -354,6 +373,10 @@ export class FactWriter {
hash: entry.hash, hash: entry.hash,
transactionIds: [entry.transactionId], transactionIds: [entry.transactionId],
sourceUpdatedAt: entry.sourceUpdatedAt ?? null, sourceUpdatedAt: entry.sourceUpdatedAt ?? null,
occurredAt: entry.draft.occurredAt ?? null,
plannedFor: entry.draft.plannedFor ?? null,
validFrom: entry.draft.validFrom ?? null,
validUntil: entry.draft.validUntil ?? null,
}); });
results.push({ results.push({
action: live ? 'superseded' : 'created', action: live ? 'superseded' : 'created',
...@@ -394,6 +417,33 @@ export class FactWriter { ...@@ -394,6 +417,33 @@ export class FactWriter {
} }
/** /**
* 时间锚相等判定 — 变更检测的组成部分(与 content hash / status 并列)。
*
* occurredAt / plannedFor / validFrom / validUntil 是事实实质:时间轴排序、
* 召回窗口(COALESCE(occurred_at, planned_for))、过期 cron(valid_until)都直接消费。
* 宿主纠正事件时间(如时区修复整体平移)时,content 往往不变 — 只比 hash+status
* 会让纠正永远停在 evidence_appended,occurred_at 无法更新(时间锚不参与检测的旧 bug)。
*
* 不纳入:title / summary(展示层,规则引擎不读)、clinicId(如需纳入另行评估)。
*/
private sameTemporalAnchors(
latest: {
occurredAt: Date | null;
plannedFor: Date | null;
validFrom: Date | null;
validUntil: Date | null;
},
draft: Pick<FactDraft, 'occurredAt' | 'plannedFor' | 'validFrom' | 'validUntil'>,
): boolean {
return (
sameInstant(latest.occurredAt, draft.occurredAt) &&
sameInstant(latest.plannedFor, draft.plannedFor) &&
sameInstant(latest.validFrom, draft.validFrom) &&
sameInstant(latest.validUntil, draft.validUntil)
);
}
/**
* 稳定 JSON hash — 递归按 key 排序后 sha256。 * 稳定 JSON hash — 递归按 key 排序后 sha256。
* JSON.stringify 默认按 insertion order,key 顺序不同会算出不同 hash。 * JSON.stringify 默认按 insertion order,key 顺序不同会算出不同 hash。
*/ */
......
import { buildSnoozeAnchors } from '../src/modules/plan/engine/plan-engine.service';
/**
* 时间锚抑制(2026-07 修)单测 —— 纯函数层。
* 背景:终态 plan 的"永久" snooze 原来按 (scenario|subKey) 一刀切,结案后**新发**的同类
* 诊断(外院种植失败回院 / 多年后新发 @whole 病种)也被永久压死。修复 = 抑制集带结案锚点,
* 引擎按 hit.latestSignalOccurredAt > anchor 放行。本文件测锚点构建;逃逸比较是一行大小关系,
* 语义由锚点正确性保证。
*/
describe('buildSnoozeAnchors', () => {
const d = (s: string) => new Date(s);
const plan = (
anchorExec: string | null,
updatedAt: string,
keys: Array<[string, string | null]>,
) => ({
updatedAt: d(updatedAt),
reasons: keys.map(([scenario, subKey]) => ({ scenario, subKey })),
executions: anchorExec ? [{ createdAt: d(anchorExec) }] : [],
});
it('锚点取结案 execution.createdAt(不取 plan.updatedAt)', () => {
// updatedAt 被召回反馈等后续写操作顶后(2026-07-10),真结案在 2026-07-01
const anchors = buildSnoozeAnchors([
plan('2026-07-01T10:00:00Z', '2026-07-10T00:00:00Z', [['recall', 'missing_tooth@36']]),
]);
expect(anchors.get('recall|missing_tooth@36')).toEqual(d('2026-07-01T10:00:00Z'));
});
it('无 execution 时兜底 plan.updatedAt', () => {
const anchors = buildSnoozeAnchors([
plan(null, '2026-07-10T00:00:00Z', [['recall', 'perio@whole']]),
]);
expect(anchors.get('recall|perio@whole')).toEqual(d('2026-07-10T00:00:00Z'));
});
it('同 key 多次结案取最新锚(最近一次处置才是新旧分界)', () => {
const anchors = buildSnoozeAnchors([
plan('2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z', [['recall', 'missing_tooth@36']]),
plan('2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', [['recall', 'missing_tooth@36']]),
]);
expect(anchors.get('recall|missing_tooth@36')).toEqual(d('2026-06-01T00:00:00Z'));
});
it('subKey=null 归一为空串 key(与引擎过滤口径一致)', () => {
const anchors = buildSnoozeAnchors([
plan('2026-07-01T00:00:00Z', '2026-07-01T00:00:00Z', [['recall', null]]),
]);
expect(anchors.get('recall|')).toEqual(d('2026-07-01T00:00:00Z'));
});
it('多 plan 多 key 并集(多次结案累积)', () => {
const anchors = buildSnoozeAnchors([
plan('2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', [['recall', 'caries@45']]),
plan('2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', [['recall', 'perio@whole']]),
]);
expect(anchors.size).toBe(2);
expect(anchors.get('recall|caries@45')).toEqual(d('2026-05-01T00:00:00Z'));
expect(anchors.get('recall|perio@whole')).toEqual(d('2026-06-01T00:00:00Z'));
});
// 逃逸语义端到端(用与引擎 336 行同款比较式,防口径漂移)
it('逃逸判定:结案后新发信号(latest > anchor)放行;老信号照压', () => {
const anchors = buildSnoozeAnchors([
plan('2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', [['recall', 'missing_tooth@36']]),
]);
const escapes = (key: string, latest: Date | null) => {
const anchor = anchors.get(key);
if (!anchor) return true;
return latest != null && latest > anchor;
};
// 老 fact(结案前 2024)聚类 → latest=2024 → 仍压
expect(escapes('recall|missing_tooth@36', d('2024-03-01T00:00:00Z'))).toBe(false);
// 结案后新发(2027 复诊再诊断)→ latest=2027 → 放行复活
expect(escapes('recall|missing_tooth@36', d('2027-01-15T00:00:00Z'))).toBe(true);
// 旧 scenario 未提供 latest → 维持旧行为全压
expect(escapes('recall|missing_tooth@36', null)).toBe(false);
// 不在抑制集 → 放行
expect(escapes('recall|caries@45', d('2024-01-01T00:00:00Z'))).toBe(true);
});
});
...@@ -24,6 +24,9 @@ export const tone = (t?: string) => TONE[t ?? 'slate'] ?? TONE.slate!; ...@@ -24,6 +24,9 @@ export const tone = (t?: string) => TONE[t ?? 'slate'] ?? TONE.slate!;
// ────────────────────────────────────────── // ──────────────────────────────────────────
// Chip // Chip
// ────────────────────────────────────────── // ──────────────────────────────────────────
/**
* 语义色小标签 — tone: teal/slate/amber/rose/indigo/emerald/sky…,icon 可选,size: xs/sm。工作台高频原语。
*/
export function Chip({ export function Chip({
children, children,
tone: t = 'slate', tone: t = 'slate',
...@@ -59,6 +62,9 @@ export function Chip({ ...@@ -59,6 +62,9 @@ export function Chip({
// ────────────────────────────────────────── // ──────────────────────────────────────────
// PriorityBar — 5 段渐变,不显数字 // PriorityBar — 5 段渐变,不显数字
// ────────────────────────────────────────── // ──────────────────────────────────────────
/**
* 优先级五段条 — score 0-100 映射 5 格渐变(绿→琥珀→玫红),可带 label。
*/
export function PriorityBar({ export function PriorityBar({
score, score,
max = 100, max = 100,
...@@ -96,6 +102,9 @@ export function PriorityBar({ ...@@ -96,6 +102,9 @@ export function PriorityBar({
// ────────────────────────────────────────── // ──────────────────────────────────────────
// AIStamp // AIStamp
// ────────────────────────────────────────── // ──────────────────────────────────────────
/**
* AI 生成时间戳 — 展示「AI 生成 · N 分钟前」,source 区分模板兜底。
*/
export function AIStamp({ export function AIStamp({
label = 'AI 生成', label = 'AI 生成',
relative, relative,
...@@ -120,6 +129,9 @@ export function AIStamp({ ...@@ -120,6 +129,9 @@ export function AIStamp({
// ────────────────────────────────────────── // ──────────────────────────────────────────
// Sidebar Card wrapper (可折叠) // Sidebar Card wrapper (可折叠)
// ────────────────────────────────────────── // ──────────────────────────────────────────
/**
* 侧栏可折叠卡片 — title + meta + action 插槽,工作台左栏信息卡的统一容器。
*/
export function SidebarCard({ export function SidebarCard({
title, title,
meta, meta,
...@@ -181,6 +193,9 @@ function inlineMD(s: string): string { ...@@ -181,6 +193,9 @@ function inlineMD(s: string): string {
.replace(/\*\*([^*]+)\*\*/g, '<strong class="font-semibold text-slate-900">$1</strong>'); .replace(/\*\*([^*]+)\*\*/g, '<strong class="font-semibold text-slate-900">$1</strong>');
} }
/**
* 轻量 markdown 渲染 — 支持粗体/列表/段落,话术与摘要文本展示用。
*/
export function MD({ text, className }: { text: string; className?: string }) { export function MD({ text, className }: { text: string; className?: string }) {
const lines = text.split('\n'); const lines = text.split('\n');
const out: ReactNode[] = []; const out: ReactNode[] = [];
......
...@@ -7,6 +7,10 @@ import { buttonVariants } from './button'; ...@@ -7,6 +7,10 @@ import { buttonVariants } from './button';
// 标准 shadcn-new-york alert-dialog 实现(根据 components.json 风格本地实现,无 CLI 注入) // 标准 shadcn-new-york alert-dialog 实现(根据 components.json 风格本地实现,无 CLI 注入)
/**
* 确认对话框(危险操作二次确认)— AlertDialog + AlertDialogTrigger/AlertDialogContent/AlertDialogAction/AlertDialogCancel 组合。
* @category overlay
*/
const AlertDialog = AlertDialogPrimitive.Root; const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger; const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal; const AlertDialogPortal = AlertDialogPrimitive.Portal;
......
...@@ -21,6 +21,10 @@ export interface BadgeProps ...@@ -21,6 +21,10 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
/**
* 状态徽标 — variant: default/secondary/destructive/outline。
* @category display
*/
export function Badge({ className, variant, ...props }: BadgeProps) { export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />; return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
} }
...@@ -36,6 +36,10 @@ export interface ButtonProps ...@@ -36,6 +36,10 @@ export interface ButtonProps
asChild?: boolean; asChild?: boolean;
} }
/**
* 主操作按钮 — variant: default/secondary/outline/ghost/destructive/link,size: sm/default/lg/icon;teal 主色。
* @category actions
*/
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'; const Comp = asChild ? Slot : 'button';
......
...@@ -21,6 +21,10 @@ const zhFormatters = { ...@@ -21,6 +21,10 @@ const zhFormatters = {
/// shadcn 官方 Calendar(react-day-picker v10)—— 全 Tailwind classNames,不引 rdp 默认样式表。 /// shadcn 官方 Calendar(react-day-picker v10)—— 全 Tailwind classNames,不引 rdp 默认样式表。
/// 主题走项目 CSS 变量(--primary=teal-600 / --accent / --ring),故选中/今天/焦点自动是 teal。 /// 主题走项目 CSS 变量(--primary=teal-600 / --accent / --ring),故选中/今天/焦点自动是 teal。
/// 支持 captionLayout="dropdown"(月/年下拉)与默认 label(左右翻页箭头)两种 caption。 /// 支持 captionLayout="dropdown"(月/年下拉)与默认 label(左右翻页箭头)两种 caption。
/**
* 日历(react-day-picker 封装)— 单选/范围由 mode 决定。
* @category forms
*/
export function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { export function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return ( return (
<DayPicker <DayPicker
......
...@@ -6,6 +6,10 @@ import { cn } from '@/lib/utils'; ...@@ -6,6 +6,10 @@ import { cn } from '@/lib/utils';
// 阴影只给"浮起"层(popover / dialog),不给静态卡片。 // 阴影只给"浮起"层(popover / dialog),不给静态卡片。
// padding 缩到 p-5(design-system desktop 默认),CardContent 顶 padding 缩到 pt-0 之前的 pb 维持。 // padding 缩到 p-5(design-system desktop 默认),CardContent 顶 padding 缩到 pt-0 之前的 pb 维持。
/**
* 内容卡片容器 — 与 CardHeader/CardTitle/CardDescription/CardContent/CardFooter 组合使用。
* @category display
*/
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div <div
......
...@@ -8,6 +8,10 @@ import { Popover, PopoverTrigger, PopoverContent } from './popover'; ...@@ -8,6 +8,10 @@ import { Popover, PopoverTrigger, PopoverContent } from './popover';
/// 纯日期选择器(shadcn「Date Picker」模式:outline Button + ChevronDown 触发 → Popover + Calendar)。 /// 纯日期选择器(shadcn「Date Picker」模式:outline Button + ChevronDown 触发 → Popover + Calendar)。
/// 时间另用 <input type="time">,不在此组件内(对齐 shadcn Date Picker with Time 示例)。 /// 时间另用 <input type="time">,不在此组件内(对齐 shadcn Date Picker with Time 示例)。
/**
* 日期选择器 — Popover + Calendar 组合,受控 value/onChange。
* @category forms
*/
export function DatePicker({ export function DatePicker({
value, value,
onChange, onChange,
......
...@@ -7,6 +7,10 @@ import { cn } from '@/lib/utils'; ...@@ -7,6 +7,10 @@ import { cn } from '@/lib/utils';
// 标准 shadcn-new-york dialog 实现 // 标准 shadcn-new-york dialog 实现
/**
* 模态对话框 — Dialog + DialogTrigger/DialogContent/DialogHeader/DialogTitle/DialogDescription/DialogFooter 组合。
* @category overlay
*/
const Dialog = DialogPrimitive.Root; const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger; const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal; const DialogPortal = DialogPrimitive.Portal;
......
...@@ -5,6 +5,10 @@ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; ...@@ -5,6 +5,10 @@ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react'; import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/**
* 下拉菜单 — DropdownMenu + DropdownMenuTrigger/DropdownMenuContent/DropdownMenuItem 组合。
* @category actions
*/
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group; const DropdownMenuGroup = DropdownMenuPrimitive.Group;
......
...@@ -5,6 +5,10 @@ import * as HoverCardPrimitive from '@radix-ui/react-hover-card'; ...@@ -5,6 +5,10 @@ import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/**
* 悬停信息卡 — HoverCard + HoverCardTrigger/HoverCardContent 组合。
* @category overlay
*/
const HoverCard = HoverCardPrimitive.Root; const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger; const HoverCardTrigger = HoverCardPrimitive.Trigger;
......
...@@ -22,6 +22,10 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> ...@@ -22,6 +22,10 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
compact?: boolean; compact?: boolean;
} }
/**
* 文本输入框 — 支持所有原生 input 属性。
* @category forms
*/
export const Input = React.forwardRef<HTMLInputElement, InputProps>( export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, compact, ...props }, ref) => { ({ className, type, compact, ...props }, ref) => {
return ( return (
......
...@@ -4,6 +4,10 @@ import * as React from 'react'; ...@@ -4,6 +4,10 @@ import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover'; import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/**
* 浮层 — Popover + PopoverTrigger/PopoverContent 组合。
* @category overlay
*/
const Popover = PopoverPrimitive.Root; const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverTrigger = PopoverPrimitive.Trigger;
......
...@@ -5,6 +5,10 @@ import * as SelectPrimitive from '@radix-ui/react-select'; ...@@ -5,6 +5,10 @@ import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown } from 'lucide-react'; import { Check, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/**
* 下拉选择 — Select + SelectTrigger/SelectValue/SelectContent/SelectItem 组合。
* @category forms
*/
const Select = SelectPrimitive.Root; const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group; const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value; const SelectValue = SelectPrimitive.Value;
......
...@@ -5,6 +5,10 @@ import * as TabsPrimitive from '@radix-ui/react-tabs'; ...@@ -5,6 +5,10 @@ import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/**
* 选项卡 — Tabs + TabsList/TabsTrigger/TabsContent 组合。
* @category display
*/
const Tabs = TabsPrimitive.Root; const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef< const TabsList = React.forwardRef<
......
...@@ -2,6 +2,10 @@ ...@@ -2,6 +2,10 @@
import { Toaster as SonnerToaster } from 'sonner'; import { Toaster as SonnerToaster } from 'sonner';
/**
* 全局 toast 容器(sonner)— 页面挂一次,用 toast()/toast.success() 触发。
* @category overlay
*/
export function Toaster() { export function Toaster() {
return ( return (
<SonnerToaster <SonnerToaster
......
/**
* PAC Design System 入口 — design-sync 打包专用(window.PAC.*)。
* 显式收录:ui/ 基础件全集 + 工作台原语(plan-detail/shared)。
* 新组件要进设计系统 → 在此 re-export,并在 .design-sync/config.json 的
* componentSrcMap 里登记(卡片清单以 componentSrcMap 为准)。
*/
export * from '../components/ui/alert-dialog';
export * from '../components/ui/badge';
export * from '../components/ui/button';
export * from '../components/ui/calendar';
export * from '../components/ui/card';
export * from '../components/ui/date-picker';
export * from '../components/ui/dialog';
export * from '../components/ui/dropdown-menu';
export * from '../components/ui/hover-card';
export * from '../components/ui/input';
export * from '../components/ui/popover';
export * from '../components/ui/select';
export * from '../components/ui/tabs';
export * from '../components/ui/toaster';
// 工作台原语(shared.tsx 里还有内部工具,不整卷导出)
export { Chip, PriorityBar, AIStamp, SidebarCard, MD } from '../components/plan-detail/shared';
// toast 触发器(配合 Toaster 容器)
export { toast } from 'sonner';
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