Commit 1692fce9 by luoqi

docs: PAC 文档站(Fumadocs)首次入库 + 全面重写;perf: 连接池随并发自动放大

文档(apps/pac-docs,首次纳入仓库):
- 接入/运维/参考全面重写,脱敏、对齐现有代码
- 运维拆为 部署/数据摄入/监控/故障排查;新增「设计系统·DESIGN.md」(三层 token + oklch)
- 过时文档归档 → docs/_archive;ADR → docs/adr

代码:
- perf(prisma): PrismaService 连接池按 PAC_COHORT_CONCURRENCY 自动推导 connection_limit(免手调)
- chore(prisma): schema 注释更正(payloadHash 不参与 source_event_id 合成)
- chore: docker-compose 增 pac-docs 服务
- 含 feat/tenant-group-p0 分支在途 WIP(pac-service / packages/types)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 64e0325e
Pipeline #3327 failed in 0 seconds
# PAC CI
#
# 目前只有一个 job:OpenAPI 文档「漂移闸」。
# 文档站的 /docs/api 由 apps/pac-docs/openapi/pac.json 渲染,而该 spec 从
# pac-service 的代码(nestjs-zod DTO)生成。本闸确保两者同步:
# 改了 API 但忘了重新导出 spec → 文档与真实接口对不上 → CI fail。
#
# 本地修复: pnpm docs:openapi 然后提交 apps/pac-docs/openapi/pac.json
stages:
- docs
openapi-drift:
stage: docs
image: node:22
rules:
# MR 里只在改了 service / types / spec 时跑(省 CI)
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- apps/pac-service/**/*
- packages/types/**/*
- apps/pac-docs/openapi/pac.json
# 默认分支推送总是跑(兜底)
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store
before_script:
- corepack enable
- corepack prepare pnpm@10.13.1 --activate
- pnpm config set store-dir .pnpm-store
- pnpm install --frozen-lockfile
# preview 模式不连 DB,但构建 AppModule 需要 prisma client + 一份 env
- pnpm --filter @pac/service prisma:generate
- cp apps/pac-service/.env.example apps/pac-service/.env
script:
- pnpm docs:openapi
- |
if ! git diff --exit-code apps/pac-docs/openapi/pac.json; then
echo ""
echo "✖ OpenAPI spec 与代码不同步。"
echo " 你改了 API(controller / DTO),但没重新导出 spec。"
echo " 本地执行: pnpm docs:openapi"
echo " 然后提交: apps/pac-docs/openapi/pac.json"
exit 1
fi
- echo "✓ OpenAPI spec 与代码一致"
node_modules
.next
.source
.turbo
coverage
.env
.env.local
*.log
.git
# deps
/node_modules
# generated content
.source
# test & build
/coverage
/.next/
/out/
/build
*.tsbuildinfo
# misc
.DS_Store
*.pem
/.pnp
.pnp.js
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# others
.env*.local
.vercel
next-env.d.ts
\ No newline at end of file
# syntax=docker/dockerfile:1.7
# PAC Docs Dockerfile — Next.js (Fumadocs) standalone build
ARG NODE_VERSION=22-alpine
FROM node:${NODE_VERSION} AS base
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@10.13.1 --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json .npmrc* ./
COPY turbo.json tsconfig.base.json ./
COPY apps/pac-docs/package.json apps/pac-docs/
COPY packages/types/package.json packages/types/
COPY packages/utils/package.json packages/utils/
# --ignore-scripts:跳过 pac-docs 的 `postinstall: fumadocs-mdx`(此刻 content/source.config.ts 还没 COPY 进来会失败);
# .source 由 build 阶段的 `fumadocs-mdx && next build` 生成。
RUN pnpm install --frozen-lockfile --ignore-scripts
FROM deps AS dev
COPY . .
WORKDIR /app/apps/pac-docs
EXPOSE 3102
CMD ["pnpm", "dev"]
FROM deps AS build
COPY . .
WORKDIR /app/apps/pac-docs
# build 脚本 = `fumadocs-mdx && next build`,确保 content 拷进来后重新生成 .source 再构建
RUN pnpm build
FROM node:${NODE_VERSION} AS prod
RUN apk add --no-cache libc6-compat
WORKDIR /app
ENV NODE_ENV=production
# 纯静态文档站,无 NEXT_PUBLIC_* / 无 public 目录;openapi 与 MDX 内容均已在 build 期烘进产物
COPY --from=build /app/apps/pac-docs/.next/standalone ./
COPY --from=build /app/apps/pac-docs/.next/static ./apps/pac-docs/.next/static
EXPOSE 3102
CMD ["node", "apps/pac-docs/server.js"]
# @pac/docs
PAC 的对外文档站,基于 [Fumadocs](https://fumadocs.dev)(Next.js App Router)。
## 开发
```bash
pnpm --filter @pac/docs dev # http://localhost:3200
```
## 结构
| 路径 | 说明 |
|---|---|
| `content/docs/**` | 手写文档源(MDX)。`meta.json` 控制侧栏顺序/分组。 |
| `content/docs/decisions/` | ADR(架构决策记录),只追加。 |
| `openapi/pac.json` | **生成物**:PAC 的 OpenAPI spec(勿手改,见下)。 |
| `lib/openapi.ts` | fumadocs-openapi server(读 `openapi/pac.json`)。 |
| `lib/source.ts` | 多源 loader:手写 docs + 由 spec 派生的虚拟 API 页(`/docs/api`)。 |
| `lib/shared.ts` | 站点名、路由、GitLab 源链接配置。 |
| `source.config.ts` | 内容集合 + frontmatter schema。 |
## API 参考(自动同步,零漂移)
`/docs/api/**` 下的端点页**不是手写的** —— 由 NestJS 的 OpenAPI spec 渲染。
spec 来自 pac-service 的代码(nestjs-zod DTO),「文档 = 代码」:
```bash
# API 改了之后,重新导出 spec(preview 模式,不连 DB):
pnpm docs:openapi # 根目录;= pnpm --filter @pac/service openapi:dump
```
它会刷新 `apps/pac-docs/openapi/pac.json`,文档站下次 build/dev 自动重渲。
建议把这步纳入 CI:导出后 `git diff --exit-code` 该文件,有差异即提示「spec 未同步」。
## 写文档
- 新页面:在 `content/docs/` 下加 `.mdx`,frontmatter 写 `title` / `description`,
在对应 `meta.json``pages` 里登记顺序。
- 新 ADR:`content/docs/decisions/NNNN-<slug>.mdx`,沿用四段结构(背景/决策/后果/状态)。
- 维护原则见文档首页(`content/docs/index.mdx`)的「文档怎么维护」。
## 校验
```bash
pnpm --filter @pac/docs type-check # fumadocs-mdx + next typegen + tsc
pnpm --filter @pac/docs build # 生产构建
```
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
});
import { getPageImage, getPageMarkdownUrl, source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
MarkdownCopyButton,
ViewOptionsPopover,
} from 'fumadocs-ui/layouts/docs/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/components/mdx';
import { OpenAPIPage } from '@/components/api-page';
import type { Metadata } from 'next';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { editUrl } from '@/lib/shared';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
// 由 OpenAPI spec 派生的虚拟参考页 —— 用专用渲染器(参数/请求/响应/试调)。
if (page.type === 'openapi') {
return (
<DocsPage toc={page.data.toc} full>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<DocsBody>
<OpenAPIPage {...page.data.getOpenAPIPageProps()} />
</DocsBody>
</DocsPage>
);
}
const MDX = page.data.body;
const markdownUrl = getPageMarkdownUrl(page).url;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<div className="flex flex-row gap-2 items-center border-b pb-6">
<MarkdownCopyButton markdownUrl={markdownUrl} />
<ViewOptionsPopover
markdownUrl={markdownUrl}
githubUrl={editUrl(page.path)}
/>
</div>
<DocsBody>
<MDX
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
openGraph: {
images: getPageImage(page).url,
},
};
}
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: LayoutProps<'/docs'>) {
return (
<DocsLayout tree={source.getPageTree()} {...baseOptions()}>
{children}
</DocsLayout>
);
}
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
/* 系统字体栈(不依赖 Google Fonts):中英文都走系统自带,离线可用 */
:root {
--font-sans:
system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial,
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-mono:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
'Liberation Mono', monospace;
}
body {
font-family: var(--font-sans);
}
html {
scrollbar-gutter: stable;
}
html > body[data-scroll-locked] {
margin-right: 0px !important;
--removed-body-scroll-bar-size: 0px !important;
}
import { RootProvider } from 'fumadocs-ui/provider/next';
import './global.css';
import type { Metadata } from 'next';
// 不用 next/font/google(Google Fonts 在内网 / 国内不可达,会卡编译)。
// 走系统字体栈(见 global.css 的 --font-sans),离线可用,中英文都正常。
// 用于解析 OG / Twitter 图片的绝对地址;部署时设 NEXT_PUBLIC_DOCS_URL。
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_DOCS_URL ?? 'http://localhost:3102'),
};
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}
import { getLLMText, source } from '@/lib/source';
export const revalidate = false;
export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join('\n\n'));
}
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
const { slug } = await params;
const page = source.getPage(slug?.slice(0, -1));
if (!page) notFound();
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
lang: page.locale,
slug: getPageMarkdownUrl(page).segments,
}));
}
import { source } from '@/lib/source';
import { llms } from 'fumadocs-core/source';
export const revalidate = false;
export function GET() {
return new Response(llms(source).index());
}
import { getPageImage, source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { generate as DefaultImage } from 'fumadocs-ui/og';
import { appName } from '@/lib/shared';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return new ImageResponse(
<DefaultImage title={page.data.title} description={page.data.description} site={appName} />,
{
width: 1200,
height: 630,
},
);
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
lang: page.locale,
slug: getPageImage(page).segments,
}));
}
'use client';
import { createOpenAPIPage } from 'fumadocs-openapi/ui';
// 渲染单个 API endpoint(参数 / 请求体 / 响应 schema / 示例 / 试调)。
// 接收 source 侧 `getOpenAPIPageProps()` 给的 payload。
export const OpenAPIPage = createOpenAPIPage();
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { Mermaid } from '@/components/mermaid';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
Mermaid,
...components,
} satisfies MDXComponents;
}
export const useMDXComponents = getMDXComponents;
declare global {
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
}
'use client';
import { Suspense, use, useEffect, useId, useState } from 'react';
import { useTheme } from 'next-themes';
// 渲染 ```mermaid 代码块为图(由 source.config.ts 的 remark 插件转成 <Mermaid chart=.../>)。
// 动态 import mermaid(~1MB)避免进主包;随主题深浅切换重渲。
export function Mermaid({ chart }: { chart: string }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return (
<Suspense fallback={<div className="text-fd-muted-foreground text-sm">渲染图中…</div>}>
<MermaidContent chart={chart} />
</Suspense>
);
}
const cache = new Map<string, Promise<unknown>>();
function cachePromise<T>(key: string, setPromise: () => Promise<T>): Promise<T> {
const cached = cache.get(key);
if (cached) return cached as Promise<T>;
const promise = setPromise();
cache.set(key, promise);
return promise;
}
function MermaidContent({ chart }: { chart: string }) {
const id = useId();
const { resolvedTheme } = useTheme();
const { default: mermaid } = use(cachePromise('mermaid', () => import('mermaid')));
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
fontFamily: 'inherit',
themeCSS: 'margin: 1.5rem auto 0;',
theme: resolvedTheme === 'dark' ? 'dark' : 'default',
});
const { svg, bindFunctions } = use(
cachePromise(`${chart}-${resolvedTheme}`, () => {
return mermaid.render(id.replaceAll(':', ''), chart.replaceAll('\\n', '\n'));
}),
);
return (
<div
ref={(container) => {
if (container) bindFunctions?.(container);
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
{
"title": "算法",
"icon": "Brain",
"pages": [
"overview",
"recall",
"persona",
"script-generation",
"verification"
]
}
---
title: 算法 · 通俗版
description: 用大白话讲清楚 PAC 的召回 / 画像 / 优先级 / 话术四个算法在算什么、为什么这么算。
icon: Sparkles
---
> **谁该读**:产品 / 运营 / 诊所管理者 / 客服主管 —— 不需要懂技术。
> **读法**:正文是大白话;每个关键论断下带一个 `📐 证据` 块(简短公式 / 取值 / 真理源),专业读者可核对,非技术读者可跳过。完整实现见 [召回](/docs/algorithms/recall) · [画像](/docs/algorithms/persona) · [话术](/docs/algorithms/script-generation)。
## 〇、PAC 是干什么的
一句话:**PAC 帮诊所找出"该回来但还没回来"的患者,告诉客服先打给谁、怎么打。**
诊所每天产生大量就诊记录,里面藏着"漏掉的机会":医生说过"这颗牙该补",患者却一直没来补。PAC 把算法连成一条流水线,客服拿到的是排好序、带背景、带话术的电话清单:
```
全院患者 客服每天的电话
│ ▲
│ ① 召回 ② 画像 ③ 优先级 ④ 话术 │
└「谁该回来?」→「他是谁?」→「先打给谁?」→「怎么打?」─┘
(筛候选) (贴标签) (排顺序) (定制脚本)
```
> ⚙️ **关于数值**:本文列的**权重 / 分档 / 阈值都是"出厂默认",可按宿主或集团策略调**。**算法的"形状"(公式 / 流程)稳定,"数值"可调**;真理源永远在代码 / 配置,改参后同步更新本文。
---
## 一、召回算法 —— "谁该被请回来?"
**核心判断只有一句**:
> 医生发现 / 建议了某个治疗,但患者一直没去做 —— 这个人就该被召回。
> 📐 **触发条件**(两个信号源,任一命中):有一条 active 的「诊断记录」或「医生建议」(`diagnosis_record` / `recommendation_record`),其 `code` ∈ 本子场景的码集,且**不存在**诊断之后启动的同类治疗(见第 3 道过滤网)。诊断 `confidence=1.0`;医生建议 `0.8`;影像 AI `0.5`(影响优先级)。
### 覆盖哪些情况(11 个子场景)
按牙科疾病大类(ICD-10 K00–K09)覆盖 **10 种"发现了没去做"的病** + 1 个跨病种的**「建议拔除」**。每种病有**诊断码**(如 `K08`)或**建议码**(如 `IMPLANT_RECOMMENDED`)两个触发源,任一命中即召。
| 诊断码 | 建议码 | 情况(大白话) | 起步分 | 冷静期 | 黄金窗末 | 紧迫临界 |
|---|---|---|---|---|---|---|
| K08 | `IMPLANT_RECOMMENDED` | 缺失牙未修复(没去种 / 镶) | **60** | 30 | 180 | 120 |
| K07 | `ORTHO_CONSULT_RECOMMENDED` | 错颌畸形未正畸 | 55 | 30 | 365 | 180 |
| K04 | `RCT_RECOMMENDED` | 牙髓炎未做根管 | 52 | 14 | 60 | 45 |
| K05 | `SRP_RECOMMENDED` | 牙周炎未做基础治疗 | 50 | 30 | 120 | 90 |
| K09 | `JAW_CYST_REMOVAL_RECOMMENDED` | 颌骨囊肿未处理 | 50 | 14 | 90 | 60 |
| K02 | `FILLING_RECOMMENDED` | 龋齿未充填(蛀牙没补) | 45 | 14 | 90 | 60 |
| K03 | `HARD_TISSUE_REPAIR_RECOMMENDED` / `CROWN_RECOMMENDED` | 牙体损伤未修复 | 35 | 14 | 90 | 60 |
| K06 | `GUM_TREATMENT_RECOMMENDED` | 牙龈牙槽问题未处置 | 35 | 14 | 120 | 60 |
| K01 | (只认诊断,无建议码) | 阻生牙未拔除 | 30 | 14 | 180 | 90 |
| K00 | `ERUPTION_INTERVENTION_RECOMMENDED` | 牙发育 / 萌出异常未处置 | 25 | 30 | 365 | 180 |
| (跨病种) | `EXTRACTION_RECOMMENDED` | **建议拔除未拔**(残根 / 松动牙等;同牙已有病种诊断则让位) | 30 | 14 | 90 | 60 |
> 📐 **真理源**:起步分在 `SUB_SCENARIOS[*].base`;冷静期 / 黄金窗 / 紧迫临界在 `DiagnosisTreatmentMap`(`canonical-codes.ts`)。上表是**默认值,可按宿主 / 集团调**。**不漏码**:host 出现的码都进池,起步分低的靠综合分自然沉底。
### 三道"过滤网"(避免打错电话)
**第 1 道 · 合规红线**(一票否决):已故 / 标了"勿打扰" → 不打。没电话的提示客服补(数据问题 ≠ 不让打)。
> 📐 `p.active=true AND pp.do_not_contact=false AND pp.deceased=false`(任一不满足直接踢)。
**第 2 道 · 时间窗**(不能太早,但不嫌晚):刚诊断完(冷静期内)不打,患者还在考虑;**越晚越该打**,缺牙拖一年比拖三个月更该召。
> 📐 只设下界 `诊断日 <= now − cooldownDays`,**不设上界**(超窗不踢,交优先级的新鲜度因子软衰减)。
**第 3 道 · 已经处理过就别打**(关键,精确到牙位):
- 已做对应治疗 / 已拔该牙 → 不召;**精确到牙位**(36 补了不挡 46 还没补)
- 已约未来的号 / 近期(14 天内)刚到过诊 → 不打扰(人在来的路上 / 刚见过面)
> 📐 `NOT EXISTS(诊断之后启动的同类 actual 治疗)`,按牙位集合交集判定(双方有牙位→牙位 overlap;信号全口→按类别;治疗全口→覆盖该类全牙位)。**逐牙算"剩余未治牙位"**,还含固定桥盲点 / 间隙关闭 / 患者拒绝 / 最新诊断为准等细化(见 [召回算法](/docs/algorithms/recall))。
### 一个聪明的合并
同患者多颗牙多个问题会**智能合并**:同病种、相邻 / 重叠牙位 → 合成一条(如"18;28;38;48 四颗智齿"一件事);不同问题分开列。客服看到的是整理好的几件事。
> 📐 按牙位重叠做并查集(union-find),每组一条理由,标识 = `病种@牙位`(`caries_no_filling@36` / `impacted_tooth@18;28;38;48` / `perio_no_srp@whole`);组内取最早诊断日。
> **本质**:召回不"猜",只认医生白纸黑字写下的诊断和建议。
---
## 二、画像算法 —— "这个患者是谁?"
筛出候选后,客服打电话前想知道:这人值不值得花时间?会不会不想来了?能不能打?画像给每个患者贴**16 个可解释标签**。
> 📌 16 标签:`rfm`(价值分群)/ `lifecycle_stage`(生命周期)/ `age_bracket` / `gender` / `acquisition_channel`(获客)/ `family_structure` / `referral_champion`(转介绍达人)/ `entitlement_status`(权益)/ `treatment_history`(治疗史)/ `potential_treatment`(潜在治疗)/ `urgency_level`(急迫等级)/ `contraindication`(禁忌)/ `time_preference`(时间偏好)/ `discount_anchor`(折扣锚点)/ `special_attention`(特别关注)/ `treatment_sensitivity`(治疗敏感)。完整见 [画像算法](/docs/algorithms/persona)。
几个客服最常看的(通俗示例):
- **价值分群(RFM)**:按最近就诊(R)+ 频次(F)+ 累计消费(M)分 8 档(重要价值 / 重要保持 / … / 低活跃)。M 按**租户内分位**,不是绝对金额。喂优先级的"意愿度"。
- **潜在治疗**:这人有哪些"诊断了还没做"的项目(种植 / 正畸 / 补牙…)—— **和召回挖的是同一个 gap**,口径一致。
- **急迫等级**:这病多快要处理(紧急 / 高 / 中 / 低),由潜在治疗 × 末诊推出,喂优先级的"急迫性"。
- **特别关注**:屡次爽约 / 经常迟到 / 免打扰 / 不可等候 —— 提醒客服沟通方式。
> 📐 **两个特点**:① **全确定性,不靠 AI 猜**(16 个里 15 个规则 + RFM 是统计分位),每个标签算法写死、带证据可追溯;② **自动更新**:患者有新动作就自动重算。真理源 `persona-feature-specs.ts`。
> **本质**:画像把散乱就诊记录,浓缩成客服一眼读懂的几个标签。
---
## 三、召回优先级 —— "先打给谁?"
客服时间有限。同样"该召回",给每个人打一个 **0–100 分**,分越高越优先。
### 分数怎么来(三维 × 两因子)
```
综合(0-10) = 急迫性 × 0.4 + 价值性 × 0.3 + 意愿度 × 0.3
最终分(0-100) = clamp( 综合 × 新鲜度 × 置信度 × 10 , 0, 100 )
```
| 维度 | 通俗解释 | 怎么算(默认值可配) |
|---|---|---|
| **急迫性** ×0.4 | 这病多快要处理(纯客观) | 急迫等级:紧急 10 / 高 7 / 中 4 / 低 1 |
| **价值性** ×0.3 | 这单成交值多少钱(纯客观,按**治疗单价**不是患者身价) | 种植 单颗 8 / 多颗 9 / 半口+ 10 · 正畸 7 · 根管 6 · 牙体 5 · 牙周 5 · 拔 3 · 补 2 |
| **意愿度** ×0.3 | 客户接受这治疗的依从性 | RFM 依从 ×0.375 + 主诉行为 ×0.375 + 信任基础 ×0.25 |
**意愿度三件套**:RFM 分群(重要价值 10 … 低活跃 2)+ 主诉行为(咨询过该治疗 8 / 仅被动诊断 2)+ 信任基础(生命周期 成熟 9…流失 0,再 + 同类治疗史 / 转介达人 / 近 1 年履约良好 各 +1,封顶 10)。
**两个打折因子**(乘在最后):
- **新鲜度**(0.4–1.0):黄金窗内 1.0,过窗线性衰减,到 2× 窗触底 0.4(老诊断压低,止损)。
- **置信度**(0.5–1.0):医生诊断 **×1.0** / 医生建议 **×0.8** / 影像 AI **×0.5**(防影像 AI 误判冲高分)。
> 📐 **真理源** `priority-scorer.ts`。每个分数附带完整 `breakdown`(三维 + 各子项 + 新鲜度 / 置信度)落 `plan_reasons.breakdown`,客服详情页逐项可见 —— **可解释 = 可信任**。
> 例:缺失 4 颗牙(潜在多颗种植)+ 咨询过种植 + RFM 重要价值 + 成熟客有种植史 + 黄金窗内 → (急迫 10×.4 + 价值 9×.3 + 意愿 9×.3) × 1.0 × 1.0 × 10 = **85 分**。
> ⚠️ **已知局限**:意愿度 ≈ 倾向性(propensity),偏好"本来就会来的人";做对"该不该打"需测增益(uplift)= 试点随机对照组。见 [召回验证](/docs/algorithms/verification)。
---
## 四、AI 话术生成 —— "电话该怎么打?"
排好打给谁,客服还需要"开口说什么"。话术按**这个患者的具体情况**生成定制脚本(开场 / 切入 / 异议应对 / 结束 4 件事),分**三档投入**(稳健 / 标准 / 深度),客服可在"重新生成"处选档 + 选模型。
### 不是套模板,是"按患者拼装"
```
按患者特征挑「话术块」:召回病种(K00–K09 各有专属)+ 人群(儿童对家长 / 老人慢)+ 新老客
+ 安全规则 → 拼成给大模型的指令 → 大模型生成话术
档位差别:稳健 = 4 段模板填空;标准 = 4 段自由编排;深度 = 多段 + 自检修订
```
举例:**70 岁老人 + 缺牙**召回 → 自动拼"种植话术 + 老人沟通 + 老客"块,出来的话术语速提示慢、主动提家属商量、不催。
### 三道安全护栏(不让 AI 乱说)
| 护栏 | 防什么 |
|---|---|
| **只认提纲事实** | 只能用系统给的诊断 / 医生 / 时间,**不许编**;只喂结构化切片,不喂病历全文 |
| **禁词 + 不承诺** | 不许"保证治好" / 报价 / 写死时间(诊所没排班接口,时间只能给"示例") |
| **失败降级** | 大模型出错或违规 → 退回安全模板,客服永远有东西可用 |
> 📐 **真理源**:提示词组装 `skill-composer.ts` + `skills/*/SKILL.md`(按 frontmatter 声明式匹配);安全规则**单一源** `safety-rules.ts`(机器闸 + prompt 同引,输出后扫禁词 / 加粗时间 / 临床码,命中即兜底);模型默认 `deepseek-v4-flash`;每次落 `agent_invocations`(档位 / 模型 / token / cost / 用了哪些块)。话术块是 MD 文件,**运营可改文案不动代码**。
> **本质**:话术不是"一个大模板",而是按患者特征拼装 + 大模型润色 + 安全兜底。
---
## 五、四者怎么配合(端到端)
以一个高价值老客走一遍:
```
① 召回:扫全院 → 发现他 128 天前诊断"46 缺失",至今没种、没拔、没约
→ 过合规红线 → 进候选池,理由="缺失牙未启动修复 · 牙位 46"
② 画像:贴标签 —— RFM=重要价值客户 / 生命周期=成熟活跃 / 可联系 / 潜在治疗含"种植"
→ 客服打电话前就知道"高价值活跃客户,值得好好聊"
③ 优先级:三维 × 新鲜度 × 置信度 → 高分排进今天清单最前,且看得到"为什么这个分"
④ 话术:点"生成" → 按"种植(K08)+ 老客 + 成人"拼装 + 大模型 → 贴合他的开场 / 异议 / 结束
(不编事实、不报价、不写死时间)
```
> **一句话总括**:**召回**筛出"该回来的人",**画像**告诉客服"他是谁",**优先级**排好"先打谁",**话术**定制"怎么打" —— 四个算法串成一条流水线,把散乱就诊数据变成客服每天一份**排好序、带背景、可解释、带脚本**的电话清单。
---
## 附:常见疑问
**Q:会不会乱召回、打扰患者?** A:三道过滤网(合规红线 + 时间冷静期 + 已处理就不打)层层把关;且只认医生写下的诊断,不自己编理由。
**Q:为什么有的患者分高有的分低?** A:三维(急迫 / 价值 / 意愿)加权 × 新鲜度 × 置信度,每个患者的明细(breakdown)详情页都能看到,完全透明。
**Q:画像标签准吗?会不会 AI 算错?** A:16 个标签目前全是确定性算的(规则 + 统计分位,不是 AI 猜),比如"消费分位"都是写死口径,可解释可追溯。
**Q:换一家诊所 / 集团,算法还能用吗?** A:能。算法逻辑跟具体诊所无关,新诊所把数据按格式对接进来即可复用。
**Q:AI 话术会不会瞎编、乱承诺?** A:三道护栏 —— ① 只能用系统给的事实;② 禁词 + 不报价 + 不写死时间;③ 出错 / 违规自动退回安全模板。且按患者真实情况(病种 / 年龄 / 新老客)定制。
---
title: 画像算法
description: 把全历史事实压缩成 16 个可解释画像标签 —— 数据源、时间语义、版本流与刷新策略。
icon: Users
---
> **真理源**:标签定义(标签值 / 数据源 / 算法 / 时间语义)在 `packages/types/src/persona-feature-specs.ts`(`PERSONA_FEATURE_SPECS`,每标签一张"标签卡");extractor 在 `apps/pac-service/src/modules/persona/features/*.feature.ts`,由 `feature.registry.ts` 注册、`persona.service.ts` 遍历。本页是人读总览,逐字段以代码为准。画像在三层里的触发 / 水位 / 版本流见 [三层模型](/docs/architecture/three-layer-model)。
---
## 定位
事实层(`patient_facts`)→ **画像层(`personas` / `persona_features`)** → 召回层(`followup_plans`)。
画像 = **对全历史 fact 的压缩当前态**(facts = raw context,persona = hidden state),回答"现在这个人是什么样"。明细留 fact 层,**画像不存明细**;看历史时点画像 → 读 `personas` 版本流(`version + computedAt + eventWatermark` = point-in-time)。单向流:画像只读事实层、只被计划层读,执行回写不进画像。
## 怎么存
`persona_features` 每行 = 一个标签:
- `key` —— 闭集标签键(`PersonaFeatureKey`)
- `description` —— 自然语言(给 LLM / 详情页)
- `data`(JSONB)—— **结构化值**(机器消费:打分 / 圈人群 / 筛选)= "是什么"
- `evidence` —— 证据(`{ factIds, ruleIds }`)= "为什么";引 fact 即可两跳回溯宿主原文
- `score` —— 列保留但**当前不用**(分值是消费场景的事,消费方从 `data` 自算)
父 `personas`:`version` 版本流 + `eventWatermark`(算到哪个 `event_seq`,判 stale)。
## 16 个标签(全确定性,无 LLM;15 规则 + 1 统计)
| key | 中文 | 数据源 | 时间语义 | data 要点 |
|---|---|---|---|---|
| `rfm` | 价值分群 | payment / recharge / refund / encounter | mixed | 8 分群;M 按**租户内分位** |
| `lifecycle_stage` | 生命周期 | 末诊时间 + 就诊频次 + 消费斜率 | mixed | 7 档 + `daysFromLastVisit` |
| `age_bracket` | 年龄段 | `patient.birthDate` | snapshot | 9 档(婴幼儿…老年) |
| `gender` | 性别 | `patient.gender` | snapshot | 男 / 女 / 未知 |
| `acquisition_channel` | 获客渠道 | `patient_profiles`(副表立柱) | snapshot(static) | channel / sub |
| `family_structure` | 家庭构成 | `PatientRelation` 关系边 | snapshot | 单身 / 两口 / 多口 / 多代 |
| `referral_champion` | 转介绍达人 | `recommend_num` / `amount`(副表) | lifetime | family / social |
| `entitlement_status` | 权益身份 | 结算卡券名 + 充值 | lifetime | `labels[]`(保险 / 私行 / 储值 / 儿牙 / 医保) |
| `treatment_history` | 治疗史 | `treatment_record.category` | lifetime | `labels[]`(种植 / 正畸 / 修复 / 牙周) |
| `potential_treatment` | 潜在治疗 | diagnosis / recommendation gap(共用召回 gap 核心) | snapshot | 8 标签(种植 / 正畸 / 早矫 / 修复 / 牙周 / 根管 / 拔牙 / 补牙) |
| `urgency_level` | 急迫等级 | 潜在治疗 gap × 末诊 | snapshot | 紧急 / 高 / 中 / 低(取最大) |
| `contraindication` | 禁忌标签 | `birthDate`(v1 仅种植年龄 ≤18) | snapshot(`validUntil` 到龄解除) | `labels[]` |
| `time_preference` | 时间偏好 | 近 2 年预约 `planned_for`(北京) | window(2y) | `labels[]` + 分布 |
| `discount_anchor` | 折扣锚点 | payment 应收 / 折扣额 | lifetime | 最低折扣率 + 日期 / 项目 |
| `special_attention` | 特别关注 | 全状态预约 + profile | window(1y) | `labels[]`(屡次爽约 / 经常迟到 / 免打扰 / 不可等候)+ `noShowRate` |
| `treatment_sensitivity` | 治疗敏感 | EMR 既往史关键词 | lifetime | `labels[]`(恐惧 / 晕针 / 晕血 / 密闭恐惧) |
每行落库:`key + description(自包含中文)+ data(结构化 JSON)+ evidence{factIds}`。
> 多标签(允许并列):`treatment_history` / `time_preference` / `entitlement_status` / `special_attention` / `treatment_sensitivity` / `contraindication` / `potential_treatment`。
## 时间语义
每标签在标签卡声明 `timeSemantics`;判别看**值的演化方向**(都在重算时刻评估,这点不是分界):
| 取值 | 含义 | 例 |
|---|---|---|
| `lifetime` | 单调累计,**只增不减** | 治疗史 / 权益 / 折扣锚点 / 治疗敏感 / 转介绍达人 |
| `snapshot` | 可逆当前态,**可增可减** | 年龄段 / 潜在治疗 gap / 禁忌(到龄解除)/ 家庭构成 |
| `window` | 近 N 期滚动窗 | 时间偏好 2y / 特别关注 1y |
| `trend` | 趋势对比(当前只作 mixed 成分) | 生命周期"近 1 年消费 vs 历史年均" |
| `mixed` | 复合 | RFM(R snapshot + F/M lifetime)/ 生命周期(末诊 window + 消费斜率 trend) |
评估基准 = 重算时刻;历史某天的画像查当天未被 supersede 的版本。本地时间概念(星期 / 时段)按北京时区算(PAC 全 UTC 存储,试点硬编北京,多宿主应读 host 时区)。
## 几个非显然口径
- **RFM**:M 按**租户内分位** p20/40/60/80(非绝对 ¥),8 段**决策树**(非几何八象限);RFM(管价值)与 `lifecycle_stage`(管阶段)解耦。
- **折扣锚点**:折扣率 = `1 − Σ折扣 / 应收`;只取原价 ≥¥500 且 ratio>0(滤掉免费洁牙 / 检查促销霸榜);排除保险方付款(非诊所折扣)。
- **时间偏好**:用 `planned_for`(干净营业钟形)按北京时区算星期 / 时段。
- **特别关注**:`no_show` / `cancelled` 预约不是 active fact → 单独全状态加载 `appointmentsAll` 算爽约率 / 迟到。
- **禁忌标签**:v1 仅"种植年龄 ≤18"(无否定 / 量化歧义,带 `validUntil` 满 19 岁自动解除);糖尿病 / 过敏 / 抗凝 / 妊娠等需读既往史 + 否定处理 + 控制状态判断 → **整体留 Layer C,不上规则版**(医疗安全)。
- **治疗敏感**:关键词精炼排假阳(裸"紧张"=颏肌紧张、裸"见血"=血凝块、裸"张口受限"=物理受限,均剔除)。
## 潜在治疗 = 复用召回 gap 核心
`potential_treatment` 标签和召回挖的是**同一个 gap**("诊断了 / 建议了但没做对应治疗")。两者共用 `clinical-gap/potential-treatment-gap.sql` 的 `buildGapCore`(牙位相减 / resolved 判定 / 误召剔除),**口径逐字节一致**(画像圈到的人群 = 召回候选超集)。
区别:**画像是常态属性**(去掉时间冷静门),**召回才讲时机**(cooldown / 排除 / 打分)。**画像不调召回**(避免 Layer 2→3 倒置),只共用中立 gap 模块。8 业务标签按年龄 / name 拆分(种植 K08>18、早矫 / 正畸 K07 按年龄、修复 vs 拔牙 按 K03 name)。`urgency_level` 由潜在治疗 gap × 末诊推出。gap 细节见 [召回算法](/docs/algorithms/recall)。
## 刷新策略(三触发)
```
① 事件驱动(主路径)= 增量 sync 写 facts → 逐人入 BullMQ(jobId=persona_<patientId>,同人去重串行)
→ PersonaService.recompute(triggeredBy='txn:…')→ 成功后级联 plan 重算
② 手动 = recompute-persona CLI(--force 跳水位 —— 算法/特征变更但数据没变时必需)
③ 兜底 = stale-scan 扫 eventWatermark < 最新 event_seq 的画像补算(cron,默认关)
```
## 幂等与版本流
| 机制 | 实现 |
|---|---|
| 水位幂等 | 每版记 `eventWatermark = max(event_seq)`;重算先比水位,无新事件 → **no-op**(落 `recompute_log` status=noop);`force` 破门;例外:无 persona(首算)/ 无 txn(写 baseline)仍跑 |
| 版本流 | `(patientId, version)` 单调递增;新版回填旧版 `supersededAt`;features 随父版整套重建;**当前版 = `supersededAt IS NULL`**(前端 / 筛选 SQL / Plan 引擎统一读这个) |
| 审计 | 历史版本全保留;`persona_recompute_logs` 记每次运行(running → success / partial / failed / noop) |
## 关键纪律
1. **规则可解释**:16 标签全确定性产出,带 evidence(factIds)可两跳回溯宿主原文。
2. **水位幂等**:重复触发零成本 no-op,重算账本可审计。
3. **版本流不可变**:画像不 update,只 append 新版;任意时点画像可还原。
4. **画像 ≠ 召回**:潜在治疗在画像里是常态属性(无时间门),在召回里才有冷静期 / 排除 —— 同一 gap 选择器两种用法。
## 未上线(Layer C / follow-up)
- **Layer C(LLM 抽取)标签**:完整禁忌(糖尿病控制 / 过敏 / 抗凝 / 妊娠)、沟通偏好、治疗意向、家庭社交关系 —— `data` 已留多标签位,Layer C 就绪后并入。
- **治疗意向**:咨询主体已摄入(`consultation_record` 含患者意向),`treatment_intent` 特征未做(咨询想做 X 但没做 = 高意愿,与 `potential_treatment` 互补)。
- **急迫等级**:v1 仅"潜在治疗"路径;"已治疗复查超期"路径待召回的复查 scenario 落地后补。
- **圈人群 campaign**:`data` 带 segment + GIN 可查、schema 已 ready(`plan_reasons.campaignId` 预留),功能未实现。
---
title: 召回算法
description: 应治未治召回(treatment_initiation_recall)—— 有诊断/医生建议但未启动对应治疗的患者,如何筛选、合并、打分成召回任务。
icon: Filter
---
> **场景**:**应治未治召回**(`treatment_initiation_recall`)—— 患者有治疗需求信号(诊断 / 医生建议)却没启动对应治疗。**这是当前唯一在引擎注册运行的召回 scenario**。
>
> **真理源**:`apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts`(入池 + 合并)、`priority-scorer.ts`(算分)、`packages/types/src/canonical-codes.ts`(`DiagnosisTreatmentMap` 窗口参数)。fact 怎么来的见 [数据摄入](/docs/architecture/data-ingestion);本页只讲"基于标准化 fact,召回怎么算"。
---
## 业务定义
> 患者**有治疗需求信号但没启动对应治疗** —— 诊断了 K08 缺失牙却没做任何种植/桥/义齿,诊断了 K04 牙髓炎却没做根管,等等。召回的目标是**邀约患者到诊启动治疗**。
算法只读结构化 `fact.content`,认医生白纸黑字写下的诊断与建议,不猜自由文本。
**触发信号(两类,任一命中)**:
| 信号 | fact | 置信度 | 现状 |
|---|---|---|---|
| 结构化诊断 | `diagnosis_record.content.code`(K00–K09)+ `tooth_position` | 1.0 | ✅ 主信号 |
| 医生建议 | `recommendation_record.content.code`(`IMPLANT_RECOMMENDED` 等) | 0.8 | ✅ 当前由规则/关键词路由(transforms `route_by_pattern`)产生;LLM 抽取(Layer C)未上线 |
---
## 子场景(11 个)
每个子场景 = 一个病种(诊断码)+ 对应建议码 +「已做就排除」的治疗 category。`base` 在 scenario 文件,窗口参数(`cooldownDays` / `windowDays` / `urgencyDayThreshold`)在 `DiagnosisTreatmentMap`,按 `primaryCode` 关联。
| sub_key | 病种码 | base | cooldown(入池下界,天) | window(黄金窗,天) | urgent 临界(天) | 排除 category(已做即不召) |
|---|---|---|---|---|---|---|
| `missing_tooth` | K08 | 60 | 30 | 180 | 120 | implant / prosthodontic |
| `ortho_no_consult` | K07 | 55 | 30 | 365 | 180 | orthodontic |
| `endo_no_rct` | K04 | 52 | 14 | 60 | 45 | endodontic |
| `perio_no_srp` | K05 | 50 | 30 | 120 | 90 | periodontic(全口) |
| `jaw_cyst` | K09 | 50 | 14 | 90 | 60 | surgical |
| `caries_no_filling` | K02 | 45 | 14 | 90 | 60 | restorative |
| `hard_tissue_damage` | K03 | 35 | 14 | 90 | 60 | restorative / prosthodontic / surgical |
| `gum_alveolar_lesion` | K06 | 35 | 14 | 120 | 60 | periodontic / surgical |
| `impacted_tooth` | K01 | 30 | 14 | 180 | 90 | surgical |
| `extraction_recommended` | EXTRACTION_RECOMMENDED | 30 | 14 | 90 | 60 | surgical(跨病种,只认建议码) |
| `development_eruption` | K00 | 25 | 30 | 365 | 180 | surgical / prosthodontic / implant / orthodontic |
> **不漏码原则**:host 数据出现的任何病种都进召回池,靠 `base` 分级 + 窗口让低优先的自然衰减排后。`goal` 字段(scenario 配置)直供话术 `plan.goal`。
---
## 入池(每个子场景一段 SQL,只换 `allCodes` / `excludeCats` / `cooldown` 参数)
```
① 隔离 host_id + tenant_id
② 合规硬过滤 patients.active=true AND do_not_contact=false AND deceased=false
③ 触发信号 fact.status='active' AND type ∈ {diagnosis_record, recommendation_record}
AND content->>'code' = ANY(allCodes)
④ 冷静期下界 COALESCE(occurred_at, planned_for) <= now - cooldownDays
⚠️ 只设下界,不设上界 —— 缺口不自愈,拖越久越该召;超窗交 scorer 新鲜度衰减
⑤ 三道排除(NOT EXISTS):
a. 已无缺口 该牙位已「解决」(resolved)—— 牙位级判定见下节
b. 已有未来预约 appointment_record active 且 planned_for > now(已在来的路上,不按科目)
c. 近期已到诊 14 天内有 encounter/emr(刚见过面,别催;⑤d 预约科目排除 W7 已移除)
```
**关键纪律(代码已固化):**
- **触发时间** = `COALESCE(occurred_at, planned_for)` —— 诊断用 `occurred_at`,建议(planned)用 `planned_for`。
- **排除按牙位级 overlap**:诊断 + actual 双方有牙位 → 牙位数组有交集才排;诊断无牙位(K05 全口)→ category 级;actual 全口(全口洁治)→ 视为覆盖该 category 全牙位。
- **时间方向**:只算"诊断之后才启动"的治疗,历史旧治疗(可能另一颗牙)不算已做。
- **排除用 `status IN ('active','fulfilled')`**:actual 完成后状态是 `fulfilled` 非 `active`,漏了会把已治者误召。
---
## 「已启动 / 无需召」的牙位级判定(resolved)
排除不是简单"做过同类治疗就不召",而是**逐牙算"剩余未治牙位"**:`remaining = 信号牙位 − resolved`,`remaining` 非空才召(全口病退化到 patient/category 级)。判定逻辑在 `clinical-gap/potential-treatment-gap.sql`,**召回与「潜在治疗」画像标签共用同一份**,口径逐字节一致(圈到的人群 = 召回候选)。
**两个 category 口径(别混)**:
- `expectedCats`(窄,主治疗)—— 展示"未启动 X" + 主诉意向匹配
- `resolverCats`(宽,治疗家族)—— 判"已解决"。结构码(K02/K03/K08…)= **任何局部结构治疗**都算(充填/根管/冠桥/种植/外科/美学/儿牙);牙周/正畸沿用各自 category
**一颗牙算「已解决」的来源**(任一即从 gap 扣掉):
- 同牙诊断**之后**做了 `resolverCats` 家族的 actual 治疗
- **固定桥盲点**:同牙弓 ≥2 颗 prosthodontic → 基牙跨度区间内全部牙位计入(桥只写基牙牙位、与缺失牙位不重叠的误召修正)
- 检查所见"缺牙但**间隙关闭 / 无修复间隙**" → 无修复指征
- 患者**拒绝**该类治疗(treatment subtype 含拒绝/不愿)
- 同牙位以**最新结构诊断**为准(更晚的诊断 → 旧诊断对该牙失效)
- 诊断 vs 建议冲突,以**医生建议**为准(治疗决定 > 诊断)
**K08 缺失牙的误召剔除**(影像 AI / name 映射常把废用牙误当缺牙):乳牙(FDI 51-85)、智齿位(18/28/38/48)、正畸**减数位**(对称前磨牙 14&24 / 34&44 拔除且处正畸语境)、name 含"先天"(交正畸统筹)、废用牙 / 无功能牙 name —— 一律不召种植 / 修复。
**全口病(K05 牙周 / K07 正畸,`wholeMouth`)**:无牙位 → patient/category 级;且 `excludeIfEverTreated` —— **曾做过该类治疗或明确拒绝即排除**,时间锚点取"最新诊断之后"。
**「建议拔除」让位(`deferToToothDx`)**:`EXTRACTION_RECOMMENDED` 跨病种(残根/阻生/松动牙都可能拔),若同牙已有编码病种诊断(K00–K09)→ 让位给该病种场景,不重复召;松动牙无 code → 由建议拔除场景正常召。
**外院已治疗**:回访记录(`patient_return_visits.result`)命中"已在外院治疗"且晚于诊断 → 患者级排除。
**置信度来源标注**:`code_source` = 医生码(std_code / name_map)1.0 / 影像 AI 0.5 / 医生建议 0.8;影像 AI 诊断在话术里单独标"(影像 AI)"(影像所见 ≠ 医生确诊,更谨慎)。
---
## 合并:同 patient 同子场景 → 按牙位 union-find 聚类
同一患者同一子场景命中多条信号时,**按牙位重叠做 union-find 聚类**(跟治疗进度 bucket 同口径),每个 cluster 产 1 条 `plan_reason`:
```
牙位集合有交集 → 并入同一 cluster;全口诊断(空牙位)→ 全归 'whole' cluster
每个 cluster → 1 个 plan_reason:
sub_key = '<子场景>@<牙位排序拼接 | whole>' (如 caries_no_filling@36 / perio_no_srp@whole)
daysSince = cluster 内最大(最早诊断,最有召回价值)
evidence.factIds = cluster 内全部信号 fact_id(可追溯)
signals.triggers = cluster 内去重 (type, code)
```
`plan_reasons` 唯一键 `(plan, scenario, sub_key)`,所以**同患者同子场景不同牙位组 = 多条 reason**(36 需充填 + 46 需充填 = 2 行,都进库),不会互相吃掉。
---
## 算分(`priority-scorer.ts`,纯 TS)
SQL 出 hit 后,逐患者拉打分上下文(persona 的 rfm / urgency_level / lifecycle / treatment_history / referral / special_attention + consultation 意向),送 `calcPriority()`:
```
base(0-10) = 急迫性 × 0.4 + 价值性 × 0.3 + 意愿度 × 0.3
raw(0-10) = base × 新鲜度 × 置信度
score(0-100) = clamp( round(raw × 10), 0, 100 )
意愿度 = RFM依从 × 0.375 + 主诉行为 × 0.375 + 信任基础 × 0.25
```
| 因子 | 范围 | 来源与口径 |
|---|---|---|
| **急迫性** | 0-10 | persona `urgency_level`:urgent 10 / high 7 / mid 4 / low 1 |
| **价值性** | 0-10 | 按病种基线 + 牙数加成(如 K08 单颗 8 / 多颗 9 / 半口+ 10) |
| **意愿度** | 0-10 | RFM依从(`rfm` 分群,important_value 10 … low_active 2)+ 主诉行为(咨询过该类 8 / 被动诊断 2)+ 信任基础(生命周期 + 治疗史/转介绍达人/近年履约 各 +1,封顶 10) |
| **新鲜度** | 0.4-1.0 | `daysSince ≤ window` → 1.0;超窗线性衰减,到 2×window 触底 0.4(老诊断衰减止损,不踢出池) |
| **置信度** | 0.5-1.0 | `clamp(confidence, 0.5, 1)`:医生诊断 1.0 / 医生建议 0.8 / 影像 AI 0.5 |
每条 reason 把上述因子明细落到 `plan_reasons.breakdown`(`{ urgency, value, willingness, rfmAdherence, intentBehavior, trustBase, freshness, confidenceFactor, base, raw }`),供详情页逐项渲染"为什么这个分"。
> **示例**:K08 缺牙多颗 + 咨询过种植 + RFM 重要价值 + 成熟客有种植史,诊断在黄金窗内 → 急迫 10×0.4 + 价值 9×0.3 + 意愿 9×0.3 = 8.5;× 新鲜度 1.0 × 置信度 1.0 → **85 分**。
> ⚠️ **局限**:意愿度本质是倾向性(propensity),偏好"本来就会来的人";要做对"该不该打"需测增益(uplift),靠试点随机对照组,见 [召回验证](/docs/algorithms/verification)。
---
## 形成召回计划(patient 级)
scenario 跑完各子场景产 hit;同一患者命中多个子场景时,**合并进同一条 `followup_plans`**(patient 级触达单元),各子场景作为 `plan_reasons[]` 行:
- `plan.priorityScore = MAX(各 reason.priorityScore)`(客服列表排序)
- 每条 reason 带 `breakdown` / `signals` / `evidence` / `goal`,详情页 WhyCard 渲染因子拆解
- 一个患者 = 一通电话 = 一张召回卡片
> 重算时对已有 plan 的处置(unchanged / 升版继承分配 / 零命中关闭 / 信号级抑制),见 [三层模型 · 第三层](/docs/architecture/three-layer-model)。
---
## 一句话归纳
> 召回算法只读结构化 `fact.content`,认医生写下的诊断与建议:**合规 + 冷静期 + 已做就不召**三道闸筛出候选 → 按牙位 union-find 合并成召回理由 → 三维(急迫/价值/意愿)× 新鲜度 × 置信度算出可解释优先级 → 同患者多理由并成一张召回卡。正确性验证见 [召回验证](/docs/algorithms/verification)。
---
title: AI 话术生成
description: 客服召回话术的三档(稳健/标准/深度)生成 —— 投入档联动输入/输出/步数,安全接地地板恒定,脊柱固定策略可插。
icon: MessageSquare
---
> **产物**:`plan_scripts`(per-plan 一份 Markdown 话术)。**按需懒生成**:客服打开详情页或点"重新生成"时触发,经 `AiCallRunner` 调 LLM,落 `agent_invocations`。
>
> **真理源**:`apps/pac-service/src/modules/ai/orchestrators/plan-script.orchestrator.ts`(编排)、`calls/draft-plan-script/`(三档 call + shared 构件)。上游消费 `plan_reasons` / `fact.content` / persona,**不读治疗链**。
---
## 目标与硬约束
一通召回电话的脚本给客服四件事:**开场 → 切入(把"该治没治"自然带出)→ 异议(患者推脱怎么接)→ close(约下一步)**。
硬约束(所有档恒定):
- **接地**:只用真事实,不编(医生没说的不说);只喂 canonical 切片,**严禁拼 raw EMR 全文 dump**。
- **不承诺**:不保证疗效、不报价、不写死时间(诊所无排班接口,时间只能给"示例")。
- **贴这个患者**:病种 / 牙位 / 年龄(儿童对家长)/ 新老客 / 语气。
- **失败兜底**:任何环节挂 → 退回安全模板(`source='template_fallback'`),客服永远有东西可用。
## 三档(投入档)
一个"投入档"旋钮联动**输入 / 输出 / 步数**三样;**安全 / 接地地板对所有档恒定**。tier 由调用方传(默认 `stable`),`?model=` 可覆盖模型。
| 档 | 输入 context | 输出 | 步数 | 调用 |
|---|---|---|---|---|
| **stable 稳健**(默认) | base | **固定 4 段**(模板填空)`opening / informMissed / reviewAdvice / closing` + `tone` | 1 | `DraftPlanScriptCall`(单次 AiCall) |
| **standard 标准** | base | **自由 4 段** `sections[{title, markdown}]`(LLM 自定标题,去模板名) | 1 | `StandardScriptCall`(单次 AiCall) |
| **deep 深度** | base + 扩展(画像 rubric + 联系历史) | **多段**(段数模型自定) | 3–4 | `DeepScriptStrategy`(pipeline) |
> **自洽**:深度档拿更多料,正好配它多一步的**自检** —— 料越多越易跑题/编,自检兜底。富输入 ↔ 强校验成对出现。
## 脊柱(固定)+ 策略(可插)
三档共用一根脊柱,只换中间一段策略;加一档 = 加一个 call/strategy,脊柱不动:
```
buildContext(plan_reasons + facts + persona, tier)
→ 策略(stable | standard | deep)
→ 安全机器闸(machineSafetyScan,输出后扫)
→ 失败/不过 → stable 模板兜底(source='template_fallback')
→ 落 plan_scripts(status=ready)+ agent_invocations(tier/model/token/cost/source)
```
**stable**:单次 AiCall 产固定 4 段 —— `opening`(开场)/ `informMissed`(告知应治未治,单项)/ `reviewAdvice`(复查建议,含`【时间段】`占位)/ `closing`(结束语,成功/失败两分支)+ `tone`。失败 → `stableTemplateFallback`(不调 LLM,从 `disease-knowledge` 拼 4 段)。
**standard**:单次 AiCall,输出 `sections[{title, markdown}]`(固定 4 段但 LLM 自定标题/结构)。失败 → stable 模板转 sections。
**deep**:pipeline —— ① **规划**(`DeepPlanCall` → 3–6 段意图 + 关键点)② **撰写**(`DeepWriteCall`,流式逐字)③ **自检**(`DeepVerifyCall` LLM 评 + 独立机器闸 `machineSafetyScan`)④ **修订**(≤1 轮,按 issues 原地改);最终再过机器闸,仍不过 → stable 兜底。
## 输入:聚焦切片 + 薄画像(`ScriptContext`)
**原则:聚焦切片 + 薄画像 > 全历史**。召回只为一件事(`plan_reasons`),喂全历史会跑题/编造/token 爆。
- **召回主体(说什么)**:scenario / sub_key、诊断码 + 病名、牙位(口语化"上门牙/智齿")、拖了多久、目标治疗、触发医生 + 日期、医生医嘱原话(仅引用本次相关)。
- **薄画像(怎么说,只定语气不进内容)**:称呼、年龄→人群、新老客、价值档、流失风险、距上次就诊;深度档另加画像 rubric + 近 5 次回访(避免重复)。
- **去标识(`pii.ts`)**:真实姓名不进 prompt —— 医生只留姓,患者用"年龄+性别+监护人"感知称呼(≤12 岁对家长)。
- **不进**:**治疗链(chain-composer)**(已废弃,"做过什么"用 `treatment_record` 近期治疗代替)、raw EMR 全文 dump、身份证/地址等(召回不需要 + 合规)。
确定性前置(`script-facts.ts`,不调 LLM):日期口语化(X 月 X 号/去年/XXXX 年)、FDI 牙位→患者友好词。
## 安全 / 接地(`safety-rules.ts` 单一源,两道闸)
禁词与规则集中在 `safety-rules.ts`,**机器闸与 prompt 注入同引一份**:
- **禁词**:承诺类(保证 / 绝对 / 100% / 一定能)、淘宝腔(亲爱的)、价格类(便宜 / 促销 / 折扣 / 免费 / 赠送)
- **承诺词**:已为您约好 / 已成功预约(无排班接口,不许预先承诺)
- **正则**:`BOLD_TIME`(禁加粗写死时间 → 用`【时间段】`占位)、`CLINICAL_CODE`(禁 K 码 / 枚举名漏给患者)
两道闸:① **prompt 级** —— `forbiddenWordsBlock()` 注入 system;② **机器闸** —— `machineSafetyScan()` 在输出后扫,命中即 block → 走兜底(深度档自检步骤额外跑一次)。
## 提示词组装(`skill-composer`)
system prompt = `base common`(`shared/skills/_base/common.md`,三档共用:角色定位/语调/接地铁律)+ tier format(`tiers/{tier}/_base/format.md`,各档输出格式不同)+ 禁词块 + 匹配的 skill(人群 adult/child)。按 `applies` 匹配 + `tiers` 过滤 + `priority` 排序拼接;`composeHash` 落账归因。user prompt 由 `fact-block.buildRichFactBlock()` 拼厚事实块。
> **知识 vs 模板分离**:病种/人群的**规则知识**(风险/趁早理由/沟通规则)tier-agnostic 共用;**口语模板**(4 段脚手架 + 口语文案 `phrasing.ts`)仅 stable 独占。标准/深度只吃知识,LLM 自己组织。
## provider 与落账
- **模型**:三档默认 `deepseek-v4-flash`(`?model=` 可覆盖)。
- **落账**:每次经 `AiCallRunner` → `invocation-recorder` 两段式落 `agent_invocations`(tier 嵌在 `promptVersion`、`modelProvider`/`modelName`、token、`costYuan`、source);深度档额外 `attachJudge` 落自检质量分。
- **写库**:`plan_scripts` upsert(`content` Markdown、`status='ready'`、`source`、`agentInvocationId`)。
## 触发
详情页打开按需拉 `GET /pac/v1/plans/:id/script:stream`(SSE 流式),或点"重新生成"走 `POST /pac/v1/plans/:id/script:regenerate`(同步)。两个旋钮:**投入档**(tier)+ **模型**。自动批量生成走 `plan-asset-generate` 队列,当前为 no-AI stub(省 token),见 [三层模型](/docs/architecture/three-layer-model)。
---
title: 召回正确性验证
description: 用独立扫描器对全量数据做牙位级交叉验证(差分测试),量化召回的 FP / FN。
icon: ShieldCheck
---
> **真理源**:扫描器 `apps/pac-service/sql/verify-recall.sql`;被验对象 `treatment-initiation-recall.scenario.ts`(召回本体)。每次摄入 / 改算法后跑一遍当回归。
---
## 方法:差分测试(独立重述,交叉比对)
扫描器**独立于 scenario 代码**,自己重述一份「码 → 排除口径(resolver / cooldown / 牙位剔除…)」配置,跟代码各写一份 —— **两边不一致即暴露**(差分测试,防口径漂移)。它读 `plan_reasons.signals` 反查 `patient_facts`,逐颗(患者 × 牙)摊开做 **2×2 牙位级交叉**:
| | 已召回 | 未召回 |
|---|---|---|
| **未治(有缺口)** | ✓ 该召 | FN 候选 |
| **已治(resolved)** | ✗ FP(已治却召) | ✓ 该排 |
两条判定纪律(纯 SQL 不建模会虚报,必须做对):
- **FN 必须逐层剥合法排除**才看"真漏召":未来预约 / 近期到诊(14d)/ 废用牙 · 乳牙 / §E 剔除 / 同牙更晚诊断取代…—— 剥完剩下的才是真漏。
- **FP 用「最新诊断」作时间基准**(非最早):否则"同牙复发(旧诊断治了、新诊断未治)"会被误判 FP。
## 性能:必须集合式
把治疗 / 召回 / 诊断牙位各 `unnest` 一次进**索引临时表 + ANALYZE**,再 EXISTS 索引查找。逐行相关子查询(`EXISTS + CROSS JOIN LATERAL unnest`)在 13 万患者上是 O(n²)(实测 1h21min 没跑完);集合式 **~39 秒**。
## 结论
最近一次全量参考运行(13 万患者):**FP 极低(约 0.02%)、真漏召接近 0**;FP 残量多为"继发龋 / 不良充填物复诊"(旧填料坏了又诊断龋)—— 其实是**对**的召回,被扫描器按"已治"误判,真 FP 更少。具体数字随数据 / 算法迭代变化,以当次运行输出为准。
> **口径**:上游 garbage-in(影像 AI 牙位错位 / name_map 误映射)的错误归"影像 / 摄入层"修,**不在召回逻辑里加猜对齐的启发式**(否则越堆越多 magic 还掩盖真 bug)。一线反馈核出的误召场景(固定桥盲点 / 间隙关闭 / 患者拒绝 / 外院已治疗 / ICD 错标纠偏)已收进召回算法的[牙位级判定](/docs/algorithms/recall)。
## 跑法
```bash
docker exec -i pac-postgres psql -U pac -d pac -f - < apps/pac-service/sql/verify-recall.sql
```
章节:§A 规模 / §B FP 硬闸(应 0)/ §C 牙位交叉 + FP / §C3–C4 FN 逐层解释 / §D 全口复发 / §E K08 正畸语境。
{
"title": "API 参考",
"icon": "Webhook",
"pages": ["..."]
}
---
title: 数据摄入
description: PAC 怎么把任何宿主的数据,经统一管道标准化成可信的患者事实 —— 传输、数据源、标准化各层、匹配规则、canonical 字典与可信保证、幂等。
icon: Import
---
摄入只有**两种传输方向**(Pull / Push),再叠加正交的**存量 / 增量**。
所有入口取回 raw 后,都汇入**同一条标准化管道**,口径一致;最终产物 `fact.content` 是下游(画像 / 召回 / AI)的**唯一真理源**,下游不再碰原始数据。
总览见 [架构总览](/docs/architecture/overview)。
---
## Pull —— PAC 主动取
按计划 / 手动去宿主取数,**三种源**任选(取回的 raw 下游完全无感知):
| 源 | 怎么取 | 状态 |
|---|---|---|
| **数据库直连** | 直连宿主数仓(目前 ClickHouse,manifest `sql_source`) | ✅ |
| **文件**(CSV / JSON) | 读宿主导出文件(manifest `tables[]`) | ✅ |
| **API 接口** | HTTP 拉取适配器(api-key / login / oauth2) | ⛔ 仅 mock,真实适配器归档待接 |
> **双源真等价**:直连用的 SQL 被刻意限制成「等价于一张 CSV 单表 dump」的朴素 SELECT(只列选 + WHERE 过滤,**禁止** JOIN / JSONExtract / multiIf 等形态改造 —— 那些归 transforms)。
> 于是宿主用直连、CSV、还是 HTTP 给数据,**下游一行都不用改**,可随时切换。
**存量 ↔ 增量**(同一条 pull 路径,靠游标切换):
- **存量**(首次,无游标)→ 全量拉。
- **增量**(后续)→ 读上次 `cursor_after`,注入 `WHERE cursor_column > 上次值`,跑完写新游标;中途失败保留上次水位,下次重跑。
---
## Push —— 宿主主动推
宿主在事件发生时 webhook 推给 PAC(`POST /push/events`,**HMAC-SHA256 验签** + 密钥轮换、±5 分钟时间窗)。
天然是事件增量流。因为 event 自带动作语义,push **可以跳过 transforms**,直接进 assembler / 落库。✅
---
## 统一标准化管道
所有入口的 raw 都过同一条管道。**前两步(transforms + assembler)是 raw → canonical,配置都在该 host 的 manifest 里(per-host 差异);后三步是集团统一内核,所有入口共用**:
```mermaid
flowchart LR
RAW["raw 行<br/>(宿主原生形态)"]
--> T["① transforms<br/>形态改造(6 算子)"]
--> A["② assembler<br/>命名 + 枚举翻译"]
--> S["③ 事务合成<br/>写 patient_transactions"]
--> P["④ parser + Zod<br/>从账本衍生 fact"]
--> F["⑤ fact 落地<br/>写 patient_facts"]
style RAW fill:#eee,stroke:#999
style F fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
```
| 层 | 做什么 | 关键 |
|---|---|---|
| **① transforms** =「**形态改造**」*(manifest,per-host)* | 拆行 / 派生 / 关键词分流 / 多列推断 / 行过滤 | **6 个白名单算子**(`project` / `split_json_array` / `derive` / `route_by_pattern` / `pick_first_nonzero` / `filter`),无副作用纯函数,**不允许任意 JS / eval** |
| **② assembler** =「**词汇翻译**」*(manifest,per-host)* | 字段名翻译(宿主 → canonical)+ 枚举码翻译(闭集字典 + `_default` 兜底)+ `emits` 推断 | manifest + yaml 驱动;**名字虽叫 assembler,本质是翻译器** |
| **③ 事务合成** | 把 canonical 行写成 `patient_transactions`(append-only 账本) | `raw_payload` + `canonical_payload` 双留底;`source_event_id` 幂等键;`event_seq` 单调水位 |
| **④ parser + Zod** =「**类型 + 语义 + 校验**」 | 从 transaction 把扁平行**构造成带类型的 `fact.content`**:单位 / 牙位 / 文本归一、code 纠偏(如 K00 误标→K08)、`FactContentSchema` 强校验 | **唯一会抛错拦脏数据的强校验闸**,字段漂移即拒 |
| **⑤ fact 落地** | 写 `patient_facts`(版本流,supersede 旧版) | 唯一写入口 `FactWriter`,evidence 反指 transaction |
**「标准化」分三个 facet,各管一层、不重复**:
- **形态**(transforms):把数据拆成对的行 / 列结构。
- **词汇**(assembler):把宿主的字段名、枚举码查表翻成 PAC 闭集字典(精确映射,不猜)。
- **类型 + 语义 + 校验**(parser):把翻译后的扁平行**构造成带类型的事实**,做归一 / 纠偏 / 强校验 —— 标准化的**最后一层 + 兜底闸**。
> **一条原则 —— 宿主差异只在 yaml**:每接一个宿主,差异**全部收敛在它的 `manifest` + `assembler yaml`**(取数 SQL/文件、形态算子、字段 / 枚举翻译)。过了标准化管道之后 —— `fact.content` 及其下游(画像 / 召回 / AI / 前端)—— **全集团一套代码、零 per-host 分支**。接新宿主 = 写一份 yaml,不改一行下游代码。
> **唯一收口**:全库只有 `TransactionSynthesizer` 能写账本、只有 `FactWriter` 能写 fact;字典只认 `canonical-codes.ts` 闭集;CI 有防漂移测试(详见下〈Canonical 字典与可信保证〉)。
### 各入口在哪一段汇入
四种取数方式接入管道的位置不同,**事务合成 → parser → 落库 这段统一内核完全共用**:
| 入口 | 取数 | transforms | assembler | 事务合成 → parser → 落库 |
|---|---|---|---|---|
| **直连 / 文件**(cold-import) | SQL / CSV | ✅ | ✅ | ✅ 共用 |
| **API 拉取** | HTTP GET | ✅ | ✅ | ✅ 共用 |
| **Push** | 宿主直推 event | ❌ 跳过 | 视形态可选 | ✅ 共用 |
---
## 一个例子:一行宽表 → 9 个 fact
> 取自一宿主一次真实接诊:宽表 `fact_emr_treatment_out` 的**一行**(`diag` / `treat_plan` 是 JSON 数组子字段)。
> 直观感受「摄入到底在干什么」:**一行 → 拆 / 译 / 校验 → 多个独立 fact**。
原始一行的 `diag` 数组里有 6 条诊断,其中 2 条只有中文 `message`、没有标准码:
```jsonc
"diag": [
{ "toothPosition": "16;17;25;26", "message": "残根" }, // ← 无 stdCode
{ "toothPosition": "27;35", "message": "牙周炎" }, // ← 无 stdCode
{ "stdCode": "K08.103", "toothPosition": "44;45;46", "message": "后天性牙齿缺失" },
{ "stdCode": "K05.300", "toothPosition": "41;31", "message": "慢性牙周炎" },
{ "stdCode": "K04.500", "toothPosition": "14;15", "message": "慢性根尖牙周炎" },
{ "stdCode": "K07.303", "toothPosition": "11;12;21", "message": "牙体缺损" }
]
```
经过管道:
1. **transforms**:`split_json_array` 把 `diag` 拆成 6 行 → `filter` 只卡「**有没有诊断名 `message`**」,6 条都有 → **一条不丢** → `derive` 有码取 K 码前 3 位(`K08.103 → K08`),无码取归一后的中文名。`treat_plan` 拆出 1 行拔牙计划。
2. **assembler**:`organization_id → clinicId`、`rq → occurredAt`、`brand → tenant_id`;诊断码 —— 有 ICD 码 `K0x` 直通;**无码用中文名查字典**:`牙周炎 → K05`、`残根 → K03`(都命中)。**无 ICD 码 ≠ 翻不出**。
3. **事务合成 → parser + Zod**:每个资源先写一条账本(`patient_transactions`),再从账本逐条衍生、规范化、强校验成 fact。
**结果:DW 一行 → 9 个独立 fact**(一条都不丢):
| fact 类型 | 条数 | 内容 |
|---|---|---|
| `encounter_record` | 1 | 接诊元数据(医生 / 时间) |
| `diagnosis_record` | **6** | 4 条有 ICD 码 → K08 / K07 / K04 / K05;2 条无码按中文名查字典 → 牙周炎 K05、残根 K03(本行 6 条全部出码) |
| `treatment_record`(planned) | 1 | 拔除,牙位 27 |
| `emr_record` | 1 | 病历自由文本(留待后续抽取) |
> **无 ICD 码 ≠ 翻不出**:中文名先精确查字典(`残根→K03`、`牙周炎→K05`),精确不中再走关键词模糊,**都不中才** `code=null` 留底(诊断不丢 fact)。本行 6 条全部出码。下游只读 `fact.content`,不碰原始宽表。
---
## 匹配规则:诊断 / 建议 / 治疗 / 等自由文本
把原始信号映射成 PAC 标准码,是**三级漏斗**:精确优先,逐级兜底。
**① 关键词路由(transforms `route_by_pattern`)** —— 先把 `treat_plan` 按 `treat_name` 分流(三模式:`starts_with` / `equals` / `contains` 子串):
| 命中 | 去向 |
|---|---|
| `starts_with`「建议」「推荐」 | **建议**(recommendation) |
| `equals` 复查 / 检查 / 暂观 / 转诊 / 缴费 …(白名单) | **复查 / 非治疗**(review) |
| 其余(`default`) | **真治疗**(treatment,`kind=actual`) |
排除法:先剥离「建议」和「复查 / 行政」,**剩下的默认当真治疗**。
**② 字典翻译(assembler)** —— 把名字 / 码翻成 PAC canonical 码,**三级**:
| 级 | 机制 | 例 |
|---|---|---|
| **精确** `enum_mapping` | 精确命中闭集字典 | `K08.103→K08`、`牙周炎→K05`、`残根→K03`、`建议种植→IMPLANT_RECOMMENDED` |
| **关键词模糊** `keyword_mapping` | 精确不中 → **子串「含任一词即命中」**;有序优先 + 排除词 `none`(语义反转)+ 先剥「条件从句」 | `「楔状 / 残根 / 隐裂」→K03`;`龋(`none`: 风险 / 可疑)→K02`;`「充填,必要时根管」剥成「充填」→ restorative` |
| **兜底** `_default` | 都不中(行为见 ③) | —— |
> **为什么要模糊层**:host 术式是 free-text,**2 万+ distinct**,精确字典盖不全;关键词「一条吃一类」治长尾。所以「不靠中文猜」要修正成:**先精确、再关键词、最后才放弃** —— 关键词层就是受控的模糊匹配。
**③ `_default` 兜底按 fact 类型不同**:
- **诊断**:翻不出 → `code=null` + 留原文,**fact 仍落**(「该患者有这问题」的事实要留)。
- **建议**:翻不出 → **直接丢**(不入 fact);只有命中、能触发召回的建议才有意义,长尾仅在病历展示。
**④ 自由文本**(`illness_desc` / 医嘱原文):
- **当前只存进 `emr_record.content` 留底,不结构化**。
- 设计中的 **Layer C**(规则 / LLM / 关键词三轨抽取 → 带 confidence 的信号、下游降权)**尚未上线**:`parser` 预留了 `extractedBy = llm / rule / doctor_input` 入口,但 **LLM signal-extractor 还没实现**。
- 所以现在的「建议」**只来自结构化 `treat_plan` 的关键词路由**,**埋在自由文本里的建议还没被挖出来**。
---
## Canonical 字典与可信保证
匹配的目标,是一套**集团统一的闭集字典**(`canonical-codes.ts`):诊断码(借 ICD-10 的 K00–K14 大类)+ PAC 业务码(种植失败 / 正畸反弹等国际标准没有、但算法要的)+ 推荐码(`IMPLANT_RECOMMENDED` …)+ 11 类治疗 category。
- **宿主随意,字典闭集**:宿主端用 ICD / 自定义码 / 中文 / 自由文本都行,`assembler` 负责翻译进字典;字典本身是小闭集(几十到一两百个码)。
- 收益:集团范围内**算法 / dashboard / 报表口径完全一致**。
`fact.content` 之所以可信,靠几道**代码层质量闸**(不靠人工审):
| 闸 | 作用 |
|---|---|
| **schema 强校验** | 每个 fact 类型一份 zod(`FactContentSchemas`),写库前必过,违反抛错 → 脏数据进不了库 |
| **唯一写入口** | 只有 `FactWriter` 能写 fact、`TransactionSynthesizer` 能写账本(lint 拦其他人直写 Prisma) |
| **字典闭集** | enum 值必须 ∈ `canonical-codes.ts`,越界值进不去 |
| **单向消费** | 算法 / 画像 / AI **只读 `fact.content`**,红线禁止读 `raw_payload`(仅 reparse CLI 例外) |
| **CI 防漂移** | 改 yaml / parser / 字典,CI 自动跑全套(schema × yaml × 字典联立),漂移即挂 |
---
## 幂等与续传
- **幂等键** `source_event_id`:由 synthesizer 统一合成 = `来源 : 资源 : source_unit(品牌) : 主体id : 更新时间标记`。同一主体、同一更新时刻 → 同键;DB 偏唯一键去重,重复直接跳过。
- **完整性** `payload_hash`:对 raw 行做 sha256,**独立字段**,做兜底校验(不参与上面那个幂等键的拼接)。
- **水位** `event_seq`:Postgres 序列单调递增,供「自上次以来新增」消费,不依赖 `created_at`。
- **游标**:增量拉记录 `cursor_after`,跑完才推进;中途失败保留上次水位,下次从断点重跑。
---
延伸:[三层模型](/docs/architecture/three-layer-model) · [数据模型](/docs/architecture/data-model) · [接入](/docs/integration/overview)
---
title: 数据模型
description: PAC 数据库 18 个 model 的层次、写入模式、隔离纪律与关键索引。
icon: Database
---
> **真理源**:`apps/pac-service/prisma/schema.prisma`(表结构)、`packages/types/src/enums`(枚举闭集)。本页给层次、写入模式、隔离与索引纪律;**逐字段细节以 schema 为准**。三层数据流 / 状态机 / 触发见 [三层模型](/docs/architecture/three-layer-model)。
---
## 模型地图(18 model)
隔离基线统一是 **`host_id + tenant_id`**(进每张业务表唯一键 + 每条查询)。
| 表 | 层 | 写入模式 | 角色 |
|---|---|---|---|
| `hosts` | 顶层 | upsert(admin) | 接入宿主注册:换票凭据 + 出站 `pull_config` + push 验签密钥 + deep-link 模板。子表 `host_id` FK 到此 |
| `patients` | L1 | upsert | 患者主档,**只装身份**(name/phone/dob/病历号);跨诊所共享一行 |
| `patient_profiles` | L1 | upsert(1:1) | 患者非事实属性副表:合规标记(`do_not_contact`/`deceased`)+ 标签/备注 + 获客渠道 + 转介绍聚合 |
| `patient_relations` | L1 | upsert(边) | 患者—患者关系边(联系人/亲属/转介绍人);关系人本身也是 patient |
| `patient_return_visits` | L1 | upsert | 诊所回访任务记录(展示用)。**非临床 fact,不进召回信号** |
| `patient_transactions` | L1 | append-only | 操作账本:谁在哪个诊所对哪个主体做了什么。原文留底 |
| `patient_facts` | L1 | 版本流 | 事实单元(版本流),`fact.content` 是下游唯一真理源 |
| `personas` | L2 | 版本流 | 患者画像(每次重算新版本) |
| `persona_features` | L2 | 版本流(随父) | 画像特征条目(key + 描述 + 证据) |
| `persona_recompute_logs` | 横切 L2 | append-only | 画像重算执行账本 |
| `followup_plans` | L3 | 版本流 | 召回计划(patient 级,单一活跃) |
| `plan_reasons` | L3 | 子表(plan 1:N) | 召回理由清单(scenario / sub_key 维度 + 证据 + 打分 breakdown) |
| `plan_scripts` | L3 | 版本流(随父) | 客服参考话术(per-plan 单行,异步生成) |
| `plan_summaries` | L3 | 版本流(随父) | 详情页摘要卡片(per-plan × type) |
| `plan_executions` | L3 | append-only | 执行回写(客服通话结果),驱动 plan 状态机 |
| `plan_generation_logs` | 横切 L3 | append-only | 召回生成执行账本 |
| `agent_invocations` | 横切 | append-only | AI 调用审计账本(跨所有层共享) |
| `sync_logs` | 横切 L1 | append-only | 同步运行账本(cursor / reconcile / 计数) |
> PAC **不立** tenants / clinics / users 表 —— 这三个 id 都是宿主经换票 token 提供的 `TEXT`,PAC 不维护其主数据(见 [架构总览](/docs/architecture/overview) · 多租户与权限)。
---
## 设计原则
**1 · 按表语义隔离,不是统一三级。** `host_id + tenant_id` 是所有业务表的硬隔离基线;`clinic_id` 只在三种业务语义下立柱,不滥用:
| 维度 | 表 | 语义 |
|---|---|---|
| `clinic_id`(操作发生地) | `patient_transactions` / `plan_executions` | 业务字段,RBAC 用,**非 DB 隔离边界** |
| `target_clinic_id`(业务归属,nullable) | `followup_plans` | null = 集团统一客服池 |
| 不立 `clinic_id` | 身份 / 推断层(persona / plan)/ 账本 | 跨诊所共享身份 |
**2 · 事实 vs 推断分层。** 事实(宿主给的结构化记录)落 `patient_transactions` + `patient_facts`;推断(PAC 算的结论)落 `persona_features` + `followup_plans`。**推断绝不回写事实** —— `plan_executions` 只驱动 plan 状态机,不进 fact/transaction(否则 PAC 自己的动作会被当患者事实参与下一轮重算,证据链就乱)。
**3 · 写入模式按数据所有权三分。**
| 模式 | 表 |
|---|---|
| **append-only**(只插不改不删) | `patient_transactions` / `plan_executions` / `agent_invocations` / 3 张 orchestration log |
| **upsert 当前态** | `patients` / `patient_profiles` / `patient_relations` / `patient_return_visits` / `hosts` |
| **版本流**(append + supersede) | `patient_facts` / `personas`(+features)/ `followup_plans`(+scripts/summaries) |
子表跟随父版本:`persona_features` 随 `personas`、`plan_scripts`/`plan_summaries` 随 `followup_plans`;父表 supersede 时子表自然进入历史。
**4 · 证据链与版本化。** 每个对外输出都能反查"为什么这么定":
```
persona_features.evidence = { factIds, agentInvocationIds, ruleIds }
plan_reasons.evidence = { factIds, agentInvocationIds, ruleIds }
plan_scripts / plan_summaries.agentInvocationId
patient_facts.transactionIds → patient_transactions.id → rawPayload
```
**5 · Harness 工程观(规则为骨、AI 为肉)。** 数值 / 决策 / 时间 / 状态机走确定性规则(不允许失败);自然语言(话术 / 摘要)走 LLM,**失败必降级模板**(`source='template_fallback'`)。三张 orchestration log 立柱 `rule/agent/fallback` 计数,`fallbackCount` 升高 = 上游 LLM 异常早期信号。
**6 · 立柱 / JSON / notes 三分。** 需 SQL 筛选 / 排序 / 索引或属合规边界 → 立柱;易变、形状不固定、代码要 parse 的结构化数据 → JSON 列(应用层 zod 校验);仅展示给人的自由文本 → `notes`。
**7 · 金额一律 Int 分。** `*Cents` 字段以最小货币单位整数全链路存;入口按宿主 `amount_unit`(`fen`/`yuan`)归一,出口前端格式化,API 不返格式化字符串。
**8 · 时间一律 UTC 存储。** 时刻字段用 `DateTime @db.Timestamptz(3)`;日期(无时刻)用 `@db.Date`。入口按宿主 `timezone`(IANA)转 UTC,出口返 ISO 8601 with offset;跨日聚合 `AT TIME ZONE <tz>` 后再 `DATE_TRUNC`。
---
## 命名 / 类型 / 索引规约
- **命名**:表名 snake_case 复数;Prisma 字段 camelCase →(`@map`)DB 列 snake_case;主键统一 `id` UUID;同表内字段不重复表名前缀(fact 用 `kind`/`type`/`status`,不用 `factKind`);枚举值小写(`script` 不是 `SCRIPT`)。
- **类型**:主键 / FK = `String @db.Uuid`;时刻 = `Timestamptz(3)`;单调水位 = `BigInt @default(autoincrement())`;金额 = `Int`(分);枚举 = `String` + 应用层 zod 校验(DB 不强枚举,留扩展)。
- **partial UNIQUE / GIN**(Prisma 表达不了,migration.sql 是单一真理源):
| 约束 | 表 | 作用 |
|---|---|---|
| partial UNIQUE `(host_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL` | `patient_transactions` | 幂等键 |
| partial UNIQUE `(host_id, tenant_id, subject_id) WHERE status='active'` | `patient_facts` | 单活跃版本 |
| partial UNIQUE `(host_id, tenant_id, patient_id) WHERE status IN ('active','assigned')` | `followup_plans` | patient 级单一活跃 plan |
| partial UNIQUE `(host_id) WHERE status='running'` | `sync_logs` | 同 host 存量/增量并发锁 |
| GIN `(transaction_ids)` | `patient_facts` | 反查"某 transaction 影响了哪些 fact" |
---
## 各表要点
### 第一层 · 患者事实
**`hosts`** — 接入宿主注册。`name` / `app_id` 唯一;`app_secret_hash` scrypt 哈希(原文不落库);`pull_config`(出站 endpoints + 凭据 + `amount_unit` + `timezone`,凭据对该宿主所有出站调用共用);`push_secret_hashes[]` 数组支持密钥平滑轮换(任一命中即通过);`action_urls` deep-link 模板;`active` 软停用(false 时换票直接 401)。
**`patients`** — 患者主档,**只装身份**。唯一键 `(host_id, tenant_id, source_unit, external_id)`。`active` 布尔(true=在召回池,false=宿主软删/归档);`name`/`phone` stub 期可空(push 首见只拿到 ref,后续 pull 补全;`phone IS NULL` 自动排除召回池);`medical_record_number` 可读病历号。**不支持物理删除**(PIPL 删除权走匿名化或 `active=false`),子表 `onDelete=Restrict` 兜底防误删。
> 召回池硬过滤:`active=true` AND `phone IS NOT NULL` AND(`patient_profiles`)`do_not_contact=false` AND `deceased=false`。
**`patient_profiles`** — 患者非事实属性副表(1:1,PK=`patient_id`,Cascade)。合规标记 `do_not_contact`(+原因/时间)、`deceased`;产品标签 `tags[]` + 自由 `notes`;获客渠道 `acquisition_channel`(立柱标准枚举,host 值经 enum_mapping 归一)+ `acquisition_sub`;转介绍聚合 `referral_count` / `referral_amount_cents`。拆出副表让主表身份查询更轻,合规过滤走 join。
**`patient_relations`** — 患者—患者关系边(联系人/亲属/转介绍人)。自关联:关系人本身也是 patient,姓名/电话从对端现取、零冗余。唯一键 `(patient_id, related_external_id, relationship)`;`related_external_id` 始终保留(晚绑定),`related_patient_id` 仅当关系人也是 active patient 时填。
**`patient_return_visits`** — 诊所回访任务记录(展示用)。唯一键 `(host_id, tenant_id, source_unit, external_id)`。**不是临床 fact、不进召回信号**;详情页"回访记录"按 `task_date` 倒序展示,避免客服重复外呼。
**`patient_transactions`** — 操作账本(append-only)。`event_seq` BigInt 单调水位(供 persona / 游标消费);`source_event_id` 幂等键(Push 必带,Pull adapter 合成);`raw_payload`(宿主原文)+ `canonical_payload`(assembler 翻译后中间态,审计/replay)+ `payload_hash`;`clinic_id` 立柱。`action` × `subject_type` 为 PAC 归一化封闭集 —— **23 个 action × 16 个 subject_type**(`@pac/types` `Action` / `SubjectType`)。
**`patient_facts`** — 事实单元(版本流)。`subject_id` 业务身份跨版本稳定,`(…, subject_id, version)` 单调递增;`kind`(`actual`/`planned`,2)× `type`(**16 个 FactType**)× `status`(`active`/`superseded`/`cancelled`/`fulfilled`/`expired`/`invalidated`,6);`content` JSONB(per type zod 校验,规则引擎只读这里);`transaction_ids` 证据链。一个 transaction 可产 0/1/N 个 fact。
### 第二层 · 患者画像
**`personas`** — 画像版本流。`source`(`txn:` / `manual:` / `scheduled:`)、`event_watermark`(已响应 `event_seq`,判 stale)、`superseded_at`(null=当前版)。不立 status enum(生命周期纯线性)。唯一键 `(patient_id, version)`。
**`persona_features`** — 画像特征(随父版本,Cascade)。`key` + `description`(自然语言,自包含,**不拆字段**)+ `score?` + `data?`(结构化值)+ `evidence`。唯一键 `(persona_id, key)`。候选 key 共 **33**(`PersonaFeatureKey`),v1 实装 **16 个规则 extractor**(`feature.registry.ts`),agent 路径未上线。
**`persona_recompute_logs`** — 重算执行账本。`triggered_by` / `status`(running/success/partial/failed)/ `rule_count`(agent/fallback 计数位 v1 恒 0)/ `produced_persona_id`。
### 第三层 · 召回计划
**`followup_plans`** — 召回计划版本流,**patient 级**(同 patient 任意时刻最多一条 active/assigned)。`priority_score` = MAX over `plan_reasons.priority_score`(排序冗余);`goal` / `recommended_at`(留 null)/ `recommended_role`(`staff`)/ `recommended_channel`(`phone`)纯规则;`status`(`active`/`assigned`/`completed`/`abandoned`/`superseded`,5);`assignee_user_id` / `recycle_at` / `snoozed_until`(抑制窗)/ `contact_attempts`(patient 级熔断)/ `recall_feedback`(召回准不准的拇指反馈);`target_clinic_id` 立柱。
**`plan_reasons`** — 召回理由子表(plan 1:N)。`scenario` + `sub_key`(临床缺口细分)+ `priority_score` + `reason`(自然语言)+ `evidence` + `signals`(结构化召回信号)+ `breakdown`(打分因子)+ `lifecycle`/`source`/`closed_reason`。唯一键 `(plan_id, scenario, sub_key)`。`PlanScenario` 枚举含 `treatment_initiation_recall`(应治未治)与 `treatment_aftercare_recall`(疗效保障),**当前仅 `treatment_initiation_recall` 在引擎注册运行**。
**`plan_scripts`** — 参考话术(per-plan 单行,Cascade)。`status`(`pending`/`ready`/`failed`)+ `source`(`agent`/`template_fallback`/`manual`)+ `agent_invocation_id`。唯一键 `(plan_id)`。
**`plan_summaries`** — 摘要卡片(per-plan × `type`,Cascade)。`SummaryType` 枚举 6 个;详情页当前用 3 张卡:`recall_brief`(本次召回简报)/ `persona_tags`(画像标签)/ `recall_history`(回访历史)。唯一键 `(plan_id, type)`。
> 话术 / 摘要按需懒生成(客服打开详情页时 `getOrGenerate`);`plan-asset-generate` 队列 worker 当前为 no-AI stub,详见 [三层模型](/docs/architecture/three-layer-model)。
**`plan_executions`** — 执行回写(append-only)。`channel` / `outcome` / `notes` / `abandon_reasons[]` / `scheduled_next_at`;`executor_clinic_id` 立柱(执行发生地,与 `target_clinic_id` 业务归属允许不一致)。`outcome` 共 **14 个**(表单可见 7,其余历史值),经 `EXECUTION_OUTCOME_META[outcome].drivesStatus`(`completed`/`abandoned`/`keep`)驱动 plan 状态机 + `suppressDays` 写抑制窗。
**`plan_generation_logs`** — 召回生成账本。`triggered_by` / `status` / `scenario_run_count` / `plans_created` / `assets_created` / `agent_count` / `fallback_count`。
### 横切支撑
**`agent_invocations`** — AI 调用审计账本(append-only,跨所有层共享)。`kind` / `call_key` / `prompt_version` / `model_provider` / `model_name` / `workflow_run_id` / `input_snapshot` / `output` / token 明细 / `cost_yuan` / `status`。每次 LLM 调用两段式落账(start=running → end 回填),是 audit / replay / 成本核算唯一真理源。AI 解析结果归 `output`,**不回写 fact**。
**`sync_logs`** — 同步运行账本。`direction`(pull/push)/ `resource` / `status`(running/success/partial/failed)/ `cursor_before`/`after`(断点续传)/ `triggered_by`(含 `reconcile:<date>` 对账)/ 计数恒等式 `fetched = transactions_written + duplicates + failed` + `facts_emitted` / `metadata`(cohort 进度 + 漂移审计 `suspectFields`)。
---
## 附录:真理源优先级
冲突时以高优先级为准:
1. **`schema.prisma`** —— 表结构 / 字段 / 索引(含 migration.sql 里的 partial UNIQUE / GIN)
2. **`packages/types/src/enums`** —— `action` / `subject_type` / `FactType` / `PlanScenario` / `ExecutionOutcome` / `SummaryType` 等枚举闭集
3. **本页 / 其他架构文档** —— 层次、写入模式、纪律的人读说明
{
"title": "架构",
"icon": "Layers",
"pages": ["overview", "data-ingestion", "three-layer-model", "data-model"]
}
---
title: 架构总览
description: PAC 的整体架构 —— 多宿主可插拔、集团级、三层推断(事实 → 画像 → 触达)的事件驱动系统。
icon: Layers
---
> PAC 是一个**多宿主可插拔、集团级**的患者数据中枢。
> 它把任何宿主的原始数据,经统一管道**标准化**成不可变的**事实账本**,在其上事件驱动地派生**画像**与**召回计划**,
> 再通过 REST / MCP / 工作台对外服务,并把客服执行结果回填。
---
## 组件 / 模块地图
| 分组 | 模块 | 职责 | 状态 |
|---|---|---|---|
| **摄入** | `sync` | 统一摄入管道(冷启 / 增量 / 推送 + transforms/assembler/parser) | ✅ |
| **标准化核心** | `clinical-gap` | 潜在治疗 gap 的 SQL 单一真理源(召回 + 画像共用,无 HTTP) | ✅ |
| **画像** | `persona` | Layer 2:确定性特征提取器(RFM / 生命周期 / 禁忌 / 潜在治疗 …) | ✅ |
| **召回** | `plan` · `plan-aggregate` | Layer 3:召回计划 + 执行状态机 + 规则引擎;详情聚合 + AI 重生成入口 | ✅ |
| **AI** | `ai` | LLM harness:AiCall 注册表 + 单一收口 runner + 多供应商(见下) | ✅ |
| **接口** | `auth` · `mcp` · `patient` · `admin` | 鉴权换票;MCP 工具(对外 agent 只读);患者档案/时间线;后台自助 | ✅ |
| **客服助手** | `assistant` · `weixin-aibot` · `realtime-coach` | 工作台 AI 助手(MCP 工具)+ 企微问答 + 实时语音教练(WS) | ✅(企微按配置启用) |
---
## 整体数据流
```mermaid
flowchart TB
H["🏥 宿主系统 / 数仓<br/>就诊 · 治疗 · 缴费 · 影像 …"]
subgraph ING["摄入 · 标准化(sync)"]
direction LR
E["Pull(直连 / 文件 / API)<br/>Push(事件)"] --> S["transforms → assembler → parser"]
end
subgraph CORE["三层推断(事件驱动)"]
direction LR
F["① 事实账本<br/>transactions / facts"] --> P["② 画像<br/>persona"] --> PL["③ 召回计划<br/>followup_plans"]
end
subgraph SVC["服务"]
direction LR
A["REST / MCP"]
AI["AI 话术 · 摘要 / 客服助手"]
end
EX["执行回填"]
H --> ING --> F
PL --> SVC --> EX
EX -.->|"驱动状态机 · 不回写事实"| PL
```
**事件驱动主路径**:事实变更 → 画像重算 → 计划重算 → 话术 / 摘要生成,三条队列依次推进。
按患者 / 计划串行(同一主体不并发)、跨主体并行;每步靠**水位**(单调序列 `event_seq` + 消费方 `eventWatermark`)判幂等,已处理过的直接跳过。
---
## 数据摄入
摄入只有**两种传输方向**,叠加正交的**存量 / 增量**;所有入口取回 raw 后,汇入同一条标准化管道(`transforms → assembler → 事务合成 → parser → fact 落地`),口径一致,`fact.content` 是下游唯一真理源。
- **Pull(PAC 主动取)**:源有 数据库直连(ClickHouse)/ 文件(CSV·JSON)/ API(待实现);存量↔增量靠游标切换。
- **Push(宿主主动推)**:webhook 推事件(`POST /push/events`,HMAC 验签)。
> 细节(各源、双源等价、管道各层、幂等机制)见 [数据摄入](/docs/architecture/data-ingestion)。
---
## 三层模型
PAC 的骨架是一条**不可变事件流**,其上派生两层读模型(详见 [三层模型](/docs/architecture/three-layer-model)):
| 层 | 表 | 性质 |
|---|---|---|
| **① 事实** | `patient_transactions`(操作账本)+ `patient_facts`(事实版本流) | 不可变、可追溯,标准化收口 |
| **② 画像** | `persona` + `persona_features` | 在标准事实上自动评价(16 个规则特征提取器,候选 33) |
| **③ 触达** | `followup_plans` + `plan_executions` | 规则打分生成召回计划,执行结果回填 |
---
## 多租户与权限
权限分两条正交轴:**功能权限**(能做什么操作)+ **数据权限**(能看哪些数据)。
- **PAC 不拥有用户**:无账号 / 用户表 —— 客服、管理员身份来自宿主,经换票(`auth/exchange-token`)签发 PAC 自己的 JWT(带角色 + `orgScope`);落库的指派人 / 执行人只是宿主侧 user id,展示名由宿主接口解析。
- **功能权限(RBAC)= 能做什么**:角色 `staff / leader / admin` 随 JWT 带入,端点用 `@RequirePermission` 守卫操作级权限 —— staff 限自己的工单(查看 / 执行 / 认领),leader 增全池查看 / 回收 / 统计,admin 全量(含平台管理)。
- **`tenant_id` = 集团**(硬隔离基线,进每张业务表唯一键 + 每条查询):宿主是 SaaS 系统 / 数仓,一套往往横跨多个集团 —— 故一个 host 可含多个 tenant;集团才是数据归属与隔离的自然边界。
- **`source_unit` = 源组织命名空间(发证来源)**:进唯一键,给 `external_id` 在集团内消歧(同号不同源 = 不同人)。取哪一层由 manifest `identity_namespace_field` 声明,单命名空间宿主留空串。语义是发证命名空间(历史不可变),非当前归属 —— 当前归属由 `orgScope` 解析。
- **`clinic_id` = 业务字段**(RBAC 用,不是 DB 隔离边界)。
- **`orgScope` = 数据权限**:JWT 只装通用 org 节点引用,每请求按 org 树 `expandOrgScope` 展开成 `sourceUnits + clinicIds`。
**三层强制(纵深防御)**:
1. **每请求拦截器** —— 展开 orgScope,放进 AsyncLocalStorage 上下文。
2. **Prisma 扩展兜底** —— 对受限模型的列表 / 聚合读自动注入 `tenant_id`(+ `source_unit`),漏写也不泄漏。
3. **DB 唯一键** —— 最后一道。
4. **fail-closed**:需 `source_unit` 消歧的租户遇到无法解析的范围 → 落 `DENY_SOURCE_UNIT` 哨兵,绝不退化成「不限范围」。
详见 [登录授权](/docs/integration/auth-login)。
---
## AI 层
- **自建 in-house harness**(数据不出境)。
- **多供应商**:**DeepSeek**(默认,国内端点) / **Qwen·DashScope**,按 modelId 路由。
- **单一收口** `ai-call-runner`:任何 LLM 调用都过它 —— 输入哈希缓存、`agent_invocations` 全量审计、安全闸、成本估算、超时 + 重试。
- **失败降级**:LLM 失败或安全闸拦截 → `template_fallback`,产出标记来源、流式照常收尾。
- LLM **只产自然语言**(话术 / 摘要);数值、决策、状态机全是确定性规则。
---
## 技术栈
| 层 | 选型 |
|---|---|
| 服务 | NestJS 11 · TypeScript · SWC |
| 存储 | PostgreSQL(单一 OLTP,经 Prisma 6)· Redis |
| 任务 | BullMQ(persona / plan / asset 三队列)+ Bull-Board |
| 数仓读取 | ClickHouse(仅冷启 / 增量数据源) |
| AI | Vercel `ai` SDK + `@ai-sdk/{deepseek,google,openai-compatible}` |
| 对外 | REST(nestjs-zod 校验 + 统一信封)· MCP(Streamable-HTTP)· Scalar API 文档 |
| 实时 | socket.io / ws |
| 前端 | Next.js(App Router) |
---
## 关键不变量
这些是 PAC 区别于普通 CRUD 系统的硬约束,**代码层强制**:
- **事件只追加**:`patient_transactions` 只插不改不删,错了补一条修正事件。
- **事实版本流**:fact 新版 supersede 旧版,任一 subject 只有一个 active 版本。
- **单向数据流**:事实 → 画像 → 计划单向;执行结果 / AI 产物**不回写事实**(归 `plan_executions` / `agent_invocations`)。
- **证据链**:画像、计划、话术都带 `factIds / agentInvocationIds / ruleIds`,任何结论可回溯「为什么」。
- **水位幂等**:`event_seq` 单调 + 消费方 `eventWatermark`,断点续传、不重算。
- **规则为骨、AI 为肉**:主路径确定性,LLM 仅在生成自然语言时出场且必有兜底。
- **宿主差异只在 yaml**:接新宿主只写 `manifest + assembler yaml`(取数 / 形态 / 字段枚举翻译);标准化之后 —— fact 及其下游 —— 全集团一套代码、零 per-host 分支。
---
延伸:[数据摄入](/docs/architecture/data-ingestion) · [三层模型](/docs/architecture/three-layer-model) · [数据模型](/docs/architecture/data-model) · [接入](/docs/integration/overview) · [登录授权](/docs/integration/auth-login)
---
title: 三层模型
description: Patient Facts → Persona → Follow-up Plan 单向数据流的 schema 落地版。
icon: Workflow
---
> Patient Facts(事实)→ Persona(画像)→ Follow-up Plan(召回计划)
> 单向数据流 + 版本化推断 + 横切审计账本
---
```mermaid
flowchart TB
%% =========== 外部数据源 ===========
subgraph Sources["🌐 宿主数据源(异构)"]
direction LR
H1["5i5ya"]
H2["Friday"]
H3["400 中心"]
H4["DW 数仓"]
end
%% =========== 接入层 ===========
subgraph Ingest["📥 接入层(adapter)"]
direction LR
PULL["Pull adapter<br/>━━━━━━━━━━━<br/>周期拉资源<br/>比对前后态合成 transaction"]
PUSH["Push adapter<br/>━━━━━━━━━━━<br/>实时收事件<br/>HMAC 验签"]
end
%% =========== Layer 1 ===========
subgraph L1["📘 第一层 · 患者事实"]
direction TB
PT["<b>patient_transactions</b><br/>━━━━━━━━━━━━━━━━━━<br/>操作账本(append-only)<br/>source_event_id 幂等键<br/>event_seq BigInt 水位<br/>action(23)× subject_type(16)<br/>raw_payload 原文留底"]
PARSER{{"Parser<br/>解析 + canonical + 衍生"}}
PF["<b>patient_facts</b><br/>━━━━━━━━━━━━━━━━━━<br/>事实单元(版本流)<br/>subject_id × version<br/>kind: actual / planned<br/>type: 16 类 FactType<br/>status: active/superseded/<br/>cancelled/fulfilled/expired/invalidated<br/>transaction_ids 证据链"]
PAT["<b>patients</b><br/>━━━━━━━━━━<br/>主档(身份)<br/>tenant 级唯一<br/>mergedInto 自引用<br/>不物删,只软删 / 匿名"]
PT -->|"1 transaction<br/>→ 0/1/N fact 版本"| PARSER
PARSER --> PF
end
%% =========== Layer 2 ===========
subgraph L2["🟠 第二层 · Persona 画像"]
direction TB
PER["<b>personas</b><br/>━━━━━━━━━━━━━<br/>版本流 supersededAt<br/>eventWatermark = 已响应 event_seq<br/>(features 读 facts active 算)"]
PFEAT["<b>persona_features</b><br/>━━━━━━━━━━━━━<br/>key + description<br/>+ score? + data? + evidence{factIds...}<br/>33 候选 key · v1 实装 16(全规则)"]
PER --> PFEAT
end
%% =========== Layer 3 ===========
subgraph L3["🟣 第三层 · 召回计划"]
direction TB
FP["<b>followup_plans</b><br/>━━━━━━━━━━━━━━━━━━<br/>版本流 + target_clinic_id<br/>status: active/assigned/<br/>completed/abandoned/superseded<br/>partial UNIQUE(单一活跃)"]
PS["<b>plan_scripts</b><br/>━━━━━━━━━━━━<br/>per-plan 单行<br/>Markdown 多子段<br/>status: pending/ready/failed<br/>source: agent/template_fallback"]
PSU["<b>plan_summaries</b><br/>━━━━━━━━━━━━<br/>per-plan × type<br/>详情页 3 卡:recall_brief<br/>/ persona_tags / recall_history"]
PEX["<b>plan_executions</b><br/>━━━━━━━━━━━━<br/>executor_clinic_id<br/>outcome 强枚举驱动状态机"]
FP --> PS
FP --> PSU
FP -->|"客服执行"| PEX
end
%% =========== AI 调用通路 ===========
subgraph AICall["🤖 AI 调用通路 · AiCall 框架"]
direction LR
AIC["AiCall 框架<br/>━━━━━━━━━━━━<br/>PAC 内部(src/modules/ai)<br/>缓存 / 安全闸 / 计费 / 重试<br/>每次调用强制落 agent_invocations"]
LLM["LLM Provider<br/>━━━━━━━━━━<br/>DeepSeek / Qwen<br/>按 modelId 前缀路由<br/>(国内合规直连)"]
AIC --> LLM
end
%% =========== 横切 ===========
subgraph Cross["🔧 横切支撑(账本)"]
direction LR
AI["agent_invocations<br/>━━━━━━━━━━━━<br/>AI 调用审计账本<br/>每次调用强制落一条<br/>token / cost / replay<br/>modelProvider / modelName / callKey"]
SL["sync_logs<br/>━━━━━━━━━━<br/>同步运行账本<br/>cursor / reconcile"]
PRL["persona_recompute_logs<br/>━━━━━━━━━━━━━<br/>画像重算账本<br/>rule/agent/fallback 计数"]
PGL["plan_generation_logs<br/>━━━━━━━━━━━━<br/>召回生成账本<br/>scenarioRun / assets / agent"]
end
%% =========== 主数据流(实线) ===========
Sources --> Ingest
PULL --> PT
PUSH --> PT
PF -->|"读 active 版本<br/>跑 features 提取器"| PER
PF -->|"主输入"| FP
PER -.->|"可选增强<br/>(enhanced 模式)"| FP
%% =========== 反馈流(虚线,只回到 Plan 层) ===========
PEX -.->|"状态机反馈<br/><b>不回写 facts</b>"| FP
%% =========== AI 调用链(详情页按需 getOrGenerate → AiCall) ===========
PS -.->|"话术 script:stream<br/>(详情页按需)"| AIC
PSU -.->|"摘要 getOrGenerate<br/>(详情页按需)"| AIC
AIC -.->|"调用落账"| AI
%% =========== 编排账本关联(虚线) ===========
Ingest -.-> SL
PER -.-> PRL
FP -.-> PGL
%% =========== 颜色 ===========
style Sources fill:#fff3e0,stroke:#f57c00
style Ingest fill:#e0f7fa,stroke:#00838f
style L1 fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style L2 fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style L3 fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
style AICall fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
style Cross fill:#f5f5f5,stroke:#616161
style PT fill:#bbdefb
style PF fill:#bbdefb
style PAT fill:#bbdefb
style PER fill:#ffe0b2
style PFEAT fill:#ffe0b2
style FP fill:#e1bee7
style PS fill:#e1bee7
style PSU fill:#e1bee7
style PEX fill:#e1bee7
style AIC fill:#c8e6c9
style LLM fill:#c8e6c9
```
**图例**:实线 = 主数据流(单向);虚线 = 反馈 / 异步调用 / 编排关联
---
## 各层生成时机 & 策略
### 第一层 · 患者事实
| 子表 | 写入模式 | 触发 |
|---|---|---|
| `patient_transactions` | append-only | Pull cron(周期)/ Push 实时 / Manual 重放 |
| `patient_facts` | 版本流(append + supersede) | ① 摄入时 transaction 落库后立即调 parser 衍生;② **reparse 离线重衍**(见下) |
| `patients` | upsert | 主档拉取 / Push 首见 stub 创建 |
**两种事实生成路径**——同一套 `transforms → assembler → parser` 内核,只是数据入口不同:
| 路径 | 入口 | 连宿主? | 典型场景 |
|---|---|---|---|
| **摄入衍生** | Pull / Push 进来的新 transaction | 是(Pull) | 增量 / 存量首次落库 |
| **Reparse 重衍** | 已存 `transaction.raw_payload`(`reparse` CLI) | **否** | 改了 assembler yaml(enum/keyword/field_mapping/transforms)或 parser 后,按新口径重衍存量 fact |
### 第二层 · Persona 画像
```
事件驱动 ┐
定时兜底 ┼→ PersonaService.recompute(patientId)
人工救火 ┘ (核心函数,共享)
队列收敛 (BullMQ jobName=patient_id)
水位幂等:if MAX(event_seq) ≤ persona.eventWatermark → no-op(force 可跳过)
例外:无 persona(首算)/ 无 txn(写 baseline 版)→ 不 noop,照常跑
读 patient_facts status∈{active, fulfilled} → 跑 16 个规则 extractor
写新 persona + features(supersede 旧)
```
> **全规则**:`feature.registry.ts` 注册 16 个 extractor 全是规则路径 —— 价值分群(rfm)、年龄段(age_bracket)、性别(gender)、获客渠道(acquisition_channel)、家庭构成(family_structure)、转介绍达人(referral_champion)、生命周期(lifecycle_stage)、治疗史(treatment_history)、时间偏好(time_preference)、折扣锚点(discount_anchor)、特别关注(special_attention)、治疗敏感(treatment_sensitivity)、禁忌标签(contraindication)、潜在治疗(potential_treatment)、急迫等级(urgency_level)、权益身份(entitlement_status)。
| 触发源 | triggeredBy | 时机 | 实装 |
|---|---|---|---|
| txn 事件驱动 | `txn:<...>` | sync 写入 transaction 后即 enqueue(`pipeline-dispatcher`) | ✅ 主路径,秒级 |
| 手工 | `manual:<...>` | `StaleScanService.runNow()` / `recompute-persona` CLI(`--force`) | ✅ 运维 |
| 定时兜底 | `scheduled:<cronId>` | `stale-scan` cron 扫 `watermark < max(event_seq)` 的漏算患者 | ✅ 默认关(`PAC_STALE_SCAN_CRON`),建议每日 |
### 第三层 · 召回计划
计划重算**跟在画像重算之后**(事件驱动主路径),另有手工端点;定时全扫代码就绪但默认关。
```
主路径(事件驱动,单患者)
sync 写 transaction → persona-recompute
→ 成功(success/partial)即 enqueue plan-recompute
→ PlanEngine.recomputeForPatient(patientId) // 只算该患者,秒级跟上
手工 HTTP(无 body,从路径参数取):
POST /pac/v1/plans/:id/recompute // 按 plan 反查 patient 重算
POST /pac/v1/plans/recompute/by-patient/:patientId
定时全扫(批量,默认关):
PlanEngine.runAllForHost({ hostId, tenantId }) // 挂在增量同步 scheduler 尾,按 tenant 全扫
cron = env PAC_INCREMENTAL_CRON;未设 → '0 0 31 12 *'(never)→ 当前不跑
```
**重算时对已有 plan 的处置**(`recomputeForPatient` / `runAllForHost` 同一套决策,返回 `created/superseded/unchanged/suppressed/closed/none`):
```
0 信号级抑制:过滤掉命中"冷静期未到"集合的 hit('suppressed')
抑制集 = 该患者所有终态(completed/abandoned)且 snoozedUntil 未到期 plan 的 reason
(scenario, subKey) 并集;按信号粒度压,不是整患者压
→ 刚结案的 K08 在冷静期内不复召,但新长的 K02 照常召回
→ 过滤后 usableHits 全空 → 不生成('suppressed')
1 命中且 (scenario, subKey) 集合不变 → 仅刷新 priorityScore,不升版本('unchanged')
2 命中且集合变了 → supersede 旧版 + 建新版('superseded');
旧版若 assigned → 新版继承 assignee/assignedAt/
recycleAt/contactAttempts(不丢单、不被抢)
3 零命中(缺口全消失) → closeStaleActivePlan 关 plan,active / assigned 都关('closed')
* 每写出一个 plan → enqueue plan-asset-generate(话术/摘要;见下)
```
> assigned 的 plan 照常重算并在升版时继承分配(`assignee/contactAttempts` 带到新版),不丢单也不被抢。
### Plan Assets(话术 / 摘要)
话术和摘要是 AI 产物,**当前按需懒生成**,不在重算时自动批量产(省 token)。
```
自动路径(目前 stub):plan-recompute → enqueue plan-asset-generate
worker = no-AI stub,只 log "would generate",不调 LLM
(置 env PLAN_ASSET_AUTO_GENERATE=true 才启用自动生成)
真实生成 = 客服打开 plan 详情页时按需触发(getOrGenerate:已有直接取,没有才调 AI):
参考话术 GET /pac/v1/plans/:id/script:stream(SSE 流式)/ POST :id/script:regenerate
本次召回简报 GET /pac/v1/plans/:id/recall-brief
画像标签 GET /pac/v1/plans/:id/persona-summary
回访历史 GET /pac/v1/plans/:id/recall-summary
```
**当前实际产出的产物**:
| 产物 | SummaryType | 详情页卡片 | 编排器 · provider |
|---|---|---|---|
| 参考话术 | (PlanScript,无 type) | 参考话术(Markdown 多子段) | `plan-script` · DeepSeek |
| 摘要 | `recall_brief` | 本次召回简报(患者是谁 / 解决什么 / 邀约做什么) | `recall-brief` · Qwen |
| 摘要 | `persona_tags` | 画像标签(画像标签 + 取值一句话) | `persona-summary` · Qwen |
| 摘要 | `recall_history` | 回访历史(plan_executions 结构化数据归纳) | `recall-summary` · Qwen |
**AiCall 框架**:所有 LLM 调用收口,单一执行器。
- 流程:inputHash + prompt cache → 起 invocation(running)→ `generateObject`(model + zod schema + system + prompt)→ 安全闸校验 → 落 invocation(succeeded/failed/cached)+ 写缓存;JSON 解析失败重试 ≤2 次
- **多 provider 按 `modelId` 前缀路由**(`ai-provider.service.ts`):`deepseek-*`(话术)/ `qwen*`(三类摘要);切 provider 不动调用方
- 每次调用经 `invocation-recorder` 两段式落 `agent_invocations`(start=running → end 回填 `output` + token 明细 + `costYuan` + `ttftMs`/`latencyMs`),是 audit / replay / 成本核算唯一真理源
- 失败兜底:安全闸不过 / LLM 失败 → 模板兜底(`source='template_fallback'`);兜底也挂 → `status='failed'`(罕见)
### PlanExecution 回写
```
客服工作台 → POST /pac/v1/plans/{id}/executions (单次提交)
body: { channel, outcome, notes?, abandonReasons?, scheduledNextAt?, ... }
写一行 plan_executions
按 EXECUTION_OUTCOME_META[outcome].drivesStatus 驱动 Plan.status(单一真理源):
completed → 结案(success_appointed 等)
abandoned → 关闭(refused / external_treatment / declined_recent 等)
keep → 状态不变,等下次(no_answer / quick_hangup / scheduled_next 等)
按 suppressDays 写 snoozedUntil 抑制窗(终态抑制 / 冷静期;到期自愈)
触发熔断检查(contactAttempts 累积达上限 → 自动 abandoned,冷静期 30 天)
```
> `outcome` 闭集共 14 个(表单可见 7 个,其余为 `hiddenInForm` 历史值,仍可翻译旧记录)。前后端共用 `EXECUTION_OUTCOME_META`(group / label / `drivesStatus` / `suppressDays`),改一处处处生效。
---
## 横切支撑
| 表 | 职责 | 平行关系 |
|---|---|---|
| `agent_invocations` | 每次 AI 调用落账,token / cost / replay / 影像 AI 结果落点 | 跨所有层共享 |
| `sync_logs` | Pull / Push 运行账本,cursor 断点续传 / 对账 trigger | 接入层 |
| `persona_recompute_logs` | 重算执行账本,rule 计数 → harness 健康度(agent/fallback 计数位 v1 恒 0) | Layer 2 |
| `plan_generation_logs` | 召回生成账本,scenarioRun / assetsCreated / agent 计数 | Layer 3 |
三张 orchestration log **平行不混用**,每个 domain 一张账本,字段贴近自身语义。
---
## 七层防线(系统间通信可靠性)
| # | 防线 | schema 落地 |
|---|---|---|
| 1 | 幂等 | `patient_transactions.source_event_id` partial UNIQUE |
| 2 | 水位续传 | `sync_logs.cursor_before/after` |
| 3 | 原始报文留底 | `patient_transactions.raw_payload` |
| 4 | Reconcile 对账 | `sync_logs.triggered_by='reconcile:<date>'` |
| 5 | Quarantine | (v1 暂缓,SyncLog.failed + errorMessage 兜底) |
| 6 | Drift 监控 | `ContractDriftError`(字段契约失配 → `SyncLog.failed`)+ `sync_logs.metadata.suspectFields`(整列缺席预警)+ mapping-miss 面板(枚举长尾)。无正式 metric |
| 7 | 推断 stale | `personas.event_watermark` |
---
## 一句话归纳
**三层模型 = 把"宿主散乱事件" 升级成"集团统一画像" 再升级成"客服可执行的召回计划"**。
每层各自承担(账本留底 / 综合评价 / 行动指令),数据流单向 + 证据可追溯 + 版本化推断。
---
title: 主体标准化字段
description: PAC 各业务主体(subjectType)的标准化 canonical 字段全量参考。
icon: Table2
---
> PAC 对每类业务主体定义一套**标准化字段**。用途:
> - **Push 形态 C**:事件 `payload` 按对应主体填这些字段。
> - **Push 形态 A / 文件 / 直连**:你给原生列 + 数据字典,PAC 映射到这些字段。
>
> 字段命名 = PAC canonical(camelCase)。**未列出的字段照样可透传**(留底,不报错),只是 PAC 算法不识别。`action` / `subjectType` 取值见 [事件枚举](/docs/event-enums)。
>
> 真理源 = 各主体 parser 实际读取的字段(`apps/pac-service/.../pipeline/parsers/*.parser.ts`);摄入校验 schema(`canonical.ts`)只强校验身份三件套 + 少数关键字段,其余字段透传,以 parser 为准。
---
## 公共字段(所有主体)
| 字段 | 必填 | 说明 |
|---|---|---|
| `externalId` | ✅ | 本记录在你系统的 id(= 事件里的 `subjectId`)|
| `patientExternalId` | ✅ | 所属患者 id(`patient` 主档自身可省)|
| `clinicId` | ✅ | 诊所 id(`referral` / `patient` 主档可空)|
| 〈发生时间〉 | ✅ | 每个主体一个主时间字段,名字见下表(如 `scheduledAt` / `startedAt` / `paidAt`)|
> 另需 §1 的组织 id(`tenantId` / `sourceUnit`)。金额字段按接入配置的 `amountUnit` 原样发,PAC 归一;时间本地值即可。
下面**只列各主体专有字段**(公共字段不再重复)。除身份/时间外几乎都可选 —— 映射不上的留空,PAC 自然 null。
---
## 患者主档域
### `patient` 患者主档
| 字段 | 说明 |
|---|---|
| `name` / `phone` / `gender` / `birthDate` | 姓名 / 电话 / 性别 / 生日(`YYYY-MM-DD`)|
| `medicalRecordNumber` | 病历号(客服沟通用)|
| `status` | `active` / `archived`(默认 active)|
| `doNotContact` / `doNotContactReason` / `deceased` | 免打扰 / 离世标记(合规)|
| `tags` / `notes` | 标签数组 / 备注 |
| `acquisitionChannel` / `acquisitionSub` | 获客渠道 / 二级渠道 |
| `referralCount` / `referralAmount` | 转介绍人数 / 带来金额 |
| 主时间 | `createdAt` / `updatedAt` |
### `patient_relation` 患者关系
| 字段 | 必填 | 说明 |
|---|---|---|
| `relatedExternalId` | ✅ | 关系人(也是一个患者)|
| `relationship` | ✅ | `mother`/`father`/`spouse`/`child`/`sibling`/`friend`/`other` 等 |
---
## 主流程
### `consultation` 咨询 — 主时间 `occurredAt`
| 字段 | 说明 |
|---|---|
| `intentRaw` | 意向原文 —— PAC 解析出潜在治疗意向 / 类别 |
| `firstVisit` | 是否初诊(`1`/`0` 或布尔)|
| `doctorId` | 接诊 / 咨询医生 |
| `appointmentExternalId` | 关联预约 |
| `taskDirector` | 任务负责人 |
### `appointment` 预约 — 主时间 `scheduledAt`
| 字段 | 说明 |
|---|---|
| `status` | `scheduled`/`rescheduled`/`cancelled`/`arrived`/`in_treatment`/`completed`/`no_show`/`walk_in` |
| `appointmentType` | 预约类型 |
| `doctorId` | 医生 |
| `arrivedAt` | 到诊时刻(实际到诊填)|
| `durationMinutes` | 时长 |
| `complaintCategory` / `complaintText` | 主诉类别 / 主诉原文 |
| `cancellationReason` | 取消原因 |
| `staffNotes` | 客服 / 前台备注 |
### `visit_registration` 挂号 — 主时间 `occurredAt`
| 字段 | 说明 |
|---|---|
| `appointmentExternalId` | 关联预约 |
### `visit_reception` 接待登记 — 主时间 `occurredAt`
| 字段 | 说明 |
|---|---|
| `receptionistId` | 接待人 |
### `encounter` 医生接诊 — 主时间 `startedAt`
| 字段 | 说明 |
|---|---|
| `doctorId` / `doctorName` | 医生 / 医生名 |
| `encounterType` | 接诊类型 |
| `chiefComplaint` | 主诉 |
| `notes` | 接诊备注 |
> 诊断 / 治疗建议**单独发** `diagnosis` / `treatment` / `recommendation` 主体(老格式可在 encounter 里嵌 `diagnoses[]` / `treatments[]`,兼容)。
---
## 临床
### `diagnosis` 诊断 — 主时间 `occurredAt`
| 字段 | 说明 |
|---|---|
| `code` / `name` | 诊断码 / 原始诊断名(PAC 映射标准码)|
| `stdCodeRaw` | 你已标准化的码(有则优先,免 PAC 猜)|
| `codeSource` | 码来源声明(如 `image_ai`)|
| `toothPosition` | 牙位(如 `17;37`)|
| `severity` / `onsetDate` | 程度 / 起病日期 |
| `doctorId` / `doctorName` | 医生 / 医生名 |
| `sourceEncounterExternalId` | 反查源接诊 |
### `treatment` 治疗 — 主时间 `occurredAt`
| 字段 | 说明 |
|---|---|
| `category` / `subtype` | 治疗大类(种植 / 正畸 / 牙周…)/ 子类 |
| `toothPosition` | 牙位 |
| `status` | 实际治疗状态 |
| `startedAt` / `completedAt` | 开始 / 完成 |
| `quantity` / `unitName` | 数量 / 单位名 |
| `treatStages` | 治疗阶段数组 |
| `reviewWindowDays` | 复查窗口天数 |
| `doctorId` / `doctorName` | 医生 / 医生名 |
| `relatedDiagnosisExternalId` / `sourceEncounterExternalId` | 关联诊断 / 源接诊 |
> 计划 vs 实际由 `action` 区分(`treatment_planned` / `treatment_started` / `treatment_completed`)。
### `recommendation` 医生建议 — 主时间 `occurredAt`
| 字段 | 说明 |
|---|---|
| `code` / `name` | 建议码(如 `IMPLANT_RECOMMENDED`)/ 名称 |
| `toothPosition` | 牙位 |
| `windowDays` | 建议窗口天数 |
| `extractedBy` / `confidence` | 抽取来源(`rule`/`llm`/`doctor_input`)/ 置信度 |
| `doctorId` / `doctorName` | 医生 / 医生名 |
| `sourceEncounterExternalId` / `sourceFactSubjectId` | 源接诊 / 源记录 |
---
## 病历 / 影像
### `emr` 病历文档 — 主时间 `submittedAt`
| 字段 | 说明 |
|---|---|
| `doctorId` / `doctorName` | 医生 / 医生名 |
| 病历自由文本段(映射不上的留空) | `illnessDesc`(主诉/现病史)· `examFindings`(检查所见)· `disposal`(处置)· `doctorAdvice`(医嘱)· `diagnosisText` · `treatmentPlan` · `generalCondition`(一般情况)· `pastHistory`(既往史)· `preIllness` |
| `encounterExternalId` | 关联接诊 |
| `qualityStatus` / `rejectReason` | 质检状态 / 打回原因 |
### `image` 影像 — 主时间 `uploadedAt`
| 字段 | 说明 |
|---|---|
| `modality` | 影像类型(CBCT / 全景 / 根尖片…)|
| `finding` | 医生影像所见 |
| `toothPositions` | 牙位 |
| `doctorId` / `doctorName` | 医生 / 医生名 |
| `encounterExternalId` | 关联接诊 |
---
## 单据
### `order` 医嘱 / 收费单 — 主时间 `createdAt`
| 字段 | 必填 | 说明 |
|---|---|---|
| `items[]` | ✅ | 至少 1 项;每项 `{ name(必), amount(必,金额), type, treatmentCategory, currency }`,`type` = `treatment`/`consumable`/`medication` |
| `totalAmount` | ✅ | 总金额 |
| `status` | ✅ | `submitted`/`paid`/`partial_paid`/`cancelled` |
| `currency` | ⭕ | 币种(默认 CNY)|
### `payment` 收款 — 主时间 `paidAt`
| 字段 | 说明 |
|---|---|
| `amount` | 金额(必)|
| `method` | 支付方式 |
| `orderExternalId` / `encounterExternalId` | 关联医嘱 / 接诊 |
| `doctorId` | 医生 |
| `cardName` / `cardTypeName` | 储值/福利卡名 / 卡类型 |
| `discountCard` / `discountCompany` / `discountDept` | 卡 / 公司 / 科室三类折扣额(PAC 合计)|
| `insuranceName` | 医保 / 保险名 |
| `settlementProject` | 结算项目 |
| `currency` | 币种 |
### `refund` 退费 — 主时间 `refundedAt`
| 字段 | 说明 |
|---|---|
| `amount` | 金额(必)|
| `paymentExternalId` | 关联收款 |
| `reason` | 退费原因 |
---
## 产品收集
### `recharge` 充值 / 储值 — 主时间 `rechargedAt`
| 字段 | 说明 |
|---|---|
| `amount` | 金额(必)|
| `rechargeType` | 福利卡 / 储值卡 / 项目预存 |
| `currency` | 币种 |
| `benefits` | 福利内容(JSON,如赠送项目 / 抵扣比例)|
### `complaint` 投诉 — 主时间 `filedAt`
| 字段 | 说明 |
|---|---|
| `content` | 投诉内容自由文本 |
| `category` | 类别(service / clinical / billing…)|
| `status` | `open` / `resolved` / `pending`(默认 open)|
| `resolvedAt` | 结案时间 |
| `relatedTreatmentSubjectId` | 关联治疗 |
### `referral` 转介绍 — 主时间 `recordedAt`
> fact 挂在**被推荐人**(`patientExternalId` = referee);推荐人信息进字段快照。
| 字段 | 说明 |
|---|---|
| `referrerExternalId` / `referrerName` / `referrerChartNumber` | 推荐人 id / 姓名 / 病历号 |
| `referrerType` | `patient`(老带新)/ `staff` / `partner` / `other` |
| `channel` | `wechat` / `face_to_face` / `event` …(自定义)|
---
title: 部署
description: PAC 的服务架构、首次部署与常用运维命令。
icon: Rocket
---
一套 PAC 由四个服务组成 —— **Postgres + Redis + pac-service + pac-web**,容器化用 `docker compose` 一键拉起。数据摄入见 [数据摄入](/docs/ingestion),监控见 [监控](/docs/monitoring),故障处理见 [故障排查与灾备](/docs/troubleshooting)。
---
## 一、架构与组件
```
浏览器 / iframe
┌──────────── Server(2 核 8G 起步,建议 4 核 16G + SSD)────────────┐
│ │
│ ┌──────────┐ HTTP API ┌──────────────────────────────┐ │
│ │ pac-web │ ─────────────▶ │ pac-service :3101 │ │
│ │ :3100 │ │ NestJS · API + Cron + Worker │ │
│ │ Next.js │ └──────┬──────────────┬────────┘ │
│ └──────────┘ │ │ │
│ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ │
│ │ Postgres16 │ │ Redis 7 │ │
│ │ :5532 │ │ :6479 │ │
│ │ pac DB │ │ 队列 / 会话│ │
│ └────────────┘ └────────────┘ │
└─────────────────────────────────────┬─────────────────────────────┘
│ pac-service 只读拉取
外部数据源
```
| 组件 | 端口 | 用途 |
|---|---|---|
| pac-web | 3100 | 前端(Next.js) |
| pac-service | 3101 | API + Cron + BullMQ Worker |
| Postgres 16 | 5532 | PAC 主数据(patients / facts / personas / plans / sync_logs) |
| Redis 7 | 6479 | BullMQ 队列 + 会话 |
---
## 二、首次部署
### 2.1 环境准备
```bash
docker --version # >= 24
docker compose version
# 若不用容器、裸机跑 pac-service:
node --version # >= 20
pnpm --version # >= 9
```
### 2.2 拉代码与配置 env
```bash
# 安装目录任选,下文命令统一用 $PAC_HOME 指代(示例 /opt/pac)
export PAC_HOME=/opt/pac
git clone <repo> $PAC_HOME && cd $PAC_HOME
cp apps/pac-service/.env.example apps/pac-service/.env
```
必填项(`apps/pac-service/.env`):
```ini
# ─── 基础 ───
NODE_ENV=production
PORT=3101
LOG_LEVEL=info
DATABASE_URL=postgresql://pac:<pwd>@localhost:5532/pac?schema=public
REDIS_URL=redis://localhost:6479
JWT_SECRET=<openssl rand -hex 32>
JWT_REFRESH_SECRET=<openssl rand -hex 32>
CORS_ORIGINS=https://<pac-web-domain>
# ─── 数据源连接(只读;按 host 的 manifest 配置,env 非空即覆盖 manifest)───
DW_CLICKHOUSE_URL=<数据源地址>
DW_CLICKHOUSE_DATABASE=<库名>
DW_CLICKHOUSE_USERNAME=<只读账号>
DW_CLICKHOUSE_PASSWORD=<密码> # env 名须等于 manifest 的 password_env
# ─── AI(DeepSeek)───
DEEPSEEK_API_KEY=<key>
DEEPSEEK_BASE_URL=https://api.deepseek.com
AI_DEFAULT_MODEL=deepseek-v4-flash
# ─── 定时任务 ───
# 注:增量 / 滞后监控 / 兜底扫描的代码默认是"永不"哨兵,必须显式设 env 才会调度
PAC_INCREMENTAL_CRON=15 8 * * * # 须设在数据源当日刷新之后(见 数据摄入);太早跑会拉到旧数据
PAC_INCREMENTAL_HOSTS=<host> # 多个用逗号分隔
PAC_INCREMENTAL_DATA_DIR=<安装目录>/apps/pac-service/data # 配置文件里写绝对路径,即 $PAC_HOME/apps/pac-service/data
PAC_INCREMENTAL_LOOKBACK_HOURS=48 # 游标下界回看重叠窗(默认 48h),靠 source_event_id 去重;0=严格 > cursor
PAC_STALE_SCAN_CRON=0 2 * * * # 兜底:每日扫"画像落后"患者补入重算队列
# ─── 资源调优(可选,不设走默认)───
PAC_COHORT_BATCH_SIZE=5000 # 按患者分批(控内存);0 = 不分批
PAC_COHORT_CONCURRENCY=1 # cohort 并发;1=串行(稳),3-4=提高吞吐(连接池自动放大)
PAC_WRITE_BATCH_SIZE=1000 # 每批写入行数;0 = 逐行
# ─── 监控 / 告警 ───
PAC_LAG_MONITOR_CRON=0 * * * * # 数据源滞后监控(每小时)
PAC_LAG_WARN_HOURS=24 # 超 24h → warning
PAC_LAG_ERROR_HOURS=48 # 超 48h → critical
PAC_HEALTH_REPORT_CRON=0 9 * * * # 每日健康日报(代码默认即 09:00)
ALERT_WEBHOOK_URL=<企微/钉钉/slack 机器人 webhook> # 告警与日报的推送通道;留空则仅落 log
```
> **Docker Compose 部署务必带 `--env-file apps/pac-service/.env`**。
> compose 对 `${POSTGRES_PASSWORD}` 等变量的插值只从 *项目根 .env / shell* 读,**不读** service 的 `env_file:`;漏带会让密码插值成默认值导致连库认证失败。
### 2.3 启动
**A. Docker Compose(推荐,生产实际用法)**
```bash
cd $PAC_HOME
# 代码更新后,pac-migrate 与 pac-service 是两个独立镜像,都要 rebuild
docker compose --env-file apps/pac-service/.env -f docker-compose.prod.yml build pac-migrate pac-service
docker compose --env-file apps/pac-service/.env -f docker-compose.prod.yml up -d
# pac-migrate(restart:no)自动跑 prisma migrate deploy 后退出,pac-service 依赖它成功
# 验证迁移:docker compose ... logs pac-migrate | grep "migrations have been applied"
```
**B. 裸机 / systemd**
```bash
cd $PAC_HOME
pnpm install --frozen-lockfile
pnpm --filter @pac/types build
pnpm --filter @pac/service prisma:deploy # = prisma migrate deploy
pnpm --filter @pac/service prisma:seed # 写入 demo / 示例 host 行
pnpm --filter @pac/service build # 编译 NestJS
pnpm --filter @pac/web build # Next.js prod build
# 起 postgres + redis(若未用托管实例)
docker run -d --name pac-postgres -p 5532:5432 \
-e POSTGRES_USER=pac -e POSTGRES_PASSWORD=<pwd> -e POSTGRES_DB=pac \
-v pac-pg-data:/var/lib/postgresql/data postgres:16-alpine
docker run -d --name pac-redis -p 6479:6379 \
-v pac-redis-data:/data redis:7-alpine
# 起服务(交给 systemd / pm2 托管)
cd apps/pac-service && node dist/main.js & # = package.json 的 start 脚本
cd apps/pac-web && pnpm start &
```
### 2.4 部署后验证
启动后用健康检查端点确认存活:
```bash
curl -s http://localhost:3101/health # → {"status":"ok","timestamp":"..."}
```
`GET /health` 免鉴权、且**不走** `pac/v1` 全局前缀,适合做负载均衡探针 / 容器 healthcheck / 外部拨测,返回 `{ "status": "ok", "timestamp": "<ISO>" }`。(同样豁免前缀的还有 Swagger `/api/docs`、Bull Board `/admin/queues`。)
随后即可首次导入数据 —— 见 [数据摄入](/docs/ingestion)。
---
## 三、常用运维命令
| 场景 | 命令 |
|---|---|
| 重启 pac-service | `systemctl restart pac-service`(或 `pm2 reload pac-service`) |
| 手动补跑增量 | `pnpm sync-incremental -- --dir=./data/<host>` |
| 某患者召回不对,临时单刷 | `pnpm recompute-plans -- --host=<host>`(全 host),或详情页"刷新"按钮(单患者) |
| 改 scenario SQL 后重算 plan | `pnpm recompute-plans -- --host=<host>`(facts 不变,无需 reparse) |
| 改 yaml 字典后补存量 fact | `pnpm reparse -- --host=<host> --subject-type=<canonical>`(见 [数据摄入 · 配置变更](/docs/ingestion)) |
---
title: 设计系统 · DESIGN.md
description: PAC 前端的设计系统(目标态),按 google-labs design.md 格式编写 —— 三层 token,给工程师与编码 agent 一致的视觉依据。
icon: Palette
---
这是 PAC 前端(Next.js + Tailwind v4 + shadcn/ui + Radix)的**设计系统规范**,按 [google-labs `design.md`](https://github.com/google-labs-code/design.md) 格式写:**token 给精确值,prose 给"为什么 / 怎么用"** —— 双受众,既给工程师,也给以后生成 PAC UI 的编码 agent 当持久依据。
token 分**三层**:原始(L1)→ 语义(L2)→ 组件(L3),层层指向。**这是目标态** —— 当前 `globals.css` 是 shadcn 两层 HSL,本文件的三层 oklch 是 CSS 应收敛到的方向;语义层 CSS 变量名(`--primary` / `--muted` / `--border`…)与现有 shadcn 命名一致,组件无需改。下面 YAML 可整段拷成 `apps/pac-web/DESIGN.md` 交给 `npx @google/design.md lint`(原始层用 oklch,官方 CLI 据此跑 WCAG)。
```yaml
version: alpha
name: PAC Workbench
description: 临床召回客服工作台 —— 嵌进宿主系统的克制仪器 · 设计版本 0.1.0。三层 token(原始/语义/组件);原始层 1:1 采用 Tailwind v4 oklch。
colors:
# ============================================================
# L1 · 原始层 PRIMITIVE — 纯色阶,无语义。值 = Tailwind v4 oklch(1:1)
# ============================================================
# 品牌 teal
teal-50: "oklch(98.4% 0.014 180.72)"
teal-500: "oklch(70.4% 0.14 182.503)"
teal-600: "oklch(60% 0.118 184.704)"
teal-700: "oklch(51.1% 0.096 186.391)"
# 中性 slate(承载约 90% 界面)
slate-50: "oklch(98.4% 0.003 247.858)"
slate-100: "oklch(96.8% 0.007 247.896)"
slate-200: "oklch(92.9% 0.013 255.508)"
slate-300: "oklch(86.9% 0.022 252.894)"
slate-400: "oklch(70.4% 0.04 256.788)"
slate-500: "oklch(55.4% 0.046 257.417)"
slate-600: "oklch(44.6% 0.043 257.281)"
slate-700: "oklch(37.2% 0.044 257.287)"
slate-800: "oklch(27.9% 0.041 260.031)"
slate-900: "oklch(20.8% 0.042 265.755)"
slate-950: "oklch(12.9% 0.042 264.695)"
# AI 强调 indigo
indigo-50: "oklch(96.2% 0.018 272.314)"
indigo-500: "oklch(58.5% 0.233 277.117)"
# 语义原料(仅纳入用到的档)
emerald-50: "oklch(97.9% 0.021 166.113)"
emerald-600: "oklch(59.6% 0.145 163.225)"
emerald-700: "oklch(50.8% 0.118 165.612)"
amber-50: "oklch(98.7% 0.022 95.277)"
amber-600: "oklch(66.6% 0.179 58.318)"
amber-700: "oklch(55.5% 0.163 48.998)"
rose-50: "oklch(96.9% 0.015 12.422)"
rose-600: "oklch(58.6% 0.253 17.585)"
rose-700: "oklch(51.4% 0.222 16.935)"
sky-50: "oklch(97.7% 0.013 236.62)"
sky-600: "oklch(58.8% 0.158 241.966)"
violet-500: "oklch(60.6% 0.25 292.717)"
white: "#FFFFFF"
# ============================================================
# L2 · 语义层 SEMANTIC — 角色映射,引用原始层。组件唯一入口
# ============================================================
primary: "{colors.teal-600}" # 唯一交互驱动色
primary-hover: "{colors.teal-700}"
primary-subtle: "{colors.teal-50}" # 标签底 / 高亮区
on-primary: "{colors.white}"
ring: "{colors.teal-600}" # focus ring 同 primary
accent-ai: "{colors.indigo-500}" # 只标 AI 产物 / 画像
accent-ai-subtle: "{colors.indigo-50}"
# 中性骨架(与宿主同频)
background: "{colors.white}" # 页面底(border-led,不用灰底)
surface: "{colors.white}" # 卡片 / 表格面
surface-muted: "{colors.slate-100}" # 表头 / 斑马纹 / 二级数据条
on-surface: "{colors.slate-900}" # 正文
on-surface-muted: "{colors.slate-500}" # 次要文字 / 元数据(对白底 4.76,别再调浅)
border: "{colors.slate-300}" # 故意比常规深一档(border-led,~87% L)
border-strong: "{colors.slate-400}" # 输入框 / 需更明确分隔处
# 语义状态(只在状态反馈时出现)
success: "{colors.emerald-600}"
warning: "{colors.amber-600}"
danger: "{colors.rose-600}"
on-danger: "{colors.white}"
info: "{colors.sky-600}"
# 召回生命周期(plan/recall 状态机的视觉投影)— 各配 -surface 浅底做徽章
status-progress: "{colors.sky-600}" # active / 已联系
status-progress-surface: "{colors.sky-50}"
status-pending: "{colors.amber-600}" # 待跟进 / 约定回访
status-pending-surface: "{colors.amber-50}"
status-won: "{colors.emerald-600}" # completed / 成单
status-won-surface: "{colors.emerald-50}"
status-closed: "{colors.slate-400}" # abandoned / 流失
status-closed-surface: "{colors.slate-100}"
# 渠道色
channel-phone: "{colors.sky-600}"
channel-wecom: "{colors.emerald-600}"
channel-sms: "{colors.violet-500}"
channel-other: "{colors.slate-500}"
typography:
# sans = 系统中文栈(不引外部 webfont);mono = 系统等宽栈
h1:
fontFamily: "PingFang SC, Noto Sans CJK SC, ui-sans-serif, system-ui, -apple-system, sans-serif"
fontSize: 1.5rem
fontWeight: 600
lineHeight: 1.2
letterSpacing: -0.01em
h2:
fontFamily: "PingFang SC, Noto Sans CJK SC, ui-sans-serif, system-ui, -apple-system, sans-serif"
fontSize: 1.25rem
fontWeight: 600
lineHeight: 1.3
body-md:
fontFamily: "PingFang SC, Noto Sans CJK SC, ui-sans-serif, system-ui, -apple-system, sans-serif"
fontSize: 1rem
fontWeight: 400
lineHeight: 1.6
label:
fontFamily: "PingFang SC, Noto Sans CJK SC, ui-sans-serif, system-ui, -apple-system, sans-serif"
fontSize: 0.875rem
fontWeight: 500
lineHeight: 1.4
label-caps: # 技术数据 / ID / 时间戳 —— 系统等宽
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
fontSize: 0.75rem
fontWeight: 500
lineHeight: 1
letterSpacing: 0.04em
data-num: # 表格等宽数字,纵向对齐不跳动
fontFamily: "PingFang SC, ui-sans-serif, system-ui, sans-serif"
fontSize: 0.875rem
fontWeight: 400
lineHeight: 1.4
fontFeature: "tnum"
rounded:
sm: 4px
md: 6px
lg: 8px # --radius base
full: 9999px # chip / 徽章 / 头像
spacing:
xs: 4px
sm: 8px
md: 12px # 列表 gap-3
lg: 16px # 卡片内边距 mobile p-4
xl: 20px # 卡片内边距 desktop p-5
"2xl": 24px
touch: 44px # 最小触控目标
components:
# ============================================================
# L3 · 组件层 COMPONENT — 引用语义层。框架无关规格;
# Web 端实现以 shadcn/ui + Radix 为准(变体消费语义 token,如 bg-primary)
# ============================================================
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
rounded: "{rounded.md}"
padding: "8px 16px"
button-primary-hover:
backgroundColor: "{colors.primary-hover}"
button-secondary:
backgroundColor: "{colors.surface}"
textColor: "{colors.on-surface}"
rounded: "{rounded.md}"
padding: "8px 16px"
button-danger:
backgroundColor: "{colors.danger}"
textColor: "{colors.on-danger}"
rounded: "{rounded.md}"
padding: "8px 16px"
input:
backgroundColor: "{colors.surface}"
textColor: "{colors.on-surface}"
rounded: "{rounded.md}"
padding: "8px 12px"
card:
backgroundColor: "{colors.surface}"
rounded: "{rounded.lg}"
padding: "{spacing.lg}"
reason-chip:
backgroundColor: "{colors.primary-subtle}"
textColor: "{colors.primary}"
rounded: "{rounded.full}"
typography: "{typography.label}"
padding: "4px 10px"
badge-won:
backgroundColor: "{colors.status-won-surface}"
textColor: "{colors.status-won}"
rounded: "{rounded.full}"
padding: "4px 10px"
badge-pending:
backgroundColor: "{colors.status-pending-surface}"
textColor: "{colors.status-pending}"
rounded: "{rounded.full}"
padding: "4px 10px"
```
## Overview
> 一句话:**把 Anthropic / Linear 那种控制台的克制,搬进诊室。**
PAC 工作台读起来像一台**嵌进宿主系统的临床仪器**:哑光、近乎无投影,靠**实线边框**而非阴影来分区;一块克制的 **teal** 负责所有"该点这里",其余颜色只在表达状态时出现;数字像电子病历一样**等宽对齐**;它礼貌地嵌在宿主页面里,不抢宿主风头,但一眼能认出是 PAC。
它服务的人是**边打电话边看边记的客服**:信息密度高、单手可达、主操作(CTA)显眼,**移动优先**。它的反面 —— 我们不做的 —— 是花哨的营销落地页、彩虹色仪表盘、卡片堆叠重投影的拟物风。
> **铁律**:单一 **teal** 驱动全部交互,中性 **slate** 承载界面主体,**indigo** 只标 AI 产物,语义色只在状态反馈时出现。**颜色稀缺,所以颜色有意义。**
## Colors
调色板 = 高对比中性 + 单一品牌色 + 语义反馈,经**三层** token 暴露给组件。色值用 oklch(与 Tailwind v4 同源,可上广色域)。
**层与纪律**:原始层(`teal-*` / `slate-*`,纯色阶)→ 语义层(`primary` / `surface` / `status-*`,角色)→ 组件层。**组件只准引语义层** —— 代码里不写裸色值、不直引原始层(不写 `bg-teal-600`,写 `bg-primary`)。状态语义变了只改语义 token,组件不动。
- **`primary`(→ teal-600):** 唯一交互驱动色。每屏只用于**单个最重要的操作** + 选中 / 链接 / 焦点环。`primary-subtle`(teal-50)做标签底。
- **中性(→ slate):** 承载约 90% 界面。`background` 白底页面(border-led,不铺灰底),`surface` 卡片面,`surface-muted` 表头 / 斑马纹,`border` 比常规深一档(`slate-300`)—— 因为我们靠边框而非阴影建立层次。
- **`accent-ai`(→ indigo-500):** 第二根色轴,**只标 AI 产物与患者画像**,跟行动色 teal 分开"这是系统给的判断"。
- **语义 / 状态色:** `success`/`warning`/`danger`/`info` 仅状态反馈,不作大面积填充;`status-*`(progress/pending/won/closed)是召回状态机的投影,各配 `-surface` 浅底做徽章。
### 可达性(WCAG)
实算的关键对比度(目标:正文 4.5:1,大文本 / UI 3:1):
| 前景 / 背景 | 比值 | 等级 | 用法 |
|---|---|---|---|
| 白字 / `primary` teal-600 | **3.7:1** | AA-large | ⚠️ teal 偏浅 —— 正文白字改用 `primary-hover`(teal-700,**5.4:1** 过 AA);teal-600 填色只配 ≥大字 / 半粗 |
| 白字 / `danger` rose-600 | 4.7:1 | AA | 可承载正文白字 |
| 白字 / `success` emerald-600 | 3.8:1 | AA-large | 填色白字须放大加粗,或改深字 / 降 -700 |
| 白字 / `warning` amber-600 | 3.2:1 | AA-large | 同上(amber 尤甚,建议直接用深字) |
| 白字 / `info` sky-600 | 4.1:1 | AA-large | 同上 |
| `on-surface` slate-900 / 白 | ~16:1 | AAA | 正文 |
| `on-surface-muted` slate-500 / 白 | 4.76:1 | AA | 次要文字下限,**别再调浅** |
徽章为 600 字 + 50 底,属 AA-large —— 文字须 ≥13px 半粗,使其按"大文本"达标。
## Typography
- **系统字体,不引外部 webfont**:中文 PingFang SC,系统栈兜底。原因有二 —— iframe 约定要求走宿主 system 字体链;且 PAC 部署网络 Google Fonts 不可达(`next/font/google` 会挂)。所以**不学需要 bundle webfont 的方案**。
- **`label-caps` 用系统等宽栈**(`ui-monospace`…)承载技术数据 / ID / 时间戳 —— 不依赖 JetBrains Mono 等 webfont。
- **`data-num` 启用 `tabular-nums`**:金额、时间窗、计数对齐成列,扫读不跳。
- 字号阶梯(移动)**12 / 14 / 16 / 20 / 24**,标题最大 24;单屏字重不超过两种。
## Layout & Spacing
- 基于 **4px 网格**,间距取自 `spacing`(4/8/12/16/20/24)。后台密度优先:行距紧凑,留白用于分隔信息块而非营造氛围。
- 卡片内边距:移动 `p-4`(16)/ 桌面 `p-5`(20);列表项 `gap-3`(12)。
- **触控目标最小 44×44px**,客服单手在手机上操作。默认 mobile,`sm/md/lg` 渐进增强。
## Elevation & Depth
层次靠**边框 + 留白**,不靠阴影 —— 这是 PAC 与"无边框/重阴影"风格的明确分野。阴影整体压到 `0.03–0.06` 透明度(对照 shadcn / Tailwind 默认 `0.05–0.10`),**只用于真正"浮起"的层**:
| token | 用途 |
|---|---|
| `--shadow-sm` `0 1px 2px /0.04` | 卡片默认(几乎不可见)|
| `--shadow-md` | hover 微抬 |
| `--shadow-lg` / `--shadow-xl` | popover / dialog 浮起 |
口诀:**平面上用边框,浮起来才用影。**
## Shapes
| 形 | 值 |
|---|---|
| 圆角 base(卡片 / 面板)| `rounded.lg` 8px |
| 输入 / 按钮 | `rounded.md` 6px |
| chip / 徽章 / 头像 | `rounded.full` |
全局只用一种圆角语言 —— 不在同一视图混用直角与大圆角。不用大圆角(消费级语气),也不用直角(太硬)。
## Components
原子组件 token 见 front matter 的 L3。Web 端实现以 **shadcn/ui + Radix** 为准:变体消费语义 token(`bg-primary` 等),与本文件语义层对齐。PAC 特有的**业务组件**:
| 组件 | 用途 | 优先级 |
|---|---|---|
| `<PlanCard>` | 召回任务列表项 | P0 |
| `<PersonaCard>` | 患者画像快读 | P0 |
| `<ReasonChip>` | scenario 标签 | P0 |
| `<PriorityBar>` | 三段渐变优先级 | P0 |
| `<ChannelIcon>` | 渠道图标 | P0 |
| `<TreatmentChainTimeline>` | 治疗链横向时间轴 ⭐ 差异化 | P0 |
| `<OutcomeDialog>` | 执行回写(强枚举)| P0 |
| `<ScriptViewer>` | Markdown 话术(开场/跟进/异议折叠)| P1 |
| `<EvidenceDrawer>` | factIds → transaction 原文 | P1 |
复用的轻量视觉模式走 Tailwind v4 `@utility`(`globals.css`):`row-quiet`(二级数据条)、`surface-inset`(卡内分组框)、`nums`(等宽数字)。
## Motion
**基线克制**:通用过渡 ease-out **150ms**,状态切换 / 列表插入 pulse **300ms**;禁 bounce / 大幅 scale / 旋转。
**唯一的例外是 AI 场景** —— 这层动效是有意为之的"活气",只绑定特定功能,别处不许用:
- `shimmer-text` —— AI 流式生成时标题扫光(GPT / Claude Code 风)。
- 实时教练:`audioWave`(语音波形)、`coachPulse`(录音呼吸光圈)、`lyricIn`(字幕当前句滑入)。
## 信息密度纪律
> 视觉焦点**只放决策必需**的信息,其余收进抽屉 / 折叠 / 二级页。
| 层级 | 形态 | 内容 |
|---|---|---|
| 焦点 | always-on | 身份 / 优先级 / 召回理由摘要 / CTA |
| 次级 | accordion / tab | reason 证据 / 治疗链时间轴 / AI 产物 |
| 详情 | 抽屉 / 全屏 modal | 完整 transaction timeline / EMR 原文 / 影像 |
| 调试 | 角色门控隐藏 | rawPayload / token / fact version 历史 |
不强制卡片结构,但**强制信息分层** —— 每个页面都要明确"什么进焦点、什么进抽屉"。
## 嵌入约束(iframe)
PAC UI 以 iframe 嵌入多个宿主,所以:
- 容器用 `bg-background` token,**不假设白底**。
- **不用 fixed / absolute 全屏遮罩**(会越出 iframe 边界)。
- **不引外部字体**,走宿主 system 字体链(另见 Typography)。
- 跳转链接 `target="_top"`(deep-link 跳回宿主原页)。
## Do's & Don'ts
**Do**
- 用**边框 + 留白**分区;阴影只给真正浮起的层(popover / dialog)。
- 一块 **teal** 管所有主操作 / 选中;AI 产物用 indigo;状态才上语义色。
- 组件一律经**语义 token**(`bg-primary`),状态语义变了只改语义层。
- 金额 / 时间 / 计数一律 **`data-num`(tabular-nums)** 对齐;数字列尤其。
- 维持 WCAG AA:正文 4.5:1、大文本 / UI 3:1;徽章文字 ≥13px 半粗。
- 触控目标 ≥ **44px**,移动优先;焦点区只放决策必需信息。
**Don't**
- 不在代码里写裸色值,或直引原始层(不写 `bg-teal-600`)。
- 不在 `success`/`warning`/`info` 填色上放白色正文(仅 AA-large)—— 改深字或降 -700;teal-600 填色配正文白字同理,改用 teal-700。
- 不堆重投影、不用投影替代边框;不彩虹、不让多个品牌色争注意力。
- 不引外部字体、不假设白底(iframe 会翻车)。
- 不 bounce / 大幅 scale / 旋转 —— **AI 流式与实时教练的专属动效除外**(见 Motion)。
- 单屏不超过两种字重;不在焦点区堆调试信息(角色门控隐藏)。
---
title: 响应信封与错误码
description: 所有对外 API 共用的响应信封结构与全量错误码速查。
icon: ListChecks
---
> 所有 PAC 对外 API(及宿主需实现的资源 endpoint / push 入口)**共用同一套响应信封与错误码**。本页是全局约定速查,与下方各 endpoint 的 OpenAPI 参考互补。真理源:`packages/types/src/schemas/wrap.ts`(`ApiCode`)。
---
## 响应信封
**HTTP 状态码恒为 `200`** —— `5xx` 只留给真崩溃(网关 / 服务挂),便于 LB / k8s 探针识别死实例。业务结果一律看信封里的 `code`。
```jsonc
// 成功
{ "code": 0, "msg": "ok", "data": { /* ... */ } }
// 失败
{ "code": 10002, "msg": "请求字段校验失败", "data": null,
"details": [ /* 可选,结构化诊断,如哪个字段不过 */ ] }
```
| 字段 | 说明 |
|---|---|
| `code` | `0` = 成功;非 0 = 错误(见下表) |
| `msg` | 一句话给人看 |
| `data` | 成功负载;失败为 `null` |
| `details` | 可选,失败时的结构化诊断(数组) |
> 例外:SSE 流式端点与 `/health` 不套信封。
---
## 编码规则(5 位 `A-BB-CC`)
| 段 | 含义 |
|---|---|
| **A** | 来源:`0` 成功 / `1` 客户端错 / `2` 业务状态错 / `3` 上游(宿主 / AI / 数仓)/ `9` PAC 内部 |
| **BB** | 模块:`00` 通用 · `01` 鉴权 · `02` 平台 · `03` 患者 · `04` 画像 · `05` 计划 · `06` 执行 · `07` 事实 · `08` 同步 · `09` agent · `10` 租户 |
| **CC** | 模块内序号 |
---
## 错误码速查
### 1xxxx · 客户端错误
| code | 含义 |
|---|---|
| `0` | 成功 |
| `10001` | 请求格式错误 |
| `10002` | 请求字段校验失败(看 `details`) |
| `10003` | 请求过于频繁(限流) |
| `10004` | 资源不存在(通用) |
| `10101` | appId 或 appSecret 不正确 |
| `10102` | 一次性 code 无效或已过期 |
| `10103` | refresh token 无效 |
| `10104` | 未携带 access token |
| `10105` | access token 已过期 |
| `10106` | access token 解析失败 |
| `10107` | 权限不足 |
| `10201` | 宿主不存在 |
| `10202` | 宿主已存在 |
| `10301` | 患者不存在 |
| `10401` | 画像不存在 |
| `10501` | 计划不存在 |
### 2xxxx · 业务 / 状态错误
| code | 含义 |
|---|---|
| `20501` | 计划已被分配 |
| `20502` | 计划已完成,不能再修改 |
| `20503` | 计划联系次数超限,已熔断 |
### 3xxxx · 上游 / 第三方
| code | 含义 |
|---|---|
| `30801` | 宿主接口不可达 |
| `30802` | 宿主字段映射漂移 |
| `30803` | 宿主登录失败 / 凭据失效 |
| `30804` | 该平台不支持单患者即时刷新 |
| `30901` | AI 网关响应超时 |
| `30902` | AI 网关返回错误 |
### 9xxxx · PAC 内部
| code | 含义 |
|---|---|
| `90000` | 服务内部错误 |
| `90001` | 数据库错误 |
---
title: 事件枚举(Action / SubjectType)
description: push 事件的 subjectType 与 action 全量封闭集速查。
icon: ListTree
---
> Push **形态 C** 事件的 `subjectType` + `action` 取值必须落在下表封闭集内(`subjectType` 16 个、`action` 23 个)。真理源:`packages/types`(`SubjectType` / `Action`)。
>
> 形态 A / 文件 / 直连由 PAC 内部归一,宿主无需关心本表。
---
## 全量速查(按 `subjectType` 分组)
每个 `subjectType` 对应一组 `action`;同一 `subjectType` 的不同 `action` = 同一类对象的不同业务时机。
| `subjectType` | `action` | 业务时机 |
|---|---|---|
| `consultation` 咨询 | `consultation_created` | 咨询创建 |
| `appointment` 预约 | `appointment_created` | 预约创建 |
| | `appointment_changed` | 预约变更 |
| | `appointment_cancelled` | 预约取消 |
| | `appointment_fulfilled` | 患者到诊(预约履约) |
| `visit_registration` 挂号 | `visit_registered` | 挂号 |
| `visit_reception` 接待登记 | `reception_completed` | 前台接待完成 |
| `encounter` 医生接诊 | `doctor_encounter_started` | 医生接诊开始(只元数据) |
| `diagnosis` 诊断 | `diagnosis_recorded` | 诊断记录 |
| `treatment` 治疗 | `treatment_planned` | 治疗计划录入 |
| | `treatment_started` | 治疗开始 |
| | `treatment_completed` | 治疗完成 |
| `recommendation` 医生建议 | `recommendation_extracted` | 医生建议(抽出 / 规则识别) |
| `emr` 病历文档 | `emr_submitted` | 病历提交 |
| | `emr_rejected` | 病历打回 |
| `order` 医嘱 / 收费单 | `order_submitted` | 收费单 / 医嘱提交 |
| `payment` 收款 | `payment_received` | 收款 |
| `refund` 退费 | `refund_created` | 退费 |
| `image` 影像资料 | `image_uploaded` | 影像上传 |
| `recharge` 充值 / 储值 | `recharge_received` | 充值 / 储值 / 办卡入账 |
| `complaint` 投诉 | `complaint_filed` | 投诉提交 |
| | `complaint_resolved` | 投诉结案 |
| `referral` 转介绍 | `referral_recorded` | 转介绍记录 |
---
## 说明
- `subjectType` / `action` 都是**封闭集** —— 宿主侧 resource 不允许造新名,必须归一化到上表;不认识的值按校验失败(`10002`)拒绝。
- `撤回 / 删除`:对应记录重发,`action` 用同类的 `*_cancelled`(目前枚举内仅 `appointment_cancelled`;其它资源的撤回语义后续补)。详见 [Push 通道 §6](/docs/integration/channel-push)。
- `treatment` 的"计划 vs 实际"由 PAC 内部按 fact 区分,宿主只发对应 `action`(`treatment_planned` / `treatment_started` / `treatment_completed`)。
---
title: 数据摄入
description: PAC 的数据摄入(首次全量 / 日级增量)、数据完整性、资源调优与配置变更。
icon: Import
---
`pnpm sync` 是统一入口:首跑(游标为空)等价全量,之后增量。`pnpm cold-import` 保留为"忽略游标强制全量"的旧别名,日常一律用 `pnpm sync`。
> 命令里 `<host>` 指一个 host 标识,对应配置目录 `apps/pac-service/data/<host>/`。
---
## 一、首次全量
**用途**:首次部署 / 重建库 / 配置大版本变更。
**机制**:游标为空即等价全量,**按患者 cohort 分批** —— 每批 load → transform → assemble → 批量写 → 释放内存 → 下一批,内存恒定在 1G 以内,不会因一次性载入而溢出。
```bash
# Docker(prod)— 必带 --env-file
docker compose --env-file apps/pac-service/.env -f docker-compose.prod.yml exec -T \
-e PAC_COHORT_CONCURRENCY=3 \
pac-service pnpm sync:prod -- --dir=./data/<host> --cohort-batch=500 --no-recompute
# 裸机 / 本地
PAC_COHORT_CONCURRENCY=3 pnpm sync -- --dir=./data/<host> --cohort-batch=5000 --no-recompute
```
| 参数 | 含义 |
|---|---|
| `--cohort-batch=N` | 每批 N 个患者(默认 5000;数据源较慢时调小到 500 看进度更细) |
| `PAC_COHORT_CONCURRENCY=3` | 3 个 cohort 并发(默认 1 串行);连接池自动随并发放大 |
| `--no-recompute` | 只拉数据,persona / plan 留待单独跑(大数据量分阶段更可控) |
> **量级参考**:十万级患者、`concurrency=3` 约 1.5–2 小时,内存峰值 < 1G。
全量完成后手动触发重算(增量模式下 cron 会自动联动,首跑用了 `--no-recompute` 时需手动补):
```bash
pnpm recompute-persona:prod -- --host=<host>
pnpm recompute-plans:prod -- --host=<host>
```
**验证**:
```sql
-- 规模自检:三者都应 > 0(导入成功)
SELECT 'patients' AS t, count(*) FROM patients
UNION ALL SELECT 'tx', count(*) FROM patient_transactions
UNION ALL SELECT 'facts', count(*) FROM patient_facts;
-- 一致性自检:下面两个都应 = 0(无重复 tx、无多 active fact)
SELECT count(*) AS dup_tx FROM (SELECT source_event_id,host_id,tenant_id FROM patient_transactions
WHERE source_event_id IS NOT NULL GROUP BY 1,2,3 HAVING count(*)>1) x;
SELECT count(*) AS multi_active FROM (SELECT subject_id,host_id,tenant_id FROM patient_facts
WHERE status='active' GROUP BY 1,2,3 HAVING count(*)>1) y;
```
---
## 二、日级增量
`SyncIncrementalSchedulerService` 的 `@Cron`(`PAC_INCREMENTAL_CRON`)随服务启动注册,走与全量同一套 `importDirectory`:
```
每天 08:15(PAC_INCREMENTAL_CRON,须设在数据源当日刷新之后)
importDirectory({ incremental: true })
- 并发锁:sync_logs partial UNIQUE (host_id) WHERE status='running'
同 host 已在跑 → 跳过,等下次 cron
- 读上次 cursor_after → SQL WHERE updated_date > (cursor − 回看窗 48h)
(回看重叠靠 source_event_id 去重;增量数据量小,通常 1 批)
- 拉新增/变更 fact,并补全主档(见 §三)
- 写 cursor_after = 本次起跑时间(非 max(updated_date)),
保证跑期间数据源的任何新写入下次都能捞回
Persona 重算(本次涉及的患者)→ Plan 重算(SQL 召回 + 多因子打分)
```
> 端到端约 T+1(数据源本身比临床事件晚一天)。
手动触发(调试 / 紧急补跑):
```bash
pnpm sync -- --dir=./data/<host> # 增量 + 联动重算
pnpm sync -- --dir=./data/<host> --no-recompute # 只拉数据
pnpm sync -- --dir=./data/<host> --dry-run # 不写库,预览游标注入
pnpm sync -- --dir=./data/<host> --full # 忽略游标强制全量(灾后)
```
---
## 三、数据完整性
**幂等(数据库双 UNIQUE)** —— 任何并发 / 重跑结果一致:
- `patient_transactions`:partial UNIQUE `(host_id, tenant_id, source_event_id) WHERE source_event_id NOT NULL`
- `patient_facts`:UNIQUE `(host_id, tenant_id, subject_id, version)` + active 行 partial UNIQUE
`source_event_id` 内含 `updatedAt`,因此源数据行级 in-place 更新会自然产生新 tx 与新 fact 版本(旧版 supersede);同行同 `updatedAt` 则幂等跳过。
**主档补全(两条保障,确保 patient_id 不丢)**:
- 增量只拉到 fact、主档游标未动时,用三段键建空 stub,真实主档随后 upsert 补 PII;
- 每次跑完按 `(patient_id, brand)` 反向强拉一次主档,补齐当批关联。
**数据源无关**:cohort 的取数维度(患者列表来源、患者键、租户键、游标列)全部由 `manifest.sql_source.cohort` 声明,代码不硬编码表名列名 —— 接新 host 只改 yaml。
---
## 四、资源调优
| 旋钮 | env / 参数 | 作用 | 建议 |
|---|---|---|---|
| cohort 大小 | `PAC_COHORT_BATCH_SIZE` / `--cohort-batch=N` | 每批患者数(控内存) | 5000;数据源较慢看进度可调 500 |
| cohort 并发 | `PAC_COHORT_CONCURRENCY` | 并发 cohort 数(提吞吐) | 默认 1 稳;3-4 并行(连接池自动放大) |
| 写批大小 | `PAC_WRITE_BATCH_SIZE` | 每批写入行数 | 1000;0 = 逐行(回退开关) |
| query 并行 | (自动) | 多个数据源 query 并发 | load 阶段约 4x,无需配置 |
并发安全:cohort 是互不相交的患者集,所有写操作按患者隔离,叠加数据库双 UNIQUE 兜底,任何并发下都一致(并发模式下禁用 checkpoint resume,靠幂等从头重跑)。
---
## 五、配置变更
配置 yaml 位于 `apps/pac-service/data/<host>/`。
### 5.1 变更影响速查
| 改动 | 影响 | 处理 |
|---|---|---|
| `enum_mapping` / `keyword_mapping` 加改术语 | 新数据生效,老数据需重解析 | `pnpm reparse --subject-type=<canonical>`(§5.2,不用全量重摄) |
| `field_mapping` 改字段名 | 同上 | 同上 |
| `transforms` 改 split / derive | 影响装配管道 | 同上(reparse 会重跑 transforms) |
| `scenario` SQL 改召回口径 | 立即生效(下次重算) | 等 cron 或手动 `recompute-plans`(facts 不变,无需 reparse) |
| `canonical-codes` 字典 | 立即生效 | 重启 pac-service |
### 5.2 Reparse —— 字典改了补存量(无需全量重摄)
改了 assembler yaml(`enum_mapping` / `keyword_mapping` / `field_mapping` / `transforms`)后,要把**已入库的老 fact 按新口径重新衍生**。无需 truncate + 全量重摄(那会重拉数据源且删掉执行历史与分配)。`reparse` 用已存的 `transaction.rawPayload`(原始源行)**离线**重跑装配 + 解析:
- **非破坏**:fact 走版本流(内容变则升版本 supersede,不变则跳过),不 truncate、不碰 transaction 账本,客服执行历史与分配全部保留;
- **不连数据源**:rawPayload 在本地,比全量重摄快一个量级;
- **可圈定**:按 host / subject-type / 患者集 / dry-run / 是否重算;
- **覆盖范围**:field/enum/keyword_mapping、transforms 算子、parser 改动;**不覆盖**改了 `sql_source` 的 SELECT 本身(需从数据源多拉列/表,那才需真重摄)。非 transform 产出的资源(如 SQL 视图类)会自动跳过。
```bash
# dry-run:报 scope(患者数 / txn 数),不写库
pnpm reparse:prod -- --host=<host> --subject-type=diagnosis --dry-run
# 实跑某资源(按患者分批,内存恒定;自动定向重算受影响患者的 persona/plan)
pnpm reparse:prod -- --host=<host> --subject-type=diagnosis
# 限定患者集(秒级,改 keyword 只影响一小批时用)
pnpm reparse:prod -- --host=<host> --subject-type=diagnosis --patient=<uuid>,<uuid>
# 只重衍 fact、不重算(超大批量时,稍后单独 recompute-plans)
pnpm reparse:prod -- --host=<host> --subject-type=diagnosis --no-recompute
# Docker(prod)容器内 detached 跑 + 看日志
docker compose --env-file apps/pac-service/.env -f docker-compose.prod.yml \
exec -d pac-service sh -c "pnpm reparse:prod -- --host=<host> --subject-type=diagnosis > /tmp/reparse.log 2>&1"
docker compose -f docker-compose.prod.yml exec -T pac-service tail -f /tmp/reparse.log
```
| 参数 | 含义 |
|---|---|
| `--subject-type=<canonical>` | 限定资源(如 diagnosis / treatment_actual);空 = 所有 transform 产出的资源 |
| `--patient=<uuid,...>` | 限定患者集(秒级);空 = 全 host(分批) |
| `--dry-run` / `--no-recompute` | 只报 scope / 只重衍 fact 不重算 |
| env `PAC_REPARSE_BATCH` | 全量时每批患者数(默认 3000,控内存) |
> **典型场景**:源数据中文诊断名变体繁多、多数无标准 ICD 码,全靠 enum / keyword 白名单翻译;漏映射会导致 `code=null`、进不了对应病种召回。修法:在 `diagnosis.yaml` 补 keyword → `pnpm reparse:prod -- --subject-type=diagnosis`,即可把该病种此前漏召的存量患者补回召回池。十万级患者全量 reparse 内存恒定在 1G 以内,单患者 / 小批为秒级。
---
title: 登录授权
description: 宿主把用户登录到内嵌 PAC 的换票接口、请求参数与数据权限填法。
icon: KeyRound
---
> PAC 工作台以 iframe 嵌入宿主系统。用户在宿主侧登录后,宿主**换票**给 PAC —— 传入「身份 + 角色 + 可见范围」,换取一次性 code 打开 PAC 页面。
> 接口与字段以 [API 参考 · auth](/docs/api/auth) 的 OpenAPI 定义为准。
---
## 接入前提
接入时由 PAC 分配一对 **`appId` + `appSecret`**;`appSecret` 仅宿主服务端持有,不下发到浏览器。
## 换票流程
```
① 宿主服务端 POST /pac/v1/auth/token { appId, appSecret, user }
→ { code, accessToken, refreshToken, ... }
② 宿主前端 用 code 拼 PAC 页面 URL 打开 iframe(code 一次性,默认 60 秒有效)
```
iframe 内的 token 落地与刷新由 PAC 前端完成。用户再次进入时重新执行换票。
---
## 接口:`POST /pac/v1/auth/token`
完整定义见 [API 参考 · auth](/docs/api/auth/AuthController_exchangeToken)。
### 请求参数
```jsonc
{
"appId": "app_xxxxxx", // PAC 分配
"appSecret": "***", // 配对密钥,仅服务端持有
"user": {
"userId": "u_10086", // 宿主侧用户唯一 id
"tenantId": "grp_001", // 集团 id(见下)
"role": "leader", // staff | leader | admin
"orgScope": ["B001"], // 数据权限(见下)
"dictionary": { // 可选:id→名,用于页面把 id 显示成名字
"clinics": { "C001": "(门店名)" },
"users": { "u_10086": "(用户名)" }
}
}
}
```
请求体为严格模式,未定义字段会被拒绝。
**`role`** — 功能权限:
| 角色 | 范围 |
|---|---|
| `staff` | 执行客服:查看 / 认领 / 执行自己的召回工单 |
| `leader` | 主管:在 staff 基础上增加全池查看 / 分配 / 回收 / 统计 |
| `admin` | 后台管理员:全部 |
**`tenantId` 与 `orgScope` —— 同一套组织 id**。宿主组织是一棵树,根 = 集团(`tenantId`),往下品牌 / 区域 / 诊所;`orgScope` 是用户可达的节点:
| 用户 | `orgScope` |
|---|---|
| 集团管理员 | `[]`(空 = 全集团) |
| 品牌主管 | `["B001"]`(品牌 → 含其下全部诊所) |
| 区域负责人 | `["R-East"]`(区域 → 含其下全部品牌与诊所) |
| 诊所客服 | `["C001", "C002"]`(诊所) |
取值用 PAC 摄入数据里的同一套 id(集团 = `tenant_id`、品牌 = `source_unit`、诊所 = `clinic_id`):**换票传的与该环境数据对得上即可命中**,与"环境"无关。单集团宿主 `tenantId` 用一个固定值。
### 响应参数
```jsonc
{
"code": "...", // 一次性 code,拼进 PAC iframe URL(默认 60 秒)
"codeExpiresIn": 60,
"accessToken": "...", // 供宿主服务端直连 PAC API(Bearer);iframe 模式可忽略
"refreshToken": "...",
"expiresIn": 7200 // accessToken 有效期(秒)
}
```
iframe 嵌入只需 `code`;`accessToken` / `refreshToken` 供服务端直连 API 时使用。响应字段后续可能扩展,消费方应忽略未知字段。
---
title: ClickHouse 视图通道
description: 宿主在自有 ClickHouse 数仓建只读视图,PAC 直接 SQL 拉取的存量加增量统一通道。
icon: Database
---
> **一句话**:宿主在自己的 ClickHouse 数仓里建一组只读视图(每类业务一张),PAC 直接 SQL 拉,**存量 + 增量同一通道**,宿主不用写任何 REST 接口。
>
> **适用对象**:数据已经在 CH 数仓里(典型 = 集团 DW 已经 ETL 过来),不想为 PAC 单独建一套接入 endpoint 的宿主。
>
> **状态**:Pull **三种数据源之一**(文件 / CH 直连 / REST);跟其它源**契约和数据字典完全互通**,切换零返工。
---
## 1. 跟现有通道的关系
| 通道 | 数据形态 | 增量机制 | 宿主侧工作 |
|---|---|---|---|
| Cold-import | CSV/JSON dump | ❌ 一次性 | `SELECT *` 导出 |
| Pull (REST) | HTTP JSON | 游标查询参数 | 实现 14 个 REST endpoint |
| Push | HTTP JSON | 实时事件 | 主动推 + HMAC |
| **CH 视图(本文档)** | **CH 行** | **视图内单调列 + SQL `WHERE`** | **建视图 + 给只读账号** |
**核心理念**:CH 视图 = REST Pull 的 SQL 变体 —— 把 HTTP 换成直连 SQL,游标语义、数据字典、容错保障**全部复用**(对宿主完全一致)。
**存量 + 增量同一条**:首次跑 `WHERE updated_at > 0` 拿全量,之后每次跑 `WHERE updated_at > <上次游标>` 拿增量,**不用再做 cold-import 一次性 dump**。
---
## 2. 宿主要做什么(4 步,工作量约 1-2 天)
### 2.1 建视图(每类业务一张,共 7 张必填 + 7 张可选)
视图名建议 `dw.v_pac_<resource>`,字段命名 / 嵌套 / 枚举编码**宿主自由**——PAC 侧 yaml 映射,跟 pull / cold-import 共用同一份[数据字典](/docs/integration/data-request-checklist)。
| 视图名(建议)| 必/选 | 对应业务 |
|---|---|---|
| `dw.v_pac_patients` | 🔴 必 | 患者主档 |
| `dw.v_pac_appointments` | 🔴 必 | 预约 |
| `dw.v_pac_clinical_encounters` | 🔴 必 | 接诊 |
| `dw.v_pac_emr_documents` | 🔴 必 | 电子病历 ⭐ |
| `dw.v_pac_image_studies` | 🔴 必 | 影像 metadata ⭐ |
| `dw.v_pac_orders` | 🔴 必 | 收费单 / 医嘱 |
| `dw.v_pac_payments` | 🔴 必 | 收款 |
| `dw.v_pac_recharges` | 🟠 强烈推荐 | 充值 / 储值 |
| `dw.v_pac_complaints` | 🟠 强烈推荐 | 投诉 |
| `dw.v_pac_referrals` | 🟠 强烈推荐 | 转介绍 |
| `dw.v_pac_refunds` / `consultations` / `visit_registrations` / `visit_receptions` | 🟡 可选 | — |
### 2.2 每张视图必须含「必带列」
视图 SELECT 里要带上[每张表必带的列](/docs/integration/data-request-checklist)(主键 / 患者外键 / 诊所 / 命名空间 / 单调更新列),并按约定的试点诊所过滤:
```sql
-- 示例:dw.v_pac_clinical_encounters
CREATE VIEW dw.v_pac_clinical_encounters AS
SELECT
enc_id, -- 业务主键(幂等锚)
clinic_id, -- 诊所
patient_id, -- 患者外键
brand, -- 命名空间(多品牌必带 → source_unit)
-- ...所有业务字段原样...,
etl_updated_at -- 单调递增列(游标基准,必须随每次行变更递增)
FROM dw.dwd_encounter_main
WHERE clinic_id IN ('clinic_a', 'clinic_b' /* ... */); -- 按约定的试点诊所过滤
```
> 单调递增列(`etl_updated_at` / `_version` / 时间戳)是 CH 通道做增量游标的关键 —— 没有就只能全表扫。
**关于 1:N 子表**(如接诊主表 + 诊断 / 治疗子表)——两种处理都行:
- **方案 A(推荐)**:在视图里用 CH 的 `groupArray` / `arrayMap` 聚成嵌套 JSON 字段,一行 = 完整业务对象
- **方案 B**:子表也建独立视图,PAC 侧 join
### 2.3 开只读账号 + 网络打通
```sql
CREATE USER pac_reader IDENTIFIED BY '<32位强随机>';
GRANT SELECT ON dw.v_pac_* TO pac_reader;
-- 不需要任何 INSERT / ALTER / 表级权限
```
**网络可达性**(三选一,按宿主合规要求):
- VPN / 内网专线(推荐)
- CH HTTPS 端口 + IP 白名单(把 PAC 出口 IP 报给宿主)
- 跳板 / 反代
⚠️ **公网直连 CH Native 协议(9000)合规雷区**——必走加密通道。
### 2.4 提供数据字典
跟 pull / cold-import 共用同一份,见 [数据请求清单](/docs/integration/data-request-checklist) §3。
**枚举值含义必须列全**——`status='02'` 是"完成"还是"作废",字典没写 PAC 只能猜。
---
## 3. PAC 怎么用这些视图(宿主了解即可,不用做)
PAC 按配置间隔(默认 5 分钟)轮询每张视图,用单调列做增量游标:
```sql
SELECT * FROM dw.v_pac_clinical_encounters
WHERE etl_updated_at > {上次游标}
ORDER BY etl_updated_at, enc_id -- 双列排序防同毫秒漏数
LIMIT 200;
```
首次全量(游标从 0 起)→ 之后只取 `etl_updated_at` 变大的行;客服点"刷新"时按 `patient_id` 即时查该患者当前态。幂等去重、断点续传、每日对账、字段漂移告警等容错全在 PAC 侧,宿主不用管。
> 增量游标 / 响应语义 / 错误处理与 [REST API 通道](/docs/integration/channel-rest) 完全一致,只是承载从 HTTP 换成 SQL。
---
## 4. 跟 REST Pull / Cold-import 的取舍
| 维度 | CH 视图 | REST Pull | Cold-import |
|---|---|---|---|
| 宿主工作量 | ⭐⭐⭐ 建视图 + 开账号(1-2 d)| ⭐ 写 14 个 endpoint(3-5 d)| ⭐⭐ 一次 dump(0.5 d)|
| 实时性 | 视图刷新频率(典型小时级)| 准实时(5 min) | ❌ 不支持增量 |
| 存量 + 增量统一 | ✅ 同一条 | ❌ 需配合 cold-import | ❌ 只解决存量 |
| 字段稳定性 | ⚠️ 视图列改 PAC SQL 立刻挂(需 2 周通知)| ✅ 接口契约稳 | n/a |
| 网络 | ⚠️ 需 VPN / 内网 | ✅ HTTPS 即可 | ✅ 文件传输 |
| 5 诊所历史回灌 | ✅ SQL 一把扫 30 min | ❌ 翻页慢 | ✅ |
| 适合场景 | **DW 数仓已就位** | 业务系统直出 | 临时 / 一次性 |
**典型选择**:
- 集团 DW 已经把宿主数据 ETL 到 CH → **走 CH 视图**(最低成本)
- 宿主只有 OLTP 没数仓 → **走 REST Pull**
- 两边都搞不定先上线 → **走 Cold-import** 兜底,后面补正
---
## 5. 接入 Checklist(宿主自查)
### 必填
- [ ] 7 张必填视图建好,字段命名按宿主习惯即可
- [ ] 每张视图含[必带列](/docs/integration/data-request-checklist)(主键 / 患者外键 / 诊所 / 命名空间 / 单调更新列)
- [ ] 视图已按约定的试点诊所过滤
- [ ] `pac_reader` 只读账号建好,密码用强随机,plaintext 一次性交付 PAC
- [ ] 网络可达性方案确定(VPN / 白名单 / 跳板)并完成测试
- [ ] 数据字典交付(参考 [数据字典示例](/docs/integration/data-dictionary))
- [ ] **视图刷新频率写明**(5 min / 1 h / T+1?)
- [ ] **字段变更 SLA**:提前 2 周通知 PAC
### 强烈推荐
- [ ] 3 张推荐视图(`recharges` / `complaints` / `referrals`)
- [ ] 1:N 子表已聚成嵌套 JSON 列(避免 PAC 侧多次查询)
- [ ] 视图查询性能验证:`WHERE etl_updated_at > now() - INTERVAL 1 HOUR LIMIT 200` 应 `< 1 s`
### 可选
- [ ] 4 张可选视图
- [ ] [actionUrls](/docs/integration/overview) 模板(deep-link 跳回宿主 UI)
---
## 6. 跟宿主对齐时的 5 个必问问题
不管前面文档读没读,直接拉对方在群里答这 5 个:
1. **视图里有没有单调递增列**(`etl_updated_at` / `_version` / 时间戳)?名字是啥?
2. **视图多久刷一次**?实时 / 5 min / 1 h / T+1?
3. **PAC 服务器怎么连你 CH 集群**?VPN / 内网专线 / 公网+白名单?哪种走得通?
4. **视图字段稳定性**——你们改表 / 改 ETL 时能提前多久通知 PAC?
5. **视图是否已经按诊所过滤**?试点 clinic_id 是哪些?
5 个对齐了就可以开工。
---
## 7. 不在本通道范围的事
CH 视图只替换"数据接入"那一段;鉴权换票 / iframe 嵌入 / actionUrls 是独立链路(跟选哪种通道无关),见 [数据请求清单 §8](/docs/integration/data-request-checklist)。
---
**联系**:对接细节 / 视图字段含义 / 性能问题,找 PAC 接入对接人。
---
title: Push 通道
description: 宿主侧实时推送患者事实到 PAC 的对接文档,含两种接入形态、HMAC 鉴权与幂等防重。
icon: Send
---
> **给宿主开发**:把患者事实**实时推**给 PAC。你只发业务系统里**本来就有的字段**;
> 标准码映射 / 幂等键 / 内部 ID 合成 / 外键关联 / 时区归一 **全部 PAC 内部做**。
> **必须 HTTPS**。配套 Pull(PAC 主动拉)。
---
## 0. 两种接入形态(二选一)
| | **形态 A:推原生行(推荐)** | **形态 C:推结构化事件(备选)** |
| ------------- | ------------------------ | ----------------------------- |
| 你发什么 | 你导出给数仓的那种**原生表行,照抄** | PAC 约定的事件(envelope + payload) |
| 你要懂 | 只懂**自己的数据字典** | 还要懂 PAC 的字段/类型约定 |
| 拆分 / 关联 / 码映射 | **全 PAC** | payload 的码仍 PAC 映射,其余你排好 |
| 一次性成本 | 给一份**数据字典**,PAC 写映射 | 对一遍字段约定 |
| 适合 | **绝大多数**(诊所 / HIS / 老系统) | 有数据中台、愿稳定吐标准格式的强宿主 |
**默认选 A**:字段最少、口径与数仓一致;PAC 以后调整映射能**自愈历史**(无需你重发)。
鉴权(§2)两形态通用;防重(§5)两形态都保证。
---
## 1. 组织定位(两形态通用)
每条数据用三个 id 标识归属,**与 [登录授权](/docs/integration/auth-login) 同一套组织 id**:
| 字段 | 含义 | 取值 |
|---|---|---|
| `tenantId` | 集团 id | 同登录授权的 `tenantId`。单集团宿主用固定值,多集团按数据归属传 |
| `sourceUnit` | 命名空间 id(品牌 / 区域等) | 集团内给患者号消歧:同号不同命名空间 = 不同人。单一命名空间可省 |
| `clinicId` | 诊所 id | 业务发生诊所 |
- **形态 A**:就是原生行里的对应列,数据字典指明哪列即可。
- **形态 C**:用上面的字段名显式带(见 §4.1)。
---
## 2. 鉴权(HMAC-SHA256,两形态通用)
| Header | 值 |
| ----------------- | ------------------------------------------------------- |
| `X-PAC-Host-Id` | PAC 分配的 host UUID(也表明"你是谁") |
| `X-PAC-Timestamp` | 当前 Unix 秒(容忍 ±5 分钟,防重放) |
| `X-PAC-Signature` | `hex( HMAC-SHA256( secret, "{timestamp}.{rawBody}" ) )` |
- `secret`:接入时 PAC 一次性安全下发,双方各存原文,永不上网。
- ⚠️ **"用来签的 body" 与 "发出去的 body" 必须是同一串字节**(先 `JSON.stringify` 一次,签它、发它)。
- 密钥轮换:PAC 给新 secret,过渡期新旧都收,你切完旧的作废(零停机)。
```js
const body = JSON.stringify(payload);
const ts = Math.floor(Date.now()/1000);
const sig = require('crypto').createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex');
// headers: X-PAC-Host-Id / X-PAC-Timestamp=ts / X-PAC-Signature=sig ; body 原样发
```
---
## 3. 形态 A:推原生行(推荐)
```jsonc
// POST /pac/v1/push/rows
{
"source": "fact_emr_treatment_out", // 哪张源表(= 你给数据字典里的表名)
"rows": [
{ /* 你那张表的一行,原生字段照抄,含嵌套结构(如 diag 数组)都不用拆;
组织列(集团 / 命名空间 / 诊所)就在行里,数据字典指明即可 —— 见 §1 */ }
]
}
```
**对原生行的唯一要求**(都是你表里本来就有的列):
- 有患者引用列(如 `patient_id`);
- 有**组织归属列**(集团 / 命名空间 / 诊所,如 `brand` / `clinic_id`)—— 对应 §1 三个 id,单集团 + 单命名空间可省到只剩诊所;
- 有**末次修改时间**列(如 `updated_date`)—— 驱动幂等 / 版本;
- 有该行的自然键(如 `id`)。
PAC 收到后据你的数据字典自动**拆分**(一行 EMR → 病历 + 多条诊断 + 治疗,外键自动挂)→ **诊断名映射标准码** → **去重入库**。拆分 / 映射 / 去重你都不用做。
---
## 4. 形态 C:推结构化事件
```jsonc
// POST /pac/v1/push/events
{ "events": [ /* 1~500 条;可混不同 subjectType;顺序无要求 */ ] }
```
每条 event = **envelope(公共)** + **payload(资源专属)**。
> 形态 C 已可用;多数宿主用 **形态 A 更省事**(字段最少、口径与数仓一致)。
### 4.1 envelope 公共字段
| 字段 | 必填 | 说明 |
| ------------- | ---- | -------------------------------------------------------------------- |
| `subjectType` | ✅ | 资源类型 —— 全量见 [事件枚举](/docs/event-enums) |
| `action` | ✅ | 动作 —— 与 `subjectType` 成对,全量见 [事件枚举](/docs/event-enums) |
| `subjectId` | ✅ | 本记录在你系统的 id(每次同一记录同值,进幂等键) |
| `patientId` | ⭕ 条件 | 所属患者 id;`subjectType=patient`(主档自身)时可省 |
| `tenantId` | ✅ | 集团 id(同 §1)。单集团固定值,多集团按归属传 |
| `sourceUnit` | ⭕ 条件 | 命名空间 id(同 §1)。多命名空间必带,单一可省 |
| `clinicId` | ✅ | 诊所 id |
| `occurredAt` | ✅ | 发生时间(本地时间即可,PAC 按配置时区归一;带 offset 也行;无独立发生时间用创建时间) |
| `updatedAt` | ⭕ 推荐 | 末次修改时间(内容变则变、重试不变 → 升版本 / 去重);不传则回退 `occurredAt` |
### 4.2 payload(按 subjectType)
**diagnosis 示例**(一次病历多条诊断 → `items[]`,`emrId` 在 payload):
```jsonc
{
"subjectType":"diagnosis", "action":"diagnosis_recorded", "subjectId":"dx_88f1",
"patientId":"P10086", "tenantId":"grp_001", "sourceUnit":"B001", "clinicId":"C001",
"occurredAt":"2025-12-06T13:51:44+08:00", "updatedAt":"2025-12-25T18:38:21+08:00",
"payload": {
"emrId": "emr_88f1", // 关联电子病历(诊断/治疗/建议有;预约/收费无)
"doctorId":"D5725", "doctorName":"(医生名)",
"items": [
{ "name":"菌斑性龈炎", "tooth":"", "code":"" }, // name=原始诊断名(PAC 映射码);code 选填;tooth 选填
{ "name":"恒牙深龋", "tooth":"17;37", "code":"K02.800x008" }
]
}
}
```
**各主体 payload 的标准化字段**(全量)见 [主体标准化字段](/docs/canonical-fields);`action` 取值见 [事件枚举](/docs/event-enums)。
> **金额、时间按你系统原样发,不用自己转**:接入时配置 `amountUnit`(fen/yuan)+ `timezone`(IANA),
> PAC 自动归一成「整数分 + UTC」(同数仓口径)。金额带 `currency`;时间本地值即可。
---
## 5. 幂等 / 防重
PAC **自己合成幂等键**。你只要:
1. **身份稳定**:`subjectId` 同一条记录每次同值(没有现成 id 就用稳定自然键,如 `emrId+name+tooth` 拼一个);
2. **`updatedAt`(更新时间字段)正确**:内容变它变、重试不变。
| 你的行为 | PAC 结果 |
|---|---|
| 网络抖动**重推一模一样的** | **自动跳过**(不重复)|
| 改了内容**重推** | 自动**升版本**(旧版作废)|
| 同请求里夹重复条目 | 自动折叠 |
只要满足上面两条,**PAC 保证幂等**——重推完全相同的不会重复、改了内容的自动升版本;**你无需自己保证事件 id 唯一**。
---
## 6. 撤回 / 删除(必须软删)⚠️
PAC 靠你**发事件**才知道——**物理删除永远感知不到**。所以:
- **关键资源必须逻辑删(软删),别物理删**;
- 撤回:**重发该记录、`action=*_cancelled`(如 `diagnosis_cancelled`)+ 更新 `updatedAt`**;
- 诊断这类 `items[]`:撤回某条就发**该条**(同 `emrId+name+tooth`)的 `*_cancelled`;
- PAC 把对应记录置"已取消",不再参与召回。
(与 Pull 通道一致:关键资源需逻辑删除并更新 `updatedAt`。)
---
## 7. 可靠投递 / 异常 / 漏推(宿主侧)
### 7.1 回执:看 envelope `code`,**不是 HTTP 状态**
PAC 统一返回 `{ code, msg, data }`,**HTTP 恒 200**(只有进程级崩溃才 5xx)。判成败**看 `code`**:
| 场景 | HTTP | `code` | 重试? |
| ------------------ | ------- | ------- | ----------------------------------------------------------------------- |
| 成功 | 200 | `0` | — `data` = `{syncLogId, accepted, transactionsWritten, duplicates, failed}` |
| 验签失败(secret/时钟/签名) | 200 | `10106` | ❌ |
| 字段校验失败 | 200 | `10002` | ❌ |
| 请求格式错 | 200 | `10001` | ❌ |
| 数据形态漂移(字段/结构对不上) | 200 | `30802` | ❌ |
| 限流 | 200 | `10003` | ✅ |
| PAC 内部错 | 200 | `9xxxx` | ✅ |
| PAC 崩溃 | **500** | `9xxxx` | ✅ |
| 超时 / 连不上 | — | — | ✅ |
> **规则**:`0` 成功;`10003` / `9xxxx` / HTTP 5xx / 超时 → **退避重试**。**不确定也重试**,发了没收到响应 → 直接重试。
### 7.2 漏推(整条没推上来)怎么补 —— reconcile
push 可能整条漏(宿主 bug / 没入队 / 崩溃没持久化):
1. **周期幂等补推(纯 push)**:每晚把最近 N 天数据**全量重推一遍**——已推跳过、漏的补上、改的升版本。这就是 push 侧对账,定时无脑跑;
2. **PAC 侧检测**:push 量异常下降 / 某 host 太久没推 → PAC 告警 → 人工触发补推。
---
## 8. 限制 / 约定
| 项 | 约定 |
|---|---|
| 批量 | 形态 C ≤ 500 events;形态 A 按批推荐 ≤ 数百行/请求 |
| 金额 / 时间 | **按你系统原样发**;PAC 按接入配置(`amountUnit`+`timezone`)归一成 分/UTC。带 offset 的时间直接用 |
| 编码 | UTF-8 |
| 重试 | 失败可安全重试(幂等);建议指数退避 |
---
## 9. 宿主接入 Checklist
- [ ] 拿到 PAC 分配的 `Host-Id` + `secret`
- [ ] 实现 HMAC 签名(`{ts}.{rawBody}`;同一串 body 签+发)
- [ ] **选形态**:能直接吐原生行 → A(给数据字典);否则 → C(对字段约定)
- [ ] 每条记录:身份稳定(有 `subjectId` 给,无则自然键稳定)、`updatedAt` 随内容变
- [ ] 每条带正确组织 id(`tenantId` 集团、`sourceUnit` 命名空间、`clinicId` 诊所;取值同登录授权)
- [ ] 删除走软删 + `*_cancelled`
- [ ] 接入时配置 `amountUnit`(fen/yuan)+ `timezone`(IANA)
- [ ] 全程 HTTPS
- [ ] **本地 outbox 持久化 + 异步 worker**(业务写库与入队同事务)
- [ ] 失败/异常**按 `body.code` 族判重试**
- [ ] **周期幂等补推**最近 N 天(纯 push 宿主兜底漏推)
---
## 附:一条完整请求(形态 C,curl)
```bash
TS=$(date +%s)
BODY='{"events":[{"subjectType":"diagnosis","action":"diagnosis_recorded","subjectId":"dx_88f1","patientId":"P10086","tenantId":"grp_001","sourceUnit":"B001","clinicId":"C001","occurredAt":"2025-12-06T13:51:44+08:00","updatedAt":"2025-12-25T18:38:21+08:00","payload":{"emrId":"emr_88f1","doctorId":"D5725","items":[{"name":"菌斑性龈炎","tooth":"","code":""},{"name":"恒牙深龋","tooth":"17;37","code":"K02.800x008"}]}}]}'
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST "$PAC/pac/v1/push/events" -H "Content-Type: application/json" \
-H "X-PAC-Host-Id: $HOST_ID" -H "X-PAC-Timestamp: $TS" -H "X-PAC-Signature: $SIG" --data "$BODY"
```
---
title: REST API 通道
description: 宿主实现一组 REST 资源 endpoint(GET + 游标),PAC 按间隔主动拉取的存量加增量统一通道。
icon: Webhook
---
> **一句话**:宿主实现一组只读 REST 资源 endpoint(每类业务一个,GET + 游标分页),PAC 按间隔主动 `GET` 拉,**存量 + 增量同一通道**。
>
> **适用对象**:有业务系统能直出 HTTP JSON、但没把数据 ETL 进数仓的宿主(典型 = OLTP 直供,无 DW)。已有 ClickHouse 数仓的走 [CH 视图](/docs/integration/channel-clickhouse) 成本更低。
>
> **状态**:Pull 三种数据源之一(文件 / [CH 直连](/docs/integration/channel-clickhouse) / REST API)。**REST 适配器 PAC 侧开发中** —— 接口契约稳定,宿主可照本文档先行实现;PAC 侧首个宿主接入时实装鉴权策略(api-key / session / OAuth2)。
---
## 1. 跟其它 Pull 源的关系
三种 Pull 源**数据形态完全一致**,共用同一份[数据字典](/docs/integration/data-request-checklist),切换零返工 —— 区别只在承载协议:
| 源 | 承载 | 增量机制 | 宿主侧工作 |
|---|---|---|---|
| 文件(CSV/JSON) | 裸表文件 | 重新导出 | `SELECT *` 导出 |
| [CH 视图](/docs/integration/channel-clickhouse) | ClickHouse 行 | 视图内单调列 + SQL `WHERE` | 建视图 + 给只读账号 |
| **REST API(本文档)** | **HTTP JSON** | **`?after=<游标>` 查询参数** | **实现资源 endpoint** |
存量(首次无游标的全量,即业内 cold-import)与增量(游标)走**同一条 pull 路径**,只是有无游标之别 —— 宿主不用单独再做一次性 dump。
---
## 2. 资源 endpoint 清单
每个 endpoint = host 侧一张表 / 视图行直出,字段命名 / 嵌套 / 枚举编码**宿主自由**,PAC 侧统一做 raw → canonical 映射(跟文件 / CH / push 共用同一份字典)。
**14 个 endpoint 跟 [数据请求清单](/docs/integration/data-request-checklist) §1 的数据类别一一对齐**(同一份数据字典两路通用)。
> 每行**必带的列**(业务主键 / 患者外键 / 诊所 / 命名空间 / 更新时间)三种 Pull 源通用 —— 见 [数据请求清单 · 每张表必带的列](/docs/integration/data-request-checklist)。
| Endpoint key | 含义 | 必/选 |
| --------------------- | ------------------------------- | --------------- |
| `patients` | 患者主档 | 🔴 必 |
| `appointments` | 预约 + 状态变更 | 🔴 必 |
| `clinical_encounters` | 医生接诊(可嵌套 1:N 子表) | 🔴 必 |
| `emr_documents` ⭐ | 电子病历(诊断 / 治疗主要载体) | 🔴 必 |
| `image_studies` ⭐ | 影像 metadata + 医生 finding(无文件本体) | 🔴 必 |
| `orders` | 收费单 / 医嘱(治疗 / 耗材 / 药品)+ 明细 | 🔴 必 |
| `payments` | 收款 | 🔴 必 |
| `recharges` | 充值 / 储值 / 福利卡 | 🟠 强烈推荐(精确 LTV) |
| `complaints` | 投诉 | 🟠 强烈推荐(合规硬约束) |
| `referrals` | 转介绍 | 🟠 强烈推荐(渠道归因) |
| `refunds` | 退费 | 🟡 可选 |
| `consultations` | 咨询 | 🟡 可选 |
| `visit_registrations` | 挂号 | 🟡 可选 |
| `visit_receptions` | 接待登记 | 🟡 可选 |
> **1:N 子表**(如接诊主表 + 诊断 / 治疗子表):在 endpoint 返回里把子表数组嵌套进主对象(一行 = 完整业务对象),避免 PAC 侧多次查询。
---
## 3. 统一响应 envelope
所有 endpoint 走全局[响应信封](/docs/error-codes) —— **HTTP 恒 `200`**,业务结果看 `code`。
```jsonc
// 成功
{
"code": 0,
"msg": "ok",
"data": {
"items": [ /* ...资源对象数组... */ ],
"nextCursor": "string | null" // null = 没有更多
}
}
// 失败
{ "code": 10101, "msg": "...", "data": null, "details": null }
```
- 每页 100-200 条,上限 500
- 业务异常通过 envelope `code` 区分(码表见[响应信封与错误码](/docs/error-codes);建议复用 PAC code 表,联调日志好对齐)
---
## 4. 增量游标
```text
GET <endpoint>?after=<cursor>&limit=200
```
- Cursor 形态:ISO 8601 时间戳 或 宿主自定义 opaque string
- **必须单调递增**(不能跳)
- 首次拉取 `after` 省略 / `null`(从最早开始)→ 即存量全量
- 推荐 host 关键资源**逻辑删除 + 更新 `updatedAt`**,避免物删产生"幻读"
PAC 每个 `(host, resource)` 维护一条独立游标,`nextCursor=null` 时跳到下个 cron 周期。
---
## 5. 单患者即时刷新
客服工作台"刷新"按钮触发,拉该患者**当前态全量**(不限增量):
```text
GET <endpoint>?patientExternalId=P00012345
```
- 返回该患者所有相关资源的当前态
- PAC 侧 **30s in-flight 去重 + 60s 冷却**,跟每日增量游标完全隔离,宿主不用担心被刷爆
- endpoint 不支持单患者过滤 → PAC 返回 `30804`(该平台不支持单患者即时刷新),功能降级但不影响每日增量
---
## 6. 错误处理
| 场景 | 宿主返回 | PAC 行为 |
|---|---|---|
| 正常 | `code=0` + items + nextCursor | 消化 + 推 cursor |
| 已最新无更多 | `code=0` + `items=[]` + `nextCursor=null` | 跳过到下个 cron |
| 鉴权失败 | `code=10101` | 触发凭证告警,暂停拉取 |
| 单患者找不到 | `code=10301` | 标 archived |
| 限流 | `code=10003` | 指数退避重试 |
| 内部异常 | `code` 属于 `90xxx` 或 HTTP `5xx` | 重试 3 次 → partial |
---
## 7. 鉴权(PAC 调宿主 endpoint)
PAC 作为客户端调宿主 endpoint,凭据由**宿主颁发给 PAC**(推荐 Bearer `apiKey`)。PAC 侧鉴权策略首个宿主接入时按其形态实装:
| 策略 | 适用 |
|---|---|
| api-key | Bearer token(推荐) |
| http-login | Session cookie |
| oauth2 | OAuth2 / OIDC |
> 注意区分方向:这里是 **PAC → 宿主**(拉数据)。**宿主 → PAC** 的接入换票(iframe 登录态)是另一条链路,见 [登录授权](/docs/integration/auth-login)。
---
## 8. 接入 Checklist(宿主自查)
### 必填
- [ ] 7 个必填 endpoint 实现(`patients` / `appointments` / `clinical_encounters` / `emr_documents` / `image_studies` / `orders` / `payments`)
- [ ] 所有 endpoint 走统一 envelope(`{ code, msg, data, details? }`)
- [ ] 增量 cursor 测试通过(`after` 省略 → 全量;追到末页 `nextCursor=null`)
- [ ] 单患者 pull 实现(`?patientExternalId=...`)
- [ ] PAC 调用凭据(`apiKey`)发给 PAC 团队
- [ ] 数据字典交付(参考 [数据字典示例](/docs/integration/data-dictionary))
- [ ] 试点 clinic 已在 endpoint 侧过滤
- [ ] **字段 / schema 变更提前 2 周通知 PAC**
### 强烈推荐
- [ ] 3 个推荐 endpoint(`recharges` / `complaints` / `referrals`)
- [ ] 1:N 子表已嵌套进主对象(避免 PAC 多次查询)
- [ ] 关键资源逻辑删除 + `updatedAt`(避免物删幻读)
---
## 9. 不在本通道范围的事
REST 通道只覆盖"数据接入"那一段;鉴权换票 / iframe 嵌入 / actionUrls 是独立链路(跟选哪种通道无关),见 [数据请求清单 §8](/docs/integration/data-request-checklist)。
---
**联系**:endpoint 字段含义 / 性能 / 鉴权策略,找 PAC 接入对接人。
---
title: 数据字典示例
description: 一份覆盖 10 张主要表的 host 数据字典 YAML 示例模板,供宿主方对照填写。
icon: FileSpreadsheet
---
下面是一份**示例 host 数据字典**,作为格式参考(不是要照搬)。它覆盖患者主档、预约、接诊、电子病历、影像、收费单、收款、充值、投诉、转介绍等 10 张主要表,每张表都给出字段类型、中文含义、枚举值映射、软删除标识和表关联。宿主方可按自己的实际表结构、字段命名、枚举值对照填写。中文 / Markdown / Excel 都接受,这份 YAML 只为示意。配套填写要点见 [数据请求清单](/docs/integration/data-request-checklist) §3。
```yaml
# Host 数据字典 — 详尽示例
#
# 用途:这是一份"格式参考",不是要照搬。按宿主方实际表结构、字段命名、枚举值填即可。
# pull 和 push 两条路都用这份字典,所以请尽量详尽。
#
# 格式说明:中文 / Markdown / Excel 都接受,本 yaml 只为示意。
# 关键:① 字段含义写清楚 ② 枚举值列全 ③ 表关联说清楚 ④ 软删除标识告知
# ─────────────────────────────────────────────
# 全局上下文(必填两项)
# ─────────────────────────────────────────────
contexts:
amount_unit: yuan # yuan(元小数) / fen(分整数),金额数值的单位
timezone: Asia/Shanghai # IANA 时区,解释无时区的 datetime
# ─────────────────────────────────────────────
# 1. 患者主档
# ─────────────────────────────────────────────
tb_patient:
含义: 患者主档(身份)
字段:
id { type: bigint, 含义: 主键 }
pat_no { type: varchar(32), 含义: 患者档案号(诊所内可见编号) }
brand { type: varchar(32), 含义: 品牌 / 命名空间标识 —— 多品牌集团必给(PAC 据此派生 source_unit 给患者号在集团内消歧;同号不同品牌=不同人);单一品牌可省 }
name { type: varchar(64), 含义: 姓名 }
gender { type: char(1), 含义: 性别,
枚举: { M: 男, F: 女, U: 未知 } }
birth_date { type: date, 含义: 出生日期 }
phone { type: varchar(20), 含义: 手机号(主要联系电话) }
vip_level { type: tinyint, 含义: VIP 等级,
枚举: { 0: 普通, 1: 银卡, 2: 金卡, 3: 钻卡 } }
tags { type: varchar(255), 含义: 自定义标签(逗号分隔,如 "难沟通,周末忙") }
remark { type: text, 含义: 客户备注(自由文本) }
contact_relation{ type: varchar(8), 含义: 主要联系人关系,
枚举: { SELF: 本人, SPOUSE: 配偶, PARENT: 父母, CHILD: 子女, OTHER: 其他 } }
do_not_contact { type: tinyint, 含义: 不打扰标记,
枚举: { 0: 可联系, 1: 不打扰(投诉/已转出/已故等) } }
created_at { type: datetime, 含义: 建档时间(无时区,按 timezone 解释) }
updated_at { type: datetime, 含义: 最后更新时间 }
is_deleted { type: tinyint, 含义: 软删除,
枚举: { 0: 有效, 1: 已删除(查询应自动过滤) } }
关联:
- 无外键,其他表通过 patient_id 关联
# ─────────────────────────────────────────────
# 2. 预约
# ─────────────────────────────────────────────
tb_appointment:
含义: 预约记录 + 状态变更
字段:
id { type: bigint, 含义: 主键 }
apt_no { type: varchar(32), 含义: 预约编号 }
patient_id { type: bigint, 含义: 关联 tb_patient.id }
clinic_id { type: bigint }
doctor_id { type: bigint }
apt_start_time { type: datetime, 含义: 预约开始时间(无时区) }
apt_duration { type: int, 含义: 预约时长(分钟) }
apt_type { type: varchar(8), 含义: 预约类型,
枚举: { FIRST: 首诊, REVISIT: 复诊, REVIEW: 复查, ORTHO: 正畸调矫, OTHER: 其他 } }
apt_status { type: varchar(8), 含义: 预约状态,
枚举: { NEW: 新建, CFRM: 已确认, CHKIN: 已到诊, FIN: 完成, CXL: 取消, NS: 爽约 } }
cancel_reason { type: varchar(64), 含义: 取消原因(apt_status=CXL 时填) }
source { type: varchar(16), 含义: 来源,
枚举: { ONLINE: 线上, PHONE: 电话, WALKIN: 到店, REFERRAL: 转介绍, RECALL: 回访邀约 } }
created_at { type: datetime }
updated_at { type: datetime }
is_deleted { type: tinyint }
关联:
- patient_id → tb_patient.id
- doctor_id → tb_doctor.id(若有医生表;否则字典里列 id↔name 映射)
# ─────────────────────────────────────────────
# 3. 接诊主表
# ─────────────────────────────────────────────
tb_encounter:
含义: 医生接诊主表(一次看诊 = 一行)
字段:
id { type: bigint, 含义: 主键 }
enc_no { type: varchar(32), 含义: 接诊编号 }
patient_id { type: bigint, 含义: 关联患者 }
apt_id { type: bigint, 含义: 关联预约(可空,walk-in 没预约) }
clinic_id { type: bigint }
doctor_id { type: bigint, 含义: 主治医生 }
enc_start_time { type: datetime, 含义: 接诊开始时间 }
enc_end_time { type: datetime, 含义: 接诊结束时间(可空,未结束时 NULL) }
enc_status { type: varchar(8), 含义: 接诊状态,
枚举: { OPEN: 进行中, CLOSED: 完成, VOID: 作废 } }
chief_complaint { type: text, 含义: 主诉 }
notes { type: text, 含义: 接诊备注 }
created_at { type: datetime }
updated_at { type: datetime }
is_deleted { type: tinyint }
关联:
- patient_id → tb_patient.id
- apt_id → tb_appointment.id(一对一,可空)
# ─────────────────────────────────────────────
# 4. 电子病历(诊断/治疗内容主要载体)⭐
# ─────────────────────────────────────────────
tb_emr:
含义: 电子病历(诊断 / 治疗计划 / 医嘱 主要载体)
字段:
id { type: bigint, 含义: 主键 }
encounter_id { type: bigint, 含义: 关联接诊(一对一) }
patient_id { type: bigint, 含义: 关联患者(冗余,便于查询) }
diagnosis_text { type: text, 含义: 诊断描述(自由文本,医生写) }
diagnosis_codes { type: varchar(255), 含义: 诊断编码(ICD-10 或诊所自定义,逗号分隔,可空) }
treatment_plan { type: text, 含义: 治疗计划(自由文本 + 项目编号) }
treatment_codes { type: varchar(255), 含义: 治疗项目编码(逗号分隔,关联 tb_order_item.item_code) }
doctor_advice { type: text, 含义: 医嘱(自由文本,含 "3 个月后复查" 等下次安排) }
next_review_at { type: date, 含义: 建议下次复查日期(结构化字段,可空 — 有则优先于 doctor_advice 解析) }
image_ids { type: varchar(255), 含义: 关联影像 id(逗号分隔) }
submitted_at { type: datetime, 含义: 病历提交时间 }
quality_status { type: varchar(8), 含义: 质控状态,
枚举: { DRAFT: 草稿, SUBMITTED: 已提交, REJECTED: 打回, APPROVED: 通过 } }
reject_reason { type: varchar(255), 含义: 打回原因(quality_status=REJECTED 时填) }
created_at { type: datetime }
updated_at { type: datetime }
is_deleted { type: tinyint }
关联:
- encounter_id → tb_encounter.id (一对一)
- patient_id → tb_patient.id
# ─────────────────────────────────────────────
# 5. 影像 metadata(不含文件本体)⭐
# ─────────────────────────────────────────────
tb_image:
含义: 影像 metadata(PAC 不消费文件本体,只看 metadata + 医生所见)
字段:
id { type: bigint, 含义: 主键 }
patient_id { type: bigint }
encounter_id { type: bigint, 含义: 关联接诊(可空,术前独立拍片可没绑接诊) }
image_type { type: varchar(16), 含义: 影像类型,
枚举: { PA: 根尖片, BW: 咬合翼, PANO: 全景片, CBCT: CBCT, SCAN: 口扫, PHOTO: 口内照, FACE: 面相 } }
tooth_positions { type: varchar(64), 含义: 涉及牙位(FDI 编号,逗号分隔,如 "16,17") }
finding { type: text, 含义: 影像所见(医生描述,自由文本) }
captured_at { type: datetime, 含义: 拍摄时间 }
doctor_id { type: bigint, 含义: 操作医生 }
file_url { type: varchar(512), 含义: 文件 URL(PAC 不取,记录用,可置空) }
created_at { type: datetime }
updated_at { type: datetime }
is_deleted { type: tinyint }
关联:
- patient_id → tb_patient.id
- encounter_id → tb_encounter.id(可空)
# ─────────────────────────────────────────────
# 6. 收费单主表 + 明细
# ─────────────────────────────────────────────
tb_charge_order:
含义: 收费单主表(医嘱/治疗/耗材/药品的开单)
字段:
id { type: bigint, 含义: 主键 }
order_no { type: varchar(32), 含义: 收费单号 }
patient_id { type: bigint }
encounter_id { type: bigint, 含义: 关联接诊(可空) }
clinic_id { type: bigint }
doctor_id { type: bigint, 含义: 开单医生 }
order_status { type: varchar(8), 含义: 收费单状态,
枚举: { DRAFT: 草稿, SUBMITTED: 已提交待付, PARTIAL: 部分收款, PAID: 已付清, VOID: 作废, REFUND: 已退 } }
total_amount { type: decimal(10,2), 含义: 应收总额(单位见 amount_unit) }
paid_amount { type: decimal(10,2), 含义: 已收金额 }
submitted_at { type: datetime, 含义: 开单提交时间 }
created_at { type: datetime }
updated_at { type: datetime }
is_deleted { type: tinyint }
关联:
- patient_id → tb_patient.id
- encounter_id → tb_encounter.id(可空)
tb_order_item:
含义: 收费单明细(一个收费单 1:N 条明细;按类型区分治疗/耗材/药品)
字段:
id { type: bigint }
order_id { type: bigint, 含义: 关联 tb_charge_order.id }
item_type { type: varchar(8), 含义: 明细类型,
枚举: { TX: 治疗, MAT: 耗材, DRUG: 药品 } }
item_code { type: varchar(32), 含义: 项目编码(诊所内部) }
item_name { type: varchar(128), 含义: 项目名称(如 "33 号种植体") }
treatment_category { type: varchar(8), 含义: 治疗大类(item_type=TX 时填),
枚举: { ENDO: 根管, CROWN: 烤瓷冠, ORTHO: 正畸主治, IMP: 单颗种植, PERIO: 牙周, CLEAN: 洁牙, EXT: 拔牙, FILL: 补牙, OTHER: 其他 } }
tooth_position { type: varchar(16), 含义: 牙位(FDI,可空) }
quantity { type: int }
unit_price { type: decimal(10,2) }
amount { type: decimal(10,2), 含义: 小计 }
created_at { type: datetime }
关联:
- order_id → tb_charge_order.id(一对多)
# ─────────────────────────────────────────────
# 7. 收款流水
# ─────────────────────────────────────────────
tb_payment:
含义: 收款流水
字段:
id { type: bigint }
pay_no { type: varchar(32), 含义: 收款单号 }
order_id { type: bigint, 含义: 关联收费单(可空,杂收时可空) }
patient_id { type: bigint }
clinic_id { type: bigint }
amount { type: decimal(10,2), 含义: 实收金额(正数=收款 / 负数=退款) }
pay_method { type: varchar(16), 含义: 支付方式,
枚举: { CASH: 现金, WX: 微信, ALI: 支付宝, CARD: 银行卡, RECHARGE: 储值卡扣款, INSURE: 医保, OTHER: 其他 } }
paid_at { type: datetime, 含义: 实际支付时间 }
operator_id { type: bigint, 含义: 经办人 }
created_at { type: datetime }
is_deleted { type: tinyint }
关联:
- order_id → tb_charge_order.id(可空)
- patient_id → tb_patient.id
# ─────────────────────────────────────────────
# 8. 充值 / 储值 / 福利卡(🟠 强烈推荐)
# ─────────────────────────────────────────────
tb_recharge:
含义: 充值入账 / 储值卡 / 福利卡办理记录
字段:
id { type: bigint }
patient_id { type: bigint }
clinic_id { type: bigint }
recharge_type { type: varchar(8), 含义: 类型,
枚举: { CASH: 现金充值, GIFT: 福利赠送, MEMBER: 会员卡, REFUND_BACK: 退款转存 } }
amount { type: decimal(10,2), 含义: 充值金额 }
bonus_amount { type: decimal(10,2), 含义: 赠送金额(充 1000 送 200 的 200) }
balance_after { type: decimal(10,2), 含义: 充值后余额 }
recharged_at { type: datetime }
operator_id { type: bigint }
created_at { type: datetime }
is_deleted { type: tinyint }
关联:
- patient_id → tb_patient.id
# ─────────────────────────────────────────────
# 9. 投诉记录(🟠 强烈推荐 — 合规硬约束)
# ─────────────────────────────────────────────
tb_complaint:
含义: 患者投诉
字段:
id { type: bigint }
patient_id { type: bigint }
related_enc_id { type: bigint, 含义: 关联接诊(可空) }
complaint_type { type: varchar(16), 含义: 投诉类型,
枚举: { SERVICE: 服务态度, QUALITY: 治疗质量, PRICE: 价格争议, ENV: 环境, OTHER: 其他 } }
content { type: text, 含义: 投诉内容 }
severity { type: varchar(8), 含义: 严重程度,
枚举: { LOW: 一般, MED: 较重, HIGH: 严重(可能涉法律) } }
status { type: varchar(8), 含义: 处理状态,
枚举: { OPEN: 待处理, IN_PROGRESS: 处理中, CLOSED: 已结案 } }
resolution { type: text, 含义: 处理结果 }
filed_at { type: datetime, 含义: 投诉提交时间 }
resolved_at { type: datetime, 含义: 结案时间(可空) }
created_at { type: datetime }
is_deleted { type: tinyint }
关联:
- patient_id → tb_patient.id
- related_enc_id → tb_encounter.id(可空)
# ─────────────────────────────────────────────
# 10. 转介绍记录(🟠 强烈推荐)
# ─────────────────────────────────────────────
tb_referral:
含义: 老带新 / 转介绍归因
字段:
id { type: bigint }
referrer_id { type: bigint, 含义: 介绍人 patient_id }
referee_id { type: bigint, 含义: 被介绍人 patient_id }
referral_channel { type: varchar(16), 含义: 介绍渠道,
枚举: { WORD: 口碑, WX: 微信分享, ACTIVITY: 活动邀约, STAFF: 内部员工, OTHER: 其他 } }
reward_type { type: varchar(8), 含义: 奖励类型,
枚举: { NONE: 无, CASH: 现金, COUPON: 优惠券, RECHARGE: 储值, SERVICE: 服务券 } }
reward_amount { type: decimal(10,2), 含义: 奖励金额 }
referred_at { type: datetime, 含义: 介绍发生时间 }
converted_at { type: datetime, 含义: 被介绍人转化(首诊)时间(可空) }
created_at { type: datetime }
is_deleted { type: tinyint }
关联:
- referrer_id → tb_patient.id
- referee_id → tb_patient.id
# ─────────────────────────────────────────────
# 11-14. 可选(有就给)— 退费 / 咨询 / 挂号 / 接待登记
# ─────────────────────────────────────────────
# 同上格式,按宿主方实际表结构填即可。
# tb_refund / tb_consultation / tb_visit_registration / tb_visit_reception
```
---
title: 数据请求清单
description: 发给宿主开发的存量历史数据导出工作单,包含数据类别、上下文配置与数据字典要求。
icon: ListChecks
---
> 这份是宿主接入的**存量数据通道**。增量通道(Pull / Push)另见 [接入总览](/docs/integration/overview) §2。**存量 + 增量两者都做才算正式接入**。
> **一句话**:把下面几类业务数据按宿主方的表**原样 `SELECT *` 导出 CSV/JSON**,配一份数据字典(每个字段什么意思),不用 JOIN、不用改字段名、不用建 JSON,PAC 这边写代码做翻译聚合。
---
## 1. 需要的数据类别
> 一类可能对应宿主方多张表(主表 + 明细表),按宿主方实际结构原样导出。**诊断 / 治疗一般没有单独表,都在电子病历 / 影像里 — 这两个反而是重点。**
### 🔴 必填(召回算法核心)
| # | 类别 | 业务含义 | 可能对应的表(供宿主方核对) |
| --- | -------------- | ----------------------------------------- | ----------------------------------- |
| 1 | **患者主档** | 患者身份信息 | `tb_patient` / `tb_customer` |
| 2 | **预约** | 预约记录 + 状态变更 | `tb_appointment` / `tb_booking` |
| 3 | **接诊** | 医生看诊主表 | `tb_encounter` / `tb_visit` |
| 4 | **电子病历 EMR** ⭐ | 诊断 / 治疗内容主要载体(自由文本 / 结构化字段 都行) | `tb_emr` / `tb_medical_record` |
| 5 | **影像** ⭐ | X 光 / CBCT / 口扫 **metadata + 描述**(不要文件本体) | `tb_image` / `tb_xray` |
| 6 | **收费单(医嘱)** | 收费单 + 明细 | `tb_charge_order` + `tb_order_item` |
| 7 | **收款** | 收款流水 | `tb_payment` / `tb_settle` |
### 🟠 强烈推荐
| # | 类别 | 缺则影响 |
| --- | --------------- | ------------------ |
| 8 | 充值 / 储值 / 福利卡 | LTV 严重失真,无法识别 VIP |
| 9 | 投诉记录 | 合规硬约束 — 不能给投诉过的人推销 |
| 10 | 转介绍记录 | 渠道归因空白 |
### 🟡 可选(有就给)
退费 / 咨询 / 挂号 / 接待登记
### 每张表必带的列(所有 Pull 源通用)
不管走文件 / ClickHouse / REST,每张表 / 每行都要能给出这几列(**名字随你,字典里指明即可**):
| 列 | 作用 | 缺失后果 |
|---|---|---|
| 业务主键(如 `*_id`)| 幂等去重的锚 | 重复入库 |
| 患者外键(如 `patient_id`)| 挂到患者 | 召回挂不上人 |
| 诊所列(如 `clinic_id`)| 数据隔离 + 定位发生地 | 隔离失守 |
| 命名空间列(如 `brand`)| **多品牌集团必给** —— PAC 据此派生 `source_unit` 给患者号消歧(同号不同品牌 = 不同人);单一品牌可省 | 跨品牌撞号 |
| 更新时间列(如 `updated_at`)| 增量游标基准:首次全量、之后只取它变大的行 | 没有只能每次全量 |
---
## 2. 必答的两个上下文
```yaml
amount_unit: <yuan / fen> # 金额数值单位
timezone: <Asia/Shanghai> # IANA 时区,解释无时区的 datetime
```
---
## 3. 数据字典(请配一份)
每张表给一段说明,**中文 / Excel / Markdown 都行**,包含:
| 项 | 说明 |
|---|---|
| 表名 + 中文含义 | `tb_encounter` = 接诊主表 |
| 字段 + 类型 + 中文含义 | `enc_status varchar(2)`:接诊状态 |
| 表关联(FK / 业务约定)| `tb_emr.encounter_id → tb_encounter.id` |
| **枚举字段的可能值含义** ⚠️ | `enc_status:00=新建 / 01=进行中 / 02=完成 / 03=作废` |
| 软删除字段(如有)| `is_deleted=1 表示已删除` |
⚠️ **枚举值是关键** — 编码值(`01` / `02` / `A01` ...)必须列出含义,否则 PAC 只能猜。
📄 **详尽示例参考**:[数据字典示例](/docs/integration/data-dictionary) — 覆盖 10 张主要表的完整模板,可对照填。
> **pull / push 都吃这一份字典**:pull 路径下 PAC 按字典写 AssemblerEngine yaml 翻译字段;push 路径下 `rawPayload` 是宿主方原生形态,parser 仍需字典理解字段含义。**写一份,两路通用**。
---
## 4. 数据规模
**接入时约定的试点诊所全量数据**(通常 3-5 家;具体诊所清单对接时给)。
---
## 5. 脱敏
按宿主方合规要求处理(姓名 / 手机号 / 身份证等)。**业务字段(诊断 / 治疗 / 状态 / 金额 / 业务 id)请保留原值**,否则召回算法无法工作。
---
## 6. 常见疑问
| 问 | 答 |
| ----------------------------- | -------------------------------------------------------- |
| 字段命名不规范 / 字段名是中文 | 行,字典写清意思即可 |
| 一类数据分散在多张表(如患者主档 + 联系方式 + 标签) | **每张原样导**,字典写清 `patient_id` 等关联键 — PAC 合并,不要宿主方 JOIN |
| 表之间没 FK,只有业务约定关联 | 字典里说"用 X 字段关联"即可 |
| 示例 yaml 里的字段宿主方没有 / 不一样 | 示例只是格式参考,**不是 PAC 定死字段** — 按宿主方实际表字段导出即可,字典写清意思就行 |
| 这次给一份就够,还是要持续更新 | **存量数据这次给一份全量**(过去 24-36 个月);后续**增量**走 Pull / Push API,届时再约 |
| 字典格式有要求吗 | 无 — Markdown / Excel / YAML / 文档表格都行,讲清楚就好 |
---
## 7. 接入必问(避免返工)
> 这几条是历次接入踩出来的坑,**对接时先问清能省大量返工**。原则:宿主只「提供数据 + 解释含义 + 给更新时间列」,**不改 schema / 命名 / 码**。
| # | 必问 | 为什么 |
|---|---|---|
| 1 | **业务 ID 唯一边界**:`patient_id` 等在 host / brand / clinic 哪个粒度内唯一? | 跨某维度才唯一(如多品牌共用一套 id)→ 得靠那个维度区分,否则两个人会被当成同一个 |
| 2 | **每张表有「更新时间」列吗?** ETL 写入时刷新? | 增量同步靠它(游标);没有只能全量 |
| 3 | **view 还是物理实表?** ETL 频率(实时 / 小时 / T+1)? | 决定实时性 + 重摄策略 |
| 4 | **分类字段有稳定语义码吗?**(诊断 / 治疗 / 结算)还是只给中文 name? | 只给中文 name → 写法五花八门、识别准确率低、长尾要持续补;有稳定码(ICD / 项目字典)从源头解决 |
| 5 | **写入是 upsert 当前态,还是 append 事件流?** 状态变更留痕吗? | upsert 只剩终态(中间态丢失);行为分析 / 动作流需 append |
| 6 | **时间字段粒度**:日期与时分是否分列?「约定时刻」与「实际发生」分得清吗? | 只取日期会让同日多次操作「看着重复」 |
| 7 | **「已做治疗」的凭据**:哪张表算真做了(结算 / 收费)?带牙位吗? | 结算无牙位 → 召回排除只能患者级(粗化);推动补 `tooth_position` |
| 8 | **子表**:嵌套 Array 还是 JOIN 炸行? | 炸行 PAC 能兜底去重,但更希望 ETL 侧修(嵌套成数组 / 给明确主键)|
| 9 | **字段含义靠数据推,不靠命名**:字段名 / 注释常误导 | 举例:字面像「日期」的列可能是结算操作日(会延后),真正的接诊 / 治疗时间藏在另一列 —— 需按数据分布确认,不能照名字猜 |
---
## 8. 不在数据通道范围内的事(所有 Pull 源通用)
数据通道(文件 / ClickHouse / REST / Push)**只覆盖"把业务数据给 PAC"那一段**,下面三件是独立链路,跟选哪种通道无关:
| 项 | 渠道 |
|---|---|
| 鉴权换票(宿主 → PAC iframe 登录态)| [登录授权](/docs/integration/auth-login) |
| iframe 嵌入(客服界面) | [接入总览 §5](/docs/integration/overview) |
| actionUrls deep-link(跳回宿主 UI) | [接入总览 §4](/docs/integration/overview) |
---
**联系**:字段含义 / 数据完整性疑问,直接找 PAC 接入对接人。
---
title: 执行结果回调
description: PAC 把客服召回执行结果实时回调给宿主的出站 webhook。
icon: BellRing
---
> **状态:规划中(v2 实时回执),接口待实现。** 字段以最终 OpenAPI 定义为准。
> 客服在 PAC 工作台填写召回执行结果(是否打通、结论、备注等)后,PAC 主动**回执**给宿主,用于同步宿主侧 CRM / 工单。需要此能力的宿主在接入时注册回执地址即可,不需要的可不注册。
---
## 方向与流程
```
客服在 PAC 提交执行结果
→ PAC 状态机更新
→ PAC 主动 POST 宿主回执地址(HMAC 签名)
→ 宿主返回 2xx 确认;非 2xx / 超时 → PAC 退避重试
```
回执方向与登录换票相反:登录是宿主调 PAC,回执是 **PAC 调宿主**。
## 接入前提
接入时向 PAC 提供:
- **回执地址**(`callbackUrl`):宿主侧接收 POST 的 URL;
- **验签密钥**:PAC 用它对请求体签名,宿主据此验签(密钥仅双方持有)。
## 请求:`POST {callbackUrl}`
请求头:
| 头 | 说明 |
|---|---|
| `X-PAC-Event-Id` | 事件唯一 id(= 请求体 `eventId`),**幂等键** |
| `X-PAC-Signature` | `HMAC-SHA256(rawBody, 验签密钥)` 的十六进制;宿主用同密钥重算比对 |
请求体:
```jsonc
{
"eventId": "...", // 幂等键,宿主据此去重
"patientExternalId": "...", // 宿主侧患者 id(关联用)
"operatorUserId": "...", // 宿主侧客服 id
"channel": "phone", // phone | wecom | sms | other
"outcome": "refused", // 执行结论,见下
"notes": "...", // 通话纪要(可选)
"scheduledNextAt": "2026-07-01T02:00:00Z", // 约定下次回访时间(可选)
"planStatus": "abandoned", // 回执时该召回工单的状态
"occurredAt": "2026-06-30T08:12:00Z" // 提交时间(ISO 8601)
}
```
**`outcome`** — 客服选择的执行结论(常见值):
| 值 | 含义 | 对 `planStatus` 的影响 |
|---|---|---|
| `success_appointed` | 成功转化为新预约 | `completed` |
| `refused` | 明确拒绝 | `abandoned` |
| `external_treatment` | 已在外院治疗 | `abandoned` |
| `declined_recent` | 近期不考虑 | 维持(冷静期后再召) |
| `scheduled_next` | 约定下次回访 | 维持 |
| `no_answer` / `quick_hangup` | 未接通 / 接通即挂 | 维持 |
> 完整取值以 OpenAPI / `ExecutionOutcome` 枚举为准。
**`planStatus`** — `active`(在召回池)/ `assigned`(已分配)/ `completed`(结案)/ `abandoned`(关闭)。
## 宿主响应与重试
- 宿主收到后**尽快返回 2xx**(建议先落库再异步处理),即视为投递成功。
- 非 2xx 或超时 → PAC 按退避策略重试;多次失败入死信并告警。
- 投递语义为 **at-least-once**:宿主**必须按 `eventId` 去重**(同一 `eventId` 可能到达多次)。
- 验签失败的请求应拒绝(返回 4xx)。
{
"title": "接入",
"icon": "Cable",
"pages": [
"overview",
"data-request-checklist",
"data-dictionary",
"channel-rest",
"channel-clickhouse",
"channel-push",
"auth-login",
"execution-callback",
"runbook"
]
}
---
title: 接入总览
description: 从宿主开发视角概述 PAC 对接的整体清单、鉴权、数据通道与 iframe 嵌入方式。
icon: Plug
---
> **一句话**:PAC 接收宿主方的患者业务数据,产出召回任务,**客服界面由 PAC 以 iframe / 独立链接形式提供**,宿主方自由放进自己系统的合适位置。
> **宿主方工作**:① 把数据提供给 PAC(选一种 Pull 源,可选再加 Push) ② 嵌入 PAC iframe(由 PAC 提供链接)。
> **不需要**:写召回客服界面 / 调 PAC 业务 API / 学 PAC 内部概念 / 改字段命名 / 进 PAC 控制台。
---
## 1. 接入清单
| 项 | 必/选 | 时机 | 说明 |
|---|---|---|---|
| 数据通道:Pull(任一源)或 Push(§2,至少一种)| ✅ 必 | 对接期 | 给文件 / 开直连 / 实现 endpoint / 推事件 |
| 接入鉴权 — 换票(§3)| ✅ 必 | 对接期 | host backend 调 `/auth/token` 拿 JWT |
| 凭证管理 + 运维 | ✅ 必 | 持续 | apiKey / pushSecret 安全存储 + 轮换 |
| 存量历史导入(首次必做,§2)| ✅ 必 | 上线时 | 一次性历史数据 dump |
| 嵌入 PAC iframe(§5)| ✅ 必 | 上线时 | PAC 给链接,宿主方用 `iframe` 嵌入生产 UI |
| 提供 actionUrls 模板(§4)| ⚠️ 推荐 | 上线时 | 客服在 PAC iframe 内点"看患者档案"跳回宿主方 UI |
---
## 全局约定
### 响应信封与错误码
所有 API(PAC 出口 + 宿主 endpoint + push 入口)共用统一响应信封 —— **HTTP 恒 `200`**,业务结果看信封里的 `code`(非 0 = 错误)。信封结构、编码规则与全量码表见 [响应信封与错误码](/docs/error-codes)。
### 时间 / 金额归一化
宿主侧**接入时一次性提供两个配置**:
```yaml
timezone: Asia/Shanghai # IANA 时区,解释无时区的 datetime
amount_unit: yuan # yuan(元小数) / fen(分整数)
```
**所有入站路径**的时间和金额字段都**按这套配置由 PAC 归一化**,宿主侧**永远不用关心格式化** — 怎么存的怎么给就行。
PAC 出站 API 统一 ISO 8601 带时区 + 整数分。
---
## 2. 数据接入
摄入只有 **两种传输方向** —— **Pull**(PAC 主动取)/ **Push**(宿主主动推),叠加正交的 **存量(首次全量)/ 增量(持续)**。宿主按自己的数据形态选一种 Pull 源,可选再加 Push;三种 Pull 源的**数据形态完全一致**,共用同一份数据字典,切换零返工。
### 2.1 Pull —— PAC 主动取(主通道,3 种数据源任选其一)
| 数据源 | 宿主提供 | 存量 | 增量 | 详见 |
|---|---|---|---|---|
| **文件(CSV / JSON)** | 导出裸表文件 + 数据字典 | ✅ | 重新导出 | [数据请求清单](/docs/integration/data-request-checklist) |
| **数据库直连(ClickHouse)** | 开只读权限,PAC 直接 SQL 读数仓 | ✅ | ✅ 游标 | [CH 视图](/docs/integration/channel-clickhouse) |
| **REST API** | 实现资源 endpoint(REST GET + 游标) | ✅ | ✅ 游标 | [REST API 通道](/docs/integration/channel-rest) |
存量(首次无游标的全量,即业内 cold-import)与增量(游标)走**同一条 pull 路径**,只是有无游标之别。
> 现状:文件 / 直连已就绪;REST API 适配器 PAC 侧开发中(契约见 [REST API 通道](/docs/integration/channel-rest))。
### 2.2 Push —— 宿主主动推(可选增强)
宿主把 transaction 事件主动推给 PAC(HMAC 验签),天然秒级增量。**有事件总线 + 想秒级再做**,否则 Pull 已够。完整契约(两种形态、HMAC 验签、幂等、补推)见 [Push 通道](/docs/integration/channel-push)。
> **存量首次必做**:无论哪种源,接入时先把历史背景一次性灌进 PAC —— 种植 / 正畸 / 牙周等长周期场景,没有 24-36 个月历史无法工作。数据字典参考 [数据字典示例](/docs/integration/data-dictionary)。
---
## 3. 鉴权
换票流程、请求 / 响应字段、`role` 与 `orgScope`(数据权限)填法见 [登录授权](/docs/integration/auth-login)。要点:host backend 调 `POST /pac/v1/auth/token`(`appId` + `appSecret` + `user`)拿一次性 `code` → 前端用 code 打开 iframe(`code` 60s 有效;不要把 accessToken 放进 URL)。
### 3.1 凭证
| 凭证 | 用途 | 安全要点 |
|---|---|---|
| `appId` + `appSecret` | host backend 换 JWT | plaintext 一次性发给宿主方,PAC 永不回读 |
| `pushSecret`(可选)| Push 路径 HMAC | 同上;支持多 key 平滑轮换 |
| `apiKey`(宿主方颁发给 PAC)| PAC 调宿主方资源 API | 宿主方颁发,推荐 Bearer 形式 |
### 3.2 错误码
鉴权错误码(`101xx`)及全量码表见 [响应信封与错误码](/docs/error-codes)。
---
## 4. actionUrls(可选,deep-link 模板)
提供一组跳转 URL 模板,PAC 前端在召回卡片点"看患者档案"等按钮时跳回宿主方的 UI。
```jsonc
{
"VIEW_PATIENT": "https://host.example.com/patient/{patientId}",
"VIEW_EMR": "https://host.example.com/emr?patientId={patientId}",
"CREATE_APPOINTMENT": "https://host.example.com/appointment/new?patientId={patientId}",
"VIEW_XRAY": null, // null = 不支持,按钮不渲染
"CALL_PATIENT": null
}
```
**只提供宿主方能支持的**,不写或填 `null` 都行。占位符 `{patientId}` / `{appointmentId}` / `{emrId}` / `{clinicId}` / `{userId}` 按字面替换。
**跨域登录态**:推荐宿主方用 cookie 跨域共享(跳过去自动登录),或在跳转 URL 加短时 token。
---
## 5. iframe 嵌入(客服界面接入方式)
**现阶段唯一接入方式**:PAC 提供客服工作台 URL,宿主方用 `iframe` 嵌入自己系统合适位置(如客服后台某个 tab / 主管 dashboard 一块区域)。
召回列表、患者画像、话术、摘要、执行回写表单等所有客服操作 UI **由 PAC 提供**,宿主方不写也不调 PAC 业务 API。
### 5.1 嵌入步骤
1. host backend 跑换票流程(§3.1),拿到 `code`(一次性,60s)
2. iframe 的 `src` = PAC 工作台**路由** + `?code=`:
```html
<iframe
src="https://pac.example.com/plans?code=XXX"
sandbox="allow-scripts allow-same-origin allow-top-navigation"
style="width:100%; height:100%; border:0;">
</iframe>
```
3. PAC 前端读 `?code=` 换 access token、存本地、并把 code 从 URL 抹掉,之后自启动。再次进入时本地 token 仍有效可不带 code。
### 5.2 PAC 工作台路由(嵌哪个页面 = 用哪条路径)
`?code=` 在任意工作台路由上都能换票自启动;**嵌哪个页面就用哪条路径**:
| 路径 | 用途 |
|---|---|
| `/plans` | 客服召回任务列表 + 详情(默认)|
| `/plans/{planId}` | 直接打开某个召回任务 |
| `/assistant` | PAC 助手(AI 对话查患者,需 `AGENT_INVOKE` 权限)|
### 5.3 嵌入约束
- 浏览器 CSP:宿主方配置 `frame-ancestors <宿主方 UI 域名>`(PAC 接入时约定)
- iframe `sandbox` 最小权限(上面示例已含)
- **不依赖 postMessage** 跟 PAC 通信 — 状态全走 PAC 后端,iframe 是纯渲染容器
- 客服界面响应式适配,移动端 / 桌面端均可
### 5.4 (可选)PAC 开放 REST API
完整 API 仅作 **未来扩展 / 运维 / 第三方对接** 预留,现阶段宿主方不需要调。各 endpoint 见侧栏 **API 参考**(OpenAPI 自动生成、按模块分组:auth / plan / push / sync …),或线上 Swagger 同源:`https://<pac-host>/api/docs`。
---
## 6. 接入流程 + Checklist
### 6.1 4 阶段
| Phase | 宿主方做 | 验证 |
|---|---|---|
| **1. 注册** | 提供联系人 / 试点 clinic 清单 / 接入模式选择 | PAC 团队跑 CLI 创 host,回发 appId + appSecret |
| **2. 联调** | 走 cold-import 先跑通 → 再开 pull / push | `failed=0, facts>0` |
| **3. 灰度** | 1 家试点 clinic 开放生产数据 + iframe 嵌入到客服界面 + 客服培训 | 24h 数据流入,前 100 召回业务方点评 |
| **4. 全量** | 其他试点 clinic 接入 + 凭证轮换演练 | 全量数据无严重异常 |
> PAC 团队侧的对应操作步骤见 [接入 Runbook](/docs/integration/runbook)。
### 6.2 验证 Checklist(交付前自查)
通道中立的上线前自查;各通道的具体验收项(必带列 / endpoint / 游标语义等)见对应通道文档。
#### 必填
- [ ] 鉴权:PAC 发的 `appId` + `appSecret` 已安全存储,换票跑通(§3)
- [ ] 数据通道任选其一并跑通存量 cold-import(`failed=0, facts>0`)
- [ ] 时区 + 金额单位配置已确认(§全局约定)
- [ ] 试点 clinic 字典 + 源组织命名空间(品牌)已提交
- [ ] iframe 已嵌入,`frame-ancestors` 配置生效(§5.3)
- [ ] 联系人 + oncall 渠道开通
#### 可选
- [ ] REST API 通道:增量游标自查见 [REST API 通道](/docs/integration/channel-rest)
- [ ] Push:HMAC 验签跑通(契约见 [Push 通道](/docs/integration/channel-push))
- [ ] actionUrls 模板提交(§4)
- [ ] 跨域单点登录方案确认
---
## 7. SLA + 运维
| 项 | 宿主方责任 | PAC 责任 |
|---|---|---|
| 凭证 | 安全存储 appSecret / pushSecret;按期轮换 | 提供轮换 CLI |
| 数据完整性 | 增量游标单调、关键资源逻辑删除(走直连 / REST 时)| 对账兜底 |
| API 可用性 | 资源 API ≥ 99.5% / month(如实现 REST 通道)| PAC 服务 ≥ 99.5% / month |
| 字段 / schema 变更 | **提前 2 周通知** PAC | 同步调整接入映射 |
| 故障 | 7×24 oncall 联系方式 | 同上 |
---
**联系**:对接细节 / 字段含义 / 故障排查,直接找 PAC 接入对接人。
---
title: 接入 Runbook
description: 接入新 Host 时 PAC 团队侧需要执行的全部操作步骤,含 host 注册、cold-import 联调与运维场景。
icon: Server
---
> **场景**:有新宿主要接入 PAC,本文档列 PAC 团队侧需要做的全部操作步骤。
> **配套文档**:
> - 给 host 看的接入契约 → [接入总览](/docs/integration/overview)
> - 冷数据装载 / 标准化管道 → [数据摄入](/docs/architecture/data-ingestion)
> - 数据三层结构 → [三层数据结构](/docs/architecture/three-layer-model)
---
## 0. 当前 PAC 侧能力速查(2026-06-30)
| 能力 | 状态 | 接口 / 工具 |
|---|---|---|
| **Auth 换 JWT** | ✅ | `POST /pac/v1/auth/token` |
| **Host 管理(create/rotate/deactivate)** | ✅ | `pnpm pac:host <cmd>` CLI(走 SSH,不走 HTTP)|
| **Host 自助 admin(读 + 受限更新 + 轮换)** | ✅ | `GET / PATCH /pac/v1/admin/host/self` + `POST .../rotate-app-secret`、`.../push-secrets` |
| **AssemblerEngine — yaml 驱动 host→canonical 投影** | ✅ | `data/<host>/assemblers/*.yaml` + `AssemblerEngine` |
| **Sync 装载脱敏数据(首跑全量/日级增量统一)** | ✅ | `pnpm sync -- --dir=...`(cold-import = legacy 全量 alias)|
| **Patient timeline 可查** | ✅ | `GET /pac/v1/patients/:id/timeline` + `pnpm timeline -- --pid=...` |
| **Drift 单条识别** | ✅ | `ContractDriftError` + `[drift]` 日志前缀 |
| **数据建模能力** | ✅ | **23 action / 16 subjectType / 16 fact_type / 33 personaFeatureKey / 14 outcome**(枚举见 [事件枚举](/docs/event-enums))|
| **Push 接收 + HMAC 验签** | ✅ | `POST /pac/v1/push/events` + `/push/rows`(两形态)+ HMAC 验签 |
| **Persona / Plan engine** | ✅ | persona features(规则 extractor)+ plan engine / scenarios |
| **批量 Pull cron / manual** | ❌ STUB | `SyncService.runPull` 抛 `NotImplementedException`;**单患者刷新已实装**(`runPullForPatient`)|
| **Reconcile 对账 / Quarantine 表** | ⚠️ 部分 | `ReconcileOrchestrator` 已建,依赖真 pull adapter(目前仅 `MockPullStrategy`)才生效 |
---
## 1. 接入流程(PAC 团队步骤)
> 对应 [接入总览](/docs/integration/overview) §6 —— host 视角看到的同一条流程。
### Phase 1:Host 注册(走 CLI,**不走 HTTP**)
敏感操作(create / rotate / deactivate)**只走 CLI**,SSH 到 PAC 服务器 + DB 直连。
理由:host 注册一年几次,低频高重要;web 鉴权 + PAC 内部用户系统是 over-engineering。
```bash
# SSH 到 PAC 生产/staging 服务器
cd /apps/pac-service
# 注册新 host
pnpm pac:host create --name=friday --push --amount-unit=yuan --timezone=Asia/Shanghai
# 输出(plaintext 密钥仅 stdout 一次性显示)
# ✓ Created host "friday"
# id: a1b2c3d4-...
# appId: pac_friday_x7k9m2lp
# appSecret: <plaintext, 仅显示一次>
# pushSecret: <plaintext, 仅显示一次>
# active: true
#
# ⚠️ 立即把 appSecret / pushSecret 存到 1Password 共享空间 "PAC-Production/friday",
# PAC 不再保留 plaintext,失落只能 rotate。
```
**CLI 命令清单**:
```bash
pnpm pac:host list # 列出所有 host
pnpm pac:host get <id-or-name> # 单个详情
pnpm pac:host create --name=<slug> ... # 注册新 host
pnpm pac:host rotate-secret <id-or-name> # 轮换 appSecret(旧立即失效)
pnpm pac:host rotate-push <id-or-name> # 轮换 pushSecret(数组前插,grace)
pnpm pac:host deactivate <id-or-name> # 软停(auth 立即拒绝)
```
**安全模型**:
| 控制项 | 实现 |
|---|---|
| 谁能跑 CLI | SSH 到 PAC 服务器(SSH key 限名单)|
| DB 凭证保护 | `.env` 里 `DATABASE_URL` 只生产节点可读 |
| 操作审计 | SSH 登录日志 + DB binlog;后续可加 `pac_admin_actions` 表 |
| Plaintext 密钥 | CLI 输出 stdout,运维即看即存 1Password,不入日志 |
**HTTP `/admin/host/self`** —— host **自助**查看 / 受限更新自己(不是 admin 管所有 host):
- `GET /admin/host/self` 自身信息 · `GET /admin/host/self/stats` 统计
- `PATCH /admin/host/self` 改 actionUrls / pullConfig 等(不改密钥)
- `POST /admin/host/self/rotate-app-secret`、`POST .../push-secrets`、`DELETE .../push-secrets/:suffix` —— host 自助轮换密钥
**create / deactivate 仍只走 CLI**(`pnpm pac:host`,SSH)。
### Phase 2:发凭据给 host(线下 / 安全通道)
把 `appId` + `appSecret`(+ `pushSecret` 如果启用 push)通过加密邮件 / 1Password 等发给 host backend 团队。
Host 拿到后即可:
```bash
# host 验证凭据可用
curl -X POST http://pac.example.com/pac/v1/auth/token \
-H 'Content-Type: application/json' \
-d '{
"appId":"pac_friday_a1b2c3d4",
"appSecret":"IYPRA...",
"user":{"userId":"u_kefu_001","tenantId":"t_friday_grp1","role":"staff","orgScope":["clinic_a"]}
}'
# → 拿到 host 侧用户的 JWT
```
### Phase 3:host 给裸表 + 数据字典 → PAC 写 assembler.yaml ⭐
**这是 v2 的关键步骤**(替代 v1 的"录 fieldMapping")。host 提供裸表 + 数据字典的契约见 [数据请求清单](/docs/integration/data-request-checklist)。
3.1 Host 给 PAC 一个**目录**:
```text
friday-export-20260513.zip
├── README.md ← 数据字典(每张表 + 每个字段 + 枚举值含义)
├── ── 必填 5 类(召回核心)──
├── tb_patient.csv ← SELECT * 原样
├── tb_appointment.csv
├── tb_encounter.csv ← + tb_dx.csv(诊断)+ tb_tx.csv(治疗)三表 1:N 嵌套
├── tb_dx.csv
├── tb_tx.csv
├── tb_charge_order.csv ← + tb_order_item.csv(明细)1:N
├── tb_order_item.csv
├── tb_payment.csv
├── ── 🟠 强烈推荐 3 类(产品收集 — 业务方明确指出关键)──
├── tb_recharge.csv ← 充值/储值/福利卡(LTV 必备,中国民营牙科核心)
├── tb_complaint.csv ← 投诉记录(合规营销避雷)
├── tb_referral.csv ← 转介绍记录(渠道归因)
└── ── 🟡 可选(有就给)── tb_refund / tb_consultation / tb_emr_document / ...
```
3.2 PAC 团队基于数据字典写 yaml:
```bash
cd apps/pac-service
mkdir -p data/friday/assemblers/
# 写 N 份 assembler.yaml(每个 canonical 资源一份)
# 模板见 data/jvs-dw/assemblers/*.yaml
```
每份 assembler.yaml 描述:
- `primary`:host 的主表名
- `field_mapping`:host 字段 → PAC canonical 字段
- `join_arrays`:1:N 嵌套(如 tb_encounter ← tb_dx[]/tb_tx[])
- `enum_mapping`:host 编码(`A01`)→ PAC 枚举(`endodontic`)
3.3 写 `manifest.yaml`:
```yaml
host_name: friday
tenant_id: t_friday_grp1 # 单集团静态 tenant;多品牌 host 改用 tenant_field + identity_namespace_field(源 brand 列 → source_unit)
amount_unit: yuan
timezone: Asia/Shanghai
tables:
# 必填 5 类
- { table: tb_patient, file: tb_patient.csv }
- { table: tb_encounter, file: tb_encounter.csv }
- { table: tb_dx, file: tb_dx.csv }
- { table: tb_tx, file: tb_tx.csv }
- { table: tb_appointment, file: tb_appointment.csv }
- { table: tb_charge_order, file: tb_charge_order.csv }
- { table: tb_order_item, file: tb_order_item.csv }
- { table: tb_payment, file: tb_payment.csv }
# 🟠 产品收集(host 能给则导)
- { table: tb_recharge, file: tb_recharge.csv }
- { table: tb_complaint, file: tb_complaint.csv }
- { table: tb_referral, file: tb_referral.csv }
assemblers:
- { file: assemblers/patient.yaml }
- { file: assemblers/clinical_encounter.yaml }
- { file: assemblers/appointment.yaml }
- { file: assemblers/charge_order.yaml }
- { file: assemblers/payment.yaml }
# 🟠 产品收集(对应 canonical resource:recharge / complaint / referral)
- { file: assemblers/recharge.yaml }
- { file: assemblers/complaint.yaml }
- { file: assemblers/referral.yaml }
```
### Phase 4:cold-import 联调
```bash
cd apps/pac-service
# 先 dry-run 看翻译效果(不写库)
pnpm sync -- --dir=./data/friday --dry-run
# 真写库(首跑 cursor 空 = 全量;cohort 分批 + 可并行,内存恒定)
pnpm sync -- --dir=./data/friday --no-recompute
# 榨资源:PAC_COHORT_CONCURRENCY=3 pnpm sync -- --dir=./data/friday --no-recompute
# 首跑完手动补 persona/plan:
pnpm recompute-persona -- --host=friday && pnpm recompute-plans -- --host=friday
# 验证 timeline
pnpm timeline -- --host=friday --tenant=t_friday_grp1 --pid=<host pid>
# 跑召回 selector 自检:确认该 host 数据形态真能进召回(识别 K00-K09 子场景)
# ⚠️ 当前已无 `verify-scenarios` npm script;自检命令以代码为准(verify-chain 等),且基于 demo 数据,真实 host 需重写期望集
```
> **统一 sync 入口**:`pnpm sync` 首跑(cursor 空)= 全量,之后 cron 增量,同一套 `importDirectory`。
> `pnpm cold-import` 保留为 legacy「忽略 cursor 强制全量」alias。
**数据装载是 PAC 三大数据通道之一**(跟 pull/push 共用 AssemblerEngine + 下游 pipeline)。
host 持续导出 + PAC 日级 `pnpm sync` 增量(cron 自动)即可作为产线长期数据通道。
### Phase 5:开 pull 周期 / push 实时
- **Push 实时**:✅ **已实装**(`/push/events` + `/push/rows` + HMAC 验签)。host 接好即可推,契约见 [Push 通道](/docs/integration/channel-push)。
- **批量 Pull cron**:❌ 仍 STUB(`runPull` 未实装);单患者即时刷新已可用。
Phase 4 的 cold-import 跟 pull / push 走**同一套下游 pipeline**(AssemblerEngine → Synthesizer → Parser → patient_facts),所以接通新通道只需换"如何拿到 raw 数据"那一段(读文件 → HTTP 拉 / 收 webhook),中下游零改动,**写好的 assembler.yaml 直接复用**。
---
## 2. 运维场景(密钥失落 / push 密钥轮换 / 软停)
### 2.1 appSecret 失落 → 重发
```bash
pnpm pac:host rotate-secret friday
# → stdout 显示新 appSecret(plaintext 仅一次),旧立即失效;立即同步给 host
```
### 2.2 Push 密钥轮换(平滑过渡)
```bash
pnpm pac:host rotate-push friday
# → stdout 显示新 pushSecret + total_keys,数组前插旧 key 仍接受 grace
# host 切完 → 单独 DB 脚本清理旧 hash(本期不暴露专用 CLI)
```
### 2.3 软停 host(合同到期 / 安全事件)
```bash
pnpm pac:host deactivate friday
# → active=false,后续 auth 立即 10101 拒绝。
# 不删数据,patient_transactions / patient_facts / followup_plans 全留(审计需要)。
```
---
## 3. 接入完成判定标准
完成接入意味着:
- [ ] `pnpm pac:host create` 拿到新 host 的 id / appId / appSecret
- [ ] Host 用 appSecret 换出 JWT(code=0,有 accessToken)
- [ ] `pnpm sync -- --dir=<host 导出目录> --no-recompute` 全成功(failed=0,facts > 0)
- [ ] `pnpm timeline -- --pid=<host 某 patient>` 能看到完整 actual + planned 列表
- [ ] 10 子场景(K00-K09)selector 跑出来命中数 > 0(说明数据形态真的能进召回)
如果 sync 报 `[drift] xxx row rejected: <字段路径>` —— 那是 host 实际字段 / 枚举值跟 PAC 的 assembler.yaml 错位,调整对应 yaml 重跑即可(不需要 host 改任何东西)。
---
## 4. 数据从 host 拿到后的落点速查
```text
host 脱敏导出 → CSV/JSON 文件 + manifest.yaml
↓ scp / S3 / 邮件
data/<host>-<timestamp>/
↓ pnpm sync -- --dir=...
[1] hosts (Admin 早已录入)
[2] patients (upsert,主档)
[3] patient_transactions (append-only,raw_payload 留 host 原文)
[4] patient_facts (版本流,parser 衍生)
[5] sync_logs (该批 import 的账本:status/factsEmitted/duplicates/failed)
↓ GET /pac/v1/patients/:id/timeline
客服可读 timeline
```
后续 W3+ 引入 Persona / Plan / PlanExecution 后,fact 层之上的画像 / 召回计划自然展开。
{
"title": "PAC 文档",
"root": true,
"pages": [
"---了解 PAC---",
"start",
"---设计---",
"architecture",
"algorithms",
"design-system",
"---对接---",
"integration",
"---运维---",
"deployment",
"ingestion",
"monitoring",
"troubleshooting",
"---参考---",
"api",
"error-codes",
"event-enums",
"canonical-fields"
]
}
---
title: 监控
description: PAC 的数据源滞后监控、每日健康日报、队列健康与关键 SQL 检查。
icon: Activity
---
PAC 的运行态监控分四块:数据源滞后、每日健康日报、队列健康、关键 SQL。前两块的告警与日报经 `AlertService` 推送(配 `ALERT_WEBHOOK_URL`,支持企微 / 钉钉 / slack;留空则仅落 log)。
---
## 一、数据源滞后监控
`DwLagMonitorService` 的 `@Cron`(`PAC_LAG_MONITOR_CRON`,如 `0 * * * *` 每小时):
```
读最近一次 incremental_bundle 的 cursor_after
算 max(cursor) 距 now 的小时数
🟢 < 24h info 仅 log
🟡 24-48h warning log + 告警推送(数据源未刷新,提醒值班)
🔴 > 48h critical log + 告警推送(严重滞后,事件可能漏召)
```
阈值由 `PAC_LAG_WARN_HOURS` / `PAC_LAG_ERROR_HOURS` 控制。未配 `ALERT_WEBHOOK_URL` 时仅落 log,值班查 `journalctl -u pac-service | grep dw-lag`。
---
## 二、每日健康日报
`DailyHealthReportService` 的 `@Cron`(`PAC_HEALTH_REPORT_CRON`,默认 `0 9 * * *`,北京时)把分散监控收口成**一条**健康快照:数据源延迟 / 画像覆盖 / 摄入 / 重算 / 召回 / AI 用量,每项带近 24h 变化;整体级别取最差项,经 `AlertService` → `ALERT_WEBHOOK_URL` 推送。手动预览端点 `internal/health-report` 由 `PAC_HEALTH_REPORT_DEMO=1` 门控。
---
## 三、队列健康(Bull Board)
`/admin/queues`(需登录 admin)实时查看三条队列 —— `persona-recompute` / `plan-recompute` / `plan-asset-generate` 的处理速度、失败重试与死信。
---
## 四、关键 SQL 检查
```sql
-- 最近几次 sync 结果(应 success / failed=0)
SELECT to_char(started_at,'YYYY-MM-DD HH24:MI') AS t, resource, status, fetched, failed, error_message
FROM sync_logs ORDER BY started_at DESC LIMIT 5;
-- 数据规模
SELECT 'patients' AS k, count(*) FROM patients
UNION ALL SELECT 'facts', count(*) FROM patient_facts
UNION ALL SELECT 'active_plans', count(*) FROM followup_plans WHERE status IN ('active','assigned');
-- 数据滞后(与 lag monitor 同源)
SELECT to_char(started_at,'HH24:MI') AS sync_t, cursor_after
FROM sync_logs WHERE resource='incremental_bundle' AND status='success'
ORDER BY started_at DESC LIMIT 1;
```
---
title: 核心场景
description: PAC 当前的召回 —— 潜在治疗(应治未治):把「医生说该治、患者却没去治」的确定需求,系统化地捞回。
icon: Target
---
> 把「医生已经诊断或建议了某项治疗、患者却一直没去做」的患者,系统化地**找全、排序、配好话术**交给客服。
> 这类需求叫**潜在治疗(应治未治)** —— 临床上该做、商业上确定,而且已经发生在你自己诊所里。
---
## 为什么先做这个
牙科生意里,最贵的是拉新客;最便宜、最确定的增长,是把**已经进过门、已经被诊断、却没成交**的需求捞回来。这块「应治未治」:
- **需求是确定的** —— 不是猜患者想不想,是医生白纸黑字诊断 / 建议过。
- **已经发生在内部** —— 患者来过、诊断在系统里,捞回成本远低于拉新。
- **靠人一定会漏** —— 几万患者、跨门诊,靠医生 / 客服记得根本看不过来;一漏就是直接的收入与健康损失。
PAC 把这件事,从「靠人记得」变成「系统兜底」。
---
## 怎么定义「应治未治」
一个患者进召回池,要同时满足三个条件:
| 条件 | 含义 |
|---|---|
| **应治** | 有诊断、医生建议、或影像分析,指向某项该做的治疗 |
| **未治** | 之后一直没做对应的治疗(只是「计划要做」不算) |
| **可联系** | 在册、未拒访、在世 |
> 「应治」减去「未治」,剩下的就是**潜在治疗** —— 该做、却没做的那一块。
---
患者对一项治疗只有三种状态,PAC 现在只覆盖第一种:
- **现在只做**:**没开始治** —— 潜在治疗(应治未治)。
- **暂未做(留后续)**:
- **治了一半**:治疗做了一部分、后续步骤没跟上的。
- **治完要复查**:治疗完成后、该定期回来复查 / 维护的(如种植年度复查、正畸保持期)。
- 这两类代码里暂未启用。
延伸:[召回算法](/docs/algorithms/recall) · [产品概述](/docs/start/what-is-pac) · [业务价值](/docs/start/business-value)
---
title: 业务价值
description: 把患者数据从散乱的成本,变成集团可复用的判断力与行动力。
icon: TrendingUp
---
价值来自**三个结构性转变**,再被**两个放大器**放大。产品定义见 [产品概述](/docs/start/what-is-pac)。
---
## 三个结构性转变
### 1. 数据 → 资产:从「散在各系统」到「统一可信的患者事实」
- **现状**:同一患者的就诊、治疗、缴费散在各门诊、各系统,口径不一、脏数据多,集团看不清一个完整的人。
- **PAC**:把任何宿主的原始数据标准化成统一口径、不可变、可追溯的**事实账本**。
- **价值**:集团第一次能**跨门诊看清同一个患者**;数据可信、可审计、可被多场景复用。
### 2. 判断 → 自动化:从「靠人拍脑袋」到「可解释的自动评价」
- **现状**:该召回谁、为什么,靠客服经验和主管派单 —— 不可复制、不可审计,几万患者根本看不过来。
- **PAC**:一夜评完全量患者,给出价值与流失风险**画像** + **召回清单 + 优先级 + 召回原因**,每条带证据链。
- **价值**:判断**规模化**(几万人一次过)、**一致**(同一口径)、**可解释**(有原因和证据)、**可复制**(不绑定某个老员工)。
### 3. 行动 → 即取即用:从「准备耗时」到「上工即打」
- **现状**:客服翻档案、临场想话术耗时;新人靠老带新,上手慢。
- **PAC**:直接给**一页摘要 + AI 话术 + 客服侧 AI 助手**;执行结果回填,形成闭环。
- **价值**:客服时间从「准备」转向「触达」;新人快速即战力;话术统一、可迭代;每次结果回流让系统更准。
---
## 两个放大器
### A. 一次投入,多处复用
标准事实层 + 画像层**不只服务召回** —— 它是集团的长期数据底座。后续精准营销、客户分层、风险预警、主管 dashboard 都能直接建在上面,边际成本低。**召回是第一个出口,底座是长期资产。**
### B. 可追溯、可审计、主权清晰
每条召回都有**原因 + 证据链**;数据**主权清晰**(源数据归宿主,PAC 只持加工副本);集团之间硬隔离。让「为什么动这个患者」可解释、可复盘,降低合规与越界风险。
---
## 怎么衡量
价值**机制是确定的**,但具体数字要靠试点实测。盯这几个可观测指标(需业务方提供基线):
| 指标 | 它衡量什么 |
|---|---|
| **召回覆盖率** | 系统识别出的「该回未回」患者数 / 总量 —— 衡量「看不过来」被解决的程度 |
| **客服准备耗时** | 每单从「翻档案 + 想话术」到「上工即打」的下降 |
| **高客单捞回比** | 高价值项目(种植 / 正畸 / 根管)漏召回被重新捞回的比例 |
| **接通后转化 / 沉睡挽回** | 高价值优先 + 个性化话术的实际效果 |
| **新客服熟练时长** | 达到稳定产能所需时间 —— 衡量「可复制性」 |
---
## 一句话归纳
**PAC 的业务价值 = 把患者数据从「散乱的成本」变成「集团可复用的判断力与行动力」。**
数据成资产、判断自动化、行动即取即用;并且一次投入、多处复用,全程可追溯。
{
"title": "开始",
"icon": "BookOpen",
"pages": ["what-is-pac", "business-model", "business-value"]
}
---
title: 产品概述
description: PAC(Patient Analysis Center)是一套集团级的患者数据中枢。它把散落在各门诊系统里的患者就诊数据,收敛成一套统一、不可变的患者事实账本,在其上自动派生患者画像(客户价值 / 流失风险 / 沟通偏好等),再转化为客服可执行的回访触达(召回清单 + AI 话术),并把执行结果回流,持续变聪明。
icon: Sparkles
---
## 三层模型
从下到上三层,上层只依赖下层:
| 层 | 做什么 | 说明 |
|---|---|---|
| **事实** | 把**任何宿主**(5i5ya / Friday / 数仓…)的原始数据,标准化成统一口径的**患者事实时间线**(不可变、可追溯) | **底座** —— 数据脏、口径乱的活全在这里收口 |
| **画像** | 在标准事实上**自动评价每个患者**(价值 / 流失风险 / 怎么沟通) | 集团的**长期数据资产** |
| **触达** | 把画像变成"**该打谁、为什么打、怎么打**"的清单 + AI 话术,执行结果再回流 | 当前的主要出口 |
> **当前旗舰场景 = 老客户召回**(把"该回来但还没回来"的患者找出来,排好序、配好话术交给客服)。
> 但召回只是第一个出口 —— 标准化的**事实层 + 画像层可复用**于精准营销、客户分层、风险预警、主管 dashboard 等。
>
> 深入:[架构总览](/docs/architecture/overview) · [三层模型](/docs/architecture/three-layer-model) · [业务价值](/docs/start/business-value)
---
## PAC 的边界
按维度归纳「确定做」与「确定不做」,划清 PAC 与现有系统的职责。
| 维度 | ✅ 确定做 | ❌ 确定不做 |
|---|---|---|
| **数据主权与隔离** | 把宿主数据标准化成一份**不可变的派生副本**,并留存原始 payload 备查;集团(租户)**硬隔离**,互不可见 | 不充当源数据的**权威系统** —— 源数据主权归宿主;不跨集团串数据 |
| **数据范围** | 接收并标准化就诊、治疗、缴费、影像 metadata、病历结构化字段等**宿主侧事实** | 不当**病历系统** —— 完整临床病历(EMR)原文留在宿主 / 医生侧 |
| **事实与产物分层** | 画像、计划、AI 产物归 PAC,**独立存放、全链路可追溯** | 不把自身产物**回写成事实** —— 事实只装宿主侧事实,PAC 产物与事实流严格分层 |
| **智能产出** | 自动派生**患者画像**(价值 / 流失风险 / 沟通偏好,带证据链);生成**召回清单 + 优先级 + 召回原因 + AI 话术 + 一页摘要**;**客服侧 AI 助手**(工作台 / 企微问答,MCP 工具) | 不**自做影像 AI 诊断** —— 影像 finding 由宿主 / 三方产出,PAC 只摄入并降权使用 |
| **触达与执行** | 产出清单、话术、摘要供客服使用,并**接收执行结果回填**,驱动召回状态机 | 不负责触达的**具体执行** —— 何时、用什么渠道联系,由使用方决定 |
**协作模式**:PAC 是「大脑」,**host-可插拔**,通过契约跟现有业务系统协作 —— 不取代任何系统。
---
## 一句话归纳
**PAC = 集团级患者数据中枢:事实账本(标准化)→ 画像(看清)→ 触达(行动)。**
召回是当前的旗舰场景;**事实层 + 画像层是集团的长期资产**。不取代现有系统,通过契约协作。
延伸阅读:[核心场景](/docs/start/business-model) · [业务价值](/docs/start/business-value)
---
title: 故障排查与灾备
description: PAC 的数据备份、代码回滚、游标倒退与并发锁清理。
icon: LifeBuoy
---
> 下文 `$PAC_HOME` 指安装目录(部署时 `export PAC_HOME=...`)。
---
## 一、数据备份
```bash
# 每日定时全量备份(与 08:15 增量错峰,如 03:00)
pg_dump -U pac -d pac -F c -f /backup/pac-$(date +%F).dump
# 保留 7 天
find /backup -name 'pac-*.dump' -mtime +7 -delete
```
---
## 二、回滚代码版本
```bash
cd $PAC_HOME && git fetch && git checkout <last-known-good-tag>
pnpm install && pnpm --filter @pac/types build && pnpm --filter @pac/service build
systemctl restart pac-service
```
---
## 三、游标倒退(重跑某时间窗)
```sql
-- 把最近一次 sync_log 的 cursor_after 改到目标时间(键为数据源表名)
UPDATE sync_logs SET cursor_after = '{"<source_table>":"2026-04-01T00:00:00", ...}'
WHERE id = (SELECT id FROM sync_logs
WHERE resource='incremental_bundle' AND status='success'
ORDER BY started_at DESC LIMIT 1);
```
下次 cron 自动从该游标拉。或直接 `pnpm sync -- --full` 忽略游标全量重拉(幂等,已有数据 skip)。
---
## 四、清理残留的 'running' 锁
并发锁是 `sync_logs` 里 `status='running'` 的行;进程崩溃未 finalize 会留下 stale 锁挡住后续 sync。
```sql
-- running 超 12h 视为 stale,人工 abort 释放锁
UPDATE sync_logs SET status='aborted', ended_at=NOW()
WHERE status='running' AND started_at < NOW() - INTERVAL '12 hours';
```
(CLI 撞锁会以退出码 `4` 退出,日志含对应 sync_log id 便于定位。)
import { FlatCompat } from '@eslint/eslintrc';
const compat = new FlatCompat({ baseDirectory: import.meta.dirname });
export default [
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
ignores: ['.next/**', 'node_modules/**', '.turbo/**', '.source/**'],
},
];
export { cn } from 'cnfast';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { Code2 } from 'lucide-react';
import { appName, repo } from './shared';
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: appName,
},
// 内部 GitLab(非 GitHub)。type: 'icon' → 渲染在侧栏底栏(主题切换左侧),不占顶部列表。
links: [
{
type: 'icon',
text: '源码',
label: '源码(GitLab)',
icon: <Code2 />,
url: repo.baseUrl,
external: true,
},
],
};
}
import { createOpenAPI } from 'fumadocs-openapi/server';
// OpenAPI spec 单一来源:由 pac-service 的 `openapi:dump`(preview 模式)生成,
// 落在 `openapi/pac.json`。本对象供运行时 <APIPage> 渲染 + 生成脚本共用同一 id。
export const openapi = createOpenAPI({
input: ['openapi/pac.json'],
});
export const appName = 'PAC 文档';
export const docsRoute = '/docs';
export const docsImageRoute = '/og/docs';
export const docsContentRoute = '/llms.mdx/docs';
// 源码托管在内部 GitLab(非 GitHub)。文档源在 monorepo 的这个子目录下。
export const repo = {
baseUrl: 'http://code.jwsmed.com/ai-tools/pac',
branch: 'main',
docsDir: 'apps/pac-docs/content/docs',
};
/** 「在 GitLab 查看/编辑此页」链接 —— page.path 是 content/docs 下的相对路径 */
export function editUrl(pagePath: string): string {
return `${repo.baseUrl}/-/blob/${repo.branch}/${repo.docsDir}/${pagePath}`;
}
import { docs } from 'collections/server';
import { loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { openapi } from './openapi';
import { docsContentRoute, docsImageRoute, docsRoute } from './shared';
// 多源 loader:手写 MDX(docs)+ 由 OpenAPI spec 派生的虚拟 API 参考页(openapi)。
// openapi 页挂在 /docs/api 下,按 tag(≈ controller)分组。loaderPlugin 给侧栏加
// GET/POST 方法标记。See https://fumadocs.dev/docs/headless/source-api
export const source = loader(
{
docs: docs.toFumadocsSource(),
openapi: await openapi.staticSource({ groupBy: 'tag', baseDir: 'api' }),
},
{
baseUrl: docsRoute,
plugins: [openapi.loaderPlugin(), lucideIconsPlugin()],
},
);
export function getPageImage(page: (typeof source)['$inferPage']) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `${docsImageRoute}/${segments.join('/')}`,
};
}
export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) {
const segments = [...page.slugs, 'content.md'];
return {
segments,
url: `${docsContentRoute}/${segments.join('/')}`,
};
}
export async function getLLMText(page: (typeof source)['$inferPage']) {
// openapi 虚拟页没有 processed markdown,跳过正文。
const processed = 'getText' in page.data ? await page.data.getText('processed') : '';
return `# ${page.data.title} (${page.url})
${processed}`;
}
import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
// 容器化产物:Next 输出 standalone(.next/standalone + server.js),Dockerfile prod 阶段直接 node 它
output: 'standalone',
// 没有营销首页 / 文档首页 —— 根路径直接进文档,/docs 再进第一篇内容
async redirects() {
return [
{ source: '/', destination: '/docs', permanent: false },
{ source: '/docs', destination: '/docs/start/what-is-pac', permanent: false },
];
},
};
export default withMDX(config);
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "@pac/docs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3102",
"build": "fumadocs-mdx && next build",
"start": "next start -p 3102",
"lint": "eslint .",
"type-check": "fumadocs-mdx && next typegen && tsc --noEmit",
"clean": "rm -rf .next .turbo .source",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"cnfast": "^0.0.8",
"fumadocs-core": "16.10.6",
"fumadocs-mdx": "15.0.13",
"fumadocs-openapi": "11.0.6",
"fumadocs-ui": "16.10.6",
"lucide-react": "^1.14.0",
"mermaid": "^11.16.0",
"next": "^16.2.4",
"next-themes": "^0.4.6",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@tailwindcss/postcss": "^4.2.4",
"@types/mdx": "^2.0.14",
"@types/node": "^22.10.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.17.0",
"eslint-config-next": "^16.2.4",
"postcss": "^8.5.15",
"tailwindcss": "^4.2.4",
"typescript": "^5.9.3"
}
}
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
import { NextRequest, NextResponse } from 'next/server';
import { isMarkdownPreferred, rewritePath } from 'fumadocs-core/negotiation';
import { docsContentRoute, docsRoute } from '@/lib/shared';
const { rewrite: rewriteDocs } = rewritePath(
`${docsRoute}{/*path}`,
`${docsContentRoute}{/*path}/content.md`,
);
const { rewrite: rewriteSuffix } = rewritePath(
`${docsRoute}{/*path}.md`,
`${docsContentRoute}{/*path}/content.md`,
);
export default function proxy(request: NextRequest) {
const result = rewriteSuffix(request.nextUrl.pathname);
if (result) {
return NextResponse.rewrite(new URL(result, request.nextUrl));
}
if (isMarkdownPreferred(request)) {
const result = rewriteDocs(request.nextUrl.pathname);
if (result) {
return NextResponse.rewrite(new URL(result, request.nextUrl));
}
}
return NextResponse.next();
}
import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
// 把 ```mermaid 代码块转成 <Mermaid chart=.../>(运行在 remark 阶段,早于 shiki 高亮,
// 故 mermaid 源不会被当普通代码高亮)。依赖无关:手写遍历 mdast,不引 unist-util-visit。
function remarkMermaid() {
return (tree: { children?: unknown[] }) => {
const walk = (node: { children?: unknown[] }) => {
if (!Array.isArray(node.children)) return;
node.children = node.children.map((child) => {
const c = child as { type?: string; lang?: string; value?: string };
if (c.type === 'code' && c.lang === 'mermaid') {
return {
type: 'mdxJsxFlowElement',
name: 'Mermaid',
attributes: [{ type: 'mdxJsxAttribute', name: 'chart', value: c.value ?? '' }],
children: [],
};
}
walk(c as { children?: unknown[] });
return child;
});
};
walk(tree);
};
}
// You can customize Zod schemas for frontmatter and `meta.json` here
// see https://fumadocs.dev/docs/mdx/collections
export const docs = defineDocs({
dir: 'content/docs',
docs: {
schema: pageSchema,
postprocess: {
includeProcessedMarkdown: true,
},
},
meta: {
schema: metaSchema,
},
});
export default defineConfig({
mdxOptions: {
// 函数形式:在 fumadocs 内置 remark 插件(gfm/structure 等)之后追加 mermaid 转换,不覆盖内置。
remarkPlugins: (v) => [...v, remarkMermaid],
},
});
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./*"],
"collections/*": ["./.source/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
# treatment_actual — EMR.treat_plan 本次实际治疗(actual) # treatment_actual — EMR.treat_plan 本次实际治疗(actual)
# #
# W4 末重大语义修正(详见 docs/dw-data-source-issues.md): # W4 末重大语义修正(详见 apps/pac-docs/content/docs/operations/dw-data-issues.mdx):
# - 原方案:从 fact_settlement_out 反推已做治疗(无牙位、颗粒度差、跟收费 1:N) # - 原方案:从 fact_settlement_out 反推已做治疗(无牙位、颗粒度差、跟收费 1:N)
# - 新方案:从 EMR.treat_plan 字段(真实临床记录,48.7% 带牙位)取本次治疗 # - 新方案:从 EMR.treat_plan 字段(真实临床记录,48.7% 带牙位)取本次治疗
# - settlement 不再消费为 treatment_actual,只走 payment.yaml(LTV)+ refund.yaml(风险) # - settlement 不再消费为 treatment_actual,只走 payment.yaml(LTV)+ refund.yaml(风险)
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# e83d432a38bb4f6284713b36db4e7497 上海世纪公园 # e83d432a38bb4f6284713b36db4e7497 上海世纪公园
# #
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# 架构纪律(v2.2 — yaml 单方案改造,docs/algorithm/canonical-fact-layer.md §五) # 架构纪律(v2.2 — yaml 单方案改造,apps/pac-docs/content/docs/architecture/data-ingestion.mdx §五)
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# #
# 数据源(双源完全等价): # 数据源(双源完全等价):
......
...@@ -38,7 +38,8 @@ ...@@ -38,7 +38,8 @@
"ai:gen-script:prod": "node dist/cli/ai-gen-script.cli.js", "ai:gen-script:prod": "node dist/cli/ai-gen-script.cli.js",
"sync:test": "ts-node --transpile-only src/cli/sync-test.cli.ts", "sync:test": "ts-node --transpile-only src/cli/sync-test.cli.ts",
"stale-scan": "ts-node --transpile-only src/cli/stale-scan.cli.ts", "stale-scan": "ts-node --transpile-only src/cli/stale-scan.cli.ts",
"stale-scan:prod": "node dist/cli/stale-scan.cli.js" "stale-scan:prod": "node dist/cli/stale-scan.cli.js",
"openapi:dump": "ts-node --transpile-only src/cli/dump-openapi.cli.ts"
}, },
"prisma": { "prisma": {
"seed": "ts-node --transpile-only prisma/seed.ts" "seed": "ts-node --transpile-only prisma/seed.ts"
......
...@@ -459,7 +459,9 @@ model PatientTransaction { ...@@ -459,7 +459,9 @@ model PatientTransaction {
/// 用途: 审计 fact 算错时回查中间态; replay yaml 改后局部重跑 parser 不必从 raw 重头算 /// 用途: 审计 fact 算错时回查中间态; replay yaml 改后局部重跑 parser 不必从 raw 重头算
/// nullable 兼容老数据(初期未实施 canonical 留底);新写入必须填 /// nullable 兼容老数据(初期未实施 canonical 留底);新写入必须填
canonicalPayload Json? @map("canonical_payload") canonicalPayload Json? @map("canonical_payload")
/// payload 指纹(sha256);幂等兜底 + 完整性校验,也作为 source_event_id 合成的输入之一 /// payload 指纹(sha256 of rawRow);完整性 / 兜底校验用的独立字段。
/// :source_event_id TransactionSynthesizer `来源:资源:source_unit:subjectId:updatedAt` 合成,
/// **不含本字段**(早期注释说"也作为 source_event_id 输入"已与代码不符,已更正)
payloadHash String @map("payload_hash") payloadHash String @map("payload_hash")
/// 宿主侧实际发生时间(adapter platform.pullConfig.timezone 解析无时区字符串后转 UTC) /// 宿主侧实际发生时间(adapter platform.pullConfig.timezone 解析无时区字符串后转 UTC)
...@@ -529,7 +531,7 @@ model PatientFact { ...@@ -529,7 +531,7 @@ model PatientFact {
subjectId String @map("subject_id") subjectId String @map("subject_id")
/// `actual`(已发生事实) / `planned`(未来安排) /// `actual`(已发生事实) / `planned`(未来安排)
kind String kind String
/// 事实类型(应用层 zod enum,@pac/types FactType) v2.1 重塑后共 16 (对齐 docs/algorithm/canonical-fact-layer.md §四): /// 事实类型(应用层 zod enum,@pac/types FactType) v2.1 重塑后共 16 (对齐 apps/pac-docs/content/docs/architecture/canonical-fact-layer.mdx §四):
/// ///
/// **设计纪律**:按独立语义粒度拆 fact_type /// **设计纪律**:按独立语义粒度拆 fact_type
/// 诊断 / 治疗 / 接诊 / 病历 / 影像 是不同生命周期的临床实体,各自独立 fact_type /// 诊断 / 治疗 / 接诊 / 病历 / 影像 是不同生命周期的临床实体,各自独立 fact_type
......
...@@ -3,32 +3,32 @@ ...@@ -3,32 +3,32 @@
-- --
-- 用途:独立于 scenario 代码,用 plan_reasons.signals 反查 patient_facts 交叉验证 -- 用途:独立于 scenario 代码,用 plan_reasons.signals 反查 patient_facts 交叉验证
-- "该召没召 / 不该召却召"。每次摄入/改算法后跑,看 FP/FN 体量。 -- "该召没召 / 不该召却召"。每次摄入/改算法后跑,看 FP/FN 体量。
-- 独立性:下方 vt_codes 重述一份 code→{resolver/cooldown/⑤d科目/全口/乳牙}, -- 独立性:下方 vt_codes 重述一份 code→{resolver/cooldown/全口/乳牙},
-- 跟 scenario 代码各写一份 → 不一致即暴露(差分)。 -- 跟 scenario 代码各写一份 → 不一致即暴露(差分)。
-- 性能:把 治疗/召回/诊断/预约 牙位各 unnest 一次进【索引临时表】+ ANALYZE, -- 性能:把 治疗/召回/诊断 牙位各 unnest 一次进【索引临时表】+ ANALYZE,
-- 再用索引查找做 EXISTS/JOIN。13 万患者秒级(旧逐行相关子查询版会 O(n²) 卡死)。 -- 再用索引查找做 EXISTS/JOIN。13 万患者秒级(旧逐行相关子查询版会 O(n²) 卡死)。
-- 运行: -- 运行:
-- 本地 docker exec -i pac-postgres psql -U pac -d pac -f - < apps/pac-service/sql/verify-recall.sql -- 本地 docker exec -i pac-postgres psql -U pac -d pac -f - < apps/pac-service/sql/verify-recall.sql
-- 服务器 docker exec -i pac-postgres-1 psql -U pac -d pac -f - < apps/pac-service/sql/verify-recall.sql -- 服务器 docker exec -i pac-postgres-1 psql -U pac -d pac -f - < apps/pac-service/sql/verify-recall.sql
-- 章节:§A 规模 / §B FP 硬闸(应0) / §C 牙位交叉表+FP / §C3-C4 FN 逐层解释 / -- 章节:§A 规模 / §B FP 硬闸(应0) / §C 牙位交叉表+FP / §C3-C4 FN 逐层解释 /
-- §D 全口码治疗后复发被压 / §E K08 正畸语境 FP -- §D 全口码治疗后复发被压 / §E K08 正畸语境 FP
-- 注:§C/§D 的"残留"需再排 cooldown / 已指派锁 / ⑤d 才是真问题(见 docs 讨论)。 -- 注:§C/§D 的"残留"需再排 cooldown / 已指派锁 才是真问题(见 docs 讨论)。
-- ============================================================ -- ============================================================
\pset pager off \pset pager off
\set ON_ERROR_STOP on \set ON_ERROR_STOP on
SET work_mem = '256MB'; SET work_mem = '256MB';
-- 牙位级 code 配置(K05/K07 全口码不在牙位交叉表,单列 §D) -- 牙位级 code 配置(K05/K07 全口码不在牙位交叉表,单列 §D)
CREATE TEMP TABLE vt_codes(code text, sub text, cooldown int, resolver text[], complaints text[], drop_decid bool); CREATE TEMP TABLE vt_codes(code text, sub text, cooldown int, resolver text[], drop_decid bool);
INSERT INTO vt_codes VALUES INSERT INTO vt_codes VALUES
('K08','missing_tooth',30, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['种植','修复'], true), ('K08','missing_tooth',30, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], true),
('K02','caries_no_filling',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY[]::text[], false), ('K02','caries_no_filling',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], false),
('K03','hard_tissue_damage',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['修复','拔牙'], false), ('K03','hard_tissue_damage',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], false),
('K04','endo_no_rct',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY[]::text[], false), ('K04','endo_no_rct',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], false),
('K01','impacted_tooth',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['拔牙'], false), ('K01','impacted_tooth',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], false),
('K09','jaw_cyst',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['拔牙'], false), ('K09','jaw_cyst',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], false),
('K00','development_eruption',30, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric','orthodontic'], ARRAY['拔牙','修复','种植','正畸','早矫'], false), ('K00','development_eruption',30, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric','orthodontic'], false),
('K06','gum_alveolar_lesion',14, ARRAY['periodontic','surgical'], ARRAY['牙周','拔牙'], false); ('K06','gum_alveolar_lesion',14, ARRAY['periodontic','surgical'], false);
-- ── 牙位 unnest 一次 → 索引临时表 ── -- ── 牙位 unnest 一次 → 索引临时表 ──
CREATE TEMP TABLE vt_treat AS CREATE TEMP TABLE vt_treat AS
...@@ -45,14 +45,6 @@ CROSS JOIN LATERAL unnest(string_to_array(regexp_replace(coalesce(pr.signals->>' ...@@ -45,14 +45,6 @@ CROSS JOIN LATERAL unnest(string_to_array(regexp_replace(coalesce(pr.signals->>'
WHERE fp.status IN('active','assigned') AND pr.scenario='treatment_initiation_recall' AND tk ~ '^[0-9]{2}$'; WHERE fp.status IN('active','assigned') AND pr.scenario='treatment_initiation_recall' AND tk ~ '^[0-9]{2}$';
CREATE INDEX ON vt_recall(patient_id, sub, tooth); ANALYZE vt_recall; CREATE INDEX ON vt_recall(patient_id, sub, tooth); ANALYZE vt_recall;
-- 预约科目(⑤d):patient × 中文 complaint × 时间
CREATE TEMP TABLE vt_appt AS
SELECT a.patient_id, trim(c) AS compl, COALESCE(a.planned_for,a.occurred_at) AS appt_at
FROM patient_facts a
CROSS JOIN LATERAL unnest(string_to_array(coalesce(a.content->>'complaint_category',''),',')) c
WHERE a.type='appointment_record' AND a.status IN('active','fulfilled') AND trim(c)<>'';
CREATE INDEX ON vt_appt(patient_id, compl); ANALYZE vt_appt;
-- 患者级标记:未来预约(⑤b)/ 近期到诊(⑤f) -- 患者级标记:未来预约(⑤b)/ 近期到诊(⑤f)
CREATE TEMP TABLE vt_future AS SELECT DISTINCT patient_id FROM patient_facts CREATE TEMP TABLE vt_future AS SELECT DISTINCT patient_id FROM patient_facts
WHERE type='appointment_record' AND status='active' AND COALESCE(planned_for,occurred_at)>now(); WHERE type='appointment_record' AND status='active' AND COALESCE(planned_for,occurred_at)>now();
...@@ -72,14 +64,14 @@ CREATE INDEX ON vt_struct(patient_id, tooth); ANALYZE vt_struct; ...@@ -72,14 +64,14 @@ CREATE INDEX ON vt_struct(patient_id, tooth); ANALYZE vt_struct;
-- ── 诊断牙(宇宙):合规 + 过 cooldown,取 LATEST 诊断 ── -- ── 诊断牙(宇宙):合规 + 过 cooldown,取 LATEST 诊断 ──
CREATE TEMP TABLE vt_diag AS CREATE TEMP TABLE vt_diag AS
SELECT d.patient_id, c.code, c.sub, c.resolver, c.complaints, c.drop_decid, SELECT d.patient_id, c.code, c.sub, c.resolver, c.drop_decid,
tk AS tooth, max(d.occurred_at) AS dx_at, string_agg(DISTINCT d.content->>'name_zh','/') AS name_zh, min(c.cooldown) AS cooldown tk AS tooth, max(d.occurred_at) AS dx_at, string_agg(DISTINCT d.content->>'name_zh','/') AS name_zh, min(c.cooldown) AS cooldown
FROM patient_facts d FROM patient_facts d
JOIN vt_codes c ON c.code = d.content->>'code' JOIN vt_codes c ON c.code = d.content->>'code'
JOIN patients p ON p.id=d.patient_id JOIN patient_profiles pp ON pp.patient_id=d.patient_id JOIN patients p ON p.id=d.patient_id JOIN patient_profiles pp ON pp.patient_id=d.patient_id
CROSS JOIN LATERAL unnest(string_to_array(regexp_replace(coalesce(d.content->>'tooth_position',''),'[^0-9;]+',';','g'),';')) tk CROSS JOIN LATERAL unnest(string_to_array(regexp_replace(coalesce(d.content->>'tooth_position',''),'[^0-9;]+',';','g'),';')) tk
WHERE d.type='diagnosis_record' AND d.status='active' AND p.active AND NOT pp.do_not_contact AND NOT pp.deceased AND tk ~ '^[0-9]{2}$' WHERE d.type='diagnosis_record' AND d.status='active' AND p.active AND NOT pp.do_not_contact AND NOT pp.deceased AND tk ~ '^[0-9]{2}$'
GROUP BY d.patient_id, c.code, c.sub, c.resolver, c.complaints, c.drop_decid, tk; GROUP BY d.patient_id, c.code, c.sub, c.resolver, c.drop_decid, tk;
DELETE FROM vt_diag WHERE dx_at > now() - (cooldown||' days')::interval; DELETE FROM vt_diag WHERE dx_at > now() - (cooldown||' days')::interval;
CREATE INDEX ON vt_diag(patient_id, tooth); ANALYZE vt_diag; CREATE INDEX ON vt_diag(patient_id, tooth); ANALYZE vt_diag;
...@@ -143,7 +135,6 @@ SELECT d.patient_id, d.code, d.sub, d.tooth, d.dx_at, d.name_zh, ...@@ -143,7 +135,6 @@ SELECT d.patient_id, d.code, d.sub, d.tooth, d.dx_at, d.name_zh,
EXISTS(SELECT 1 FROM vt_recall r WHERE r.patient_id=d.patient_id AND r.sub=d.sub AND r.tooth=d.tooth) AS recalled, EXISTS(SELECT 1 FROM vt_recall r WHERE r.patient_id=d.patient_id AND r.sub=d.sub AND r.tooth=d.tooth) AS recalled,
(d.patient_id IN (SELECT patient_id FROM vt_future)) AS x_future, (d.patient_id IN (SELECT patient_id FROM vt_future)) AS x_future,
(d.patient_id IN (SELECT patient_id FROM vt_recent)) AS x_recent, (d.patient_id IN (SELECT patient_id FROM vt_recent)) AS x_recent,
(cardinality(d.complaints)>0 AND EXISTS(SELECT 1 FROM vt_appt a WHERE a.patient_id=d.patient_id AND a.compl=ANY(d.complaints) AND a.appt_at>=d.dx_at)) AS x_appt,
(d.name_zh IN('废用牙','无功能牙')) AS x_inelig, (d.name_zh IN('废用牙','无功能牙')) AS x_inelig,
(d.drop_decid AND d.tooth ~ '^[5-8][1-5]$') AS x_decid, (d.drop_decid AND d.tooth ~ '^[5-8][1-5]$') AS x_decid,
EXISTS(SELECT 1 FROM vt_struct s WHERE s.patient_id=d.patient_id AND s.tooth=d.tooth AND s.code<>d.code AND s.at>=d.dx_at) AS x_superseded, EXISTS(SELECT 1 FROM vt_struct s WHERE s.patient_id=d.patient_id AND s.tooth=d.tooth AND s.code<>d.code AND s.at>=d.dx_at) AS x_superseded,
......
/**
* Dump OpenAPI CLI
*
* 把 PAC 的 OpenAPI spec 导出成静态 JSON,供文档站(fumadocs-openapi)渲染。
* 用 Nest **preview 模式**:构建模块图 + 注册路由供 Swagger 内省,但**不实例化 provider**
* —— 不连 DB / Redis,无副作用,纯导出。CI 里可直接跑,无需起服务。
*
* Usage:
* pnpm --filter @pac/service openapi:dump # → ../pac-docs/openapi/pac.json
* pnpm --filter @pac/service openapi:dump -- out.json
*/
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { AppModule } from '../app.module';
import {
GLOBAL_PREFIX,
GLOBAL_PREFIX_EXCLUDE,
buildPacOpenApiDocument,
} from '../openapi/build-document';
async function main() {
const outArg = process.argv[2] ?? '../pac-docs/openapi/pac.json';
const out = resolve(process.cwd(), outArg);
const app = await NestFactory.create(AppModule, { preview: true, logger: false });
app.setGlobalPrefix(GLOBAL_PREFIX, { exclude: GLOBAL_PREFIX_EXCLUDE });
const document = buildPacOpenApiDocument(app);
await app.close();
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, JSON.stringify(document, null, 2) + '\n');
const pathCount = Object.keys(document.paths ?? {}).length;
console.log(`OpenAPI written → ${out} (${pathCount} paths)`);
if (pathCount === 0) {
console.error('⚠️ 0 paths — preview 模式可能没注册路由,检查 Nest 版本/模块结构');
process.exitCode = 2;
}
}
main().catch((err) => {
console.error('dump-openapi failed', err);
process.exit(1);
});
...@@ -136,11 +136,13 @@ async function testPush(apiBase: string, hostId: string, secret: string) { ...@@ -136,11 +136,13 @@ async function testPush(apiBase: string, hostId: string, secret: string) {
const events = [ const events = [
{ {
subjectType: 'patient', subjectType: 'patient',
subjectRef: `push-p${randomUUID().slice(0, 6)}`,
clinicId: 'mock-clinic-1',
action: 'patient_created', action: 'patient_created',
sourceEventId: `push-evt-${randomUUID()}`, subjectId: `push-p${randomUUID().slice(0, 6)}`,
tenantId: 't_demo',
sourceUnit: '',
clinicId: 'mock-clinic-1',
occurredAt: now.toISOString(), occurredAt: now.toISOString(),
updatedAt: now.toISOString(),
payload: { payload: {
name: '推送测试-张三', name: '推送测试-张三',
phone: '13900001111', phone: '13900001111',
......
...@@ -2,13 +2,15 @@ import 'reflect-metadata'; ...@@ -2,13 +2,15 @@ import 'reflect-metadata';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { apiReference } from '@scalar/nestjs-api-reference'; import { apiReference } from '@scalar/nestjs-api-reference';
import { cleanupOpenApiDoc } from 'nestjs-zod';
import helmet from 'helmet'; import helmet from 'helmet';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { wrapOpenApiResponses } from './openapi/wrap-openapi'; import {
GLOBAL_PREFIX,
GLOBAL_PREFIX_EXCLUDE,
buildPacOpenApiDocument,
} from './openapi/build-document';
async function bootstrap() { async function bootstrap() {
// rawBody: true → req.rawBody 保留原始 bytes,push HMAC 验签需要(parsed JSON 字段顺序不稳) // rawBody: true → req.rawBody 保留原始 bytes,push HMAC 验签需要(parsed JSON 字段顺序不稳)
...@@ -24,33 +26,11 @@ async function bootstrap() { ...@@ -24,33 +26,11 @@ async function bootstrap() {
allowedHeaders: ['Authorization', 'Content-Type', 'Accept'], allowedHeaders: ['Authorization', 'Content-Type', 'Accept'],
}); });
app.setGlobalPrefix('pac/v1', { app.setGlobalPrefix(GLOBAL_PREFIX, { exclude: GLOBAL_PREFIX_EXCLUDE });
exclude: [
'health',
'api/docs',
'api/openapi.json',
// Bull Board UI(@bull-board/nestjs 自己挂 Express handler,不走 Nest 路由前缀)
'admin/queues',
'admin/queues/*path',
],
});
app.useGlobalFilters(new AllExceptionsFilter()); app.useGlobalFilters(new AllExceptionsFilter());
const config = new DocumentBuilder() const document = buildPacOpenApiDocument(app);
.setTitle('PAC API')
.setDescription(
'Patient Analyze Center — three-pool framework (facts in, plans out, agents inside).',
)
.setVersion('0.1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', name: 'Authorization', in: 'header' },
'accessToken',
)
.build();
const document = wrapOpenApiResponses(
cleanupOpenApiDoc(SwaggerModule.createDocument(app, config)),
);
app.use('/api/openapi.json', (_req: unknown, res: { json: (b: unknown) => void }) => app.use('/api/openapi.json', (_req: unknown, res: { json: (b: unknown) => void }) =>
res.json(document), res.json(document),
......
...@@ -10,7 +10,7 @@ import type { Tone } from './tone'; ...@@ -10,7 +10,7 @@ import type { Tone } from './tone';
// ⭐ 话术上下文(tier-agnostic):稳健/标准/深度三档共用同一份 ScriptContext; // ⭐ 话术上下文(tier-agnostic):稳健/标准/深度三档共用同一份 ScriptContext;
// 也被实时教练复用(realtime-coach-context)。脊柱 = AiCallRunner,策略 = 各档 AiCall。 // 也被实时教练复用(realtime-coach-context)。脊柱 = AiCallRunner,策略 = 各档 AiCall。
// 设计见 docs/algorithm/ai-script-generation.md // 设计见 apps/pac-docs/content/docs/algorithms/script-generation.mdx
export interface ScriptContext { export interface ScriptContext {
// 患者数据(ScriptContext = 三档全集数据,各档组装 user prompt 时各取所需;非审计快照)。 // 患者数据(ScriptContext = 三档全集数据,各档组装 user prompt 时各取所需;非审计快照)。
// ⚠️ ID 类不进(关联走 agent_invocations.linked_patient_id 列);真名 name 是数据, // ⚠️ ID 类不进(关联走 agent_invocations.linked_patient_id 列);真名 name 是数据,
......
...@@ -27,7 +27,7 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor { ...@@ -27,7 +27,7 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor {
private static readonly LATE_MS = 15 * 60_000; private static readonly LATE_MS = 15 * 60_000;
// 注:曾因源 CH 跑 UTC 导致 arrived_at(in_time)摄入早 8h,这里加过 +8h band-aid。 // 注:曾因源 CH 跑 UTC 导致 arrived_at(in_time)摄入早 8h,这里加过 +8h band-aid。
// 已根治 —— 源 CH(本地镜像 + 远程 DW)统一 Asia/Shanghai 时区,in_time 摄入即为正确北京时刻, // 已根治 —— 源 CH(本地镜像 + 远程 DW)统一 Asia/Shanghai 时区,in_time 摄入即为正确北京时刻,
// band-aid 移除(留着反而会让到店时间晚 8h)。详见 docs/dw-data-source-issues.md // band-aid 移除(留着反而会让到店时间晚 8h)。详见 apps/pac-docs/content/docs/operations/dw-data-issues.mdx
private static readonly ATTENDED = new Set(['completed', 'arrived', 'in_treatment']); private static readonly ATTENDED = new Set(['completed', 'arrived', 'in_treatment']);
private static readonly WAIT_KW = ['不可等候', '时间敏感', '赶时间', '不能等']; private static readonly WAIT_KW = ['不可等候', '时间敏感', '赶时间', '不能等'];
......
...@@ -58,10 +58,13 @@ import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../.. ...@@ -58,10 +58,13 @@ import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../..
* diagnosis 用 occurred_at;recommendation 是 planned kind,occurred_at 为 null、时间在 planned_for。 * diagnosis 用 occurred_at;recommendation 是 planned kind,occurred_at 为 null、时间在 planned_for。
* 旧 bug:SQL 写 `occurred_at IS NOT NULL` → recommendation 触发(recCodes)全部命不中,死代码。 * 旧 bug:SQL 写 `occurred_at IS NOT NULL` → recommendation 触发(recCodes)全部命不中,死代码。
* *
* ⚠️ 牙位无法做"按牙排除"(数据源限制,非 bug): * 牙位级排除(W4 末已升级 —— 旧注释说"做不到"已废,实现见 ⑤a):
* "已做治疗"的真凭据是结算表 fact_settlement_out(花钱才算做)→ 落 actual treatment, * "已做治疗"的凭据 = actual treatment(花钱结算落地)。排除按牙位 overlap 判:
* 而结算按收费项记账,**结构上无牙位列**。故排除只能 patient 级(同 patient 做过同类治疗即排)。 * - 信号有牙位 + actual 有牙位 → tooth array overlap 命中即排除(牙位来自 EMR.treat_plan,~48.7% 带牙位)
* treat_plan 里 46% 有牙位,但落的是 planned(≠已做),不能用于排除。反馈 DW 在结算补牙位再升级。 * - 信号无牙位(如 K05 全口诊断)→ 退化到 patient / category 级
* - actual 无牙位(如全口洁治)→ 视为全口覆盖 → 排除该 category 全部牙位
* 注:结算表本身无牙位列;牙位取自 EMR.treat_plan(planned,不当"已做",仅供与 actual 对齐做 overlap)。
* 反馈 DW:在结算补牙位可进一步提精度。
* *
* 通用过滤(每个子场景 SQL 自己写,共用片段后续抽): * 通用过滤(每个子场景 SQL 自己写,共用片段后续抽):
* active + 非 DNC + 非 deceased(phone 缺失暂不强制,DW 不提供) * active + 非 DNC + 非 deceased(phone 缺失暂不强制,DW 不提供)
......
...@@ -97,7 +97,7 @@ export const AssemblerConfigSchema = z.object({ ...@@ -97,7 +97,7 @@ export const AssemblerConfigSchema = z.object({
key: z.string().min(1), key: z.string().min(1),
/// **炸行兜底**:若 DW ETL 把 1:N 子表(影像 / 结算明细等)写成 LEFT JOIN 没 groupArray, /// **炸行兜底**:若 DW ETL 把 1:N 子表(影像 / 结算明细等)写成 LEFT JOIN 没 groupArray,
/// 主表 id 会被复制 N 次。配 dedup_by=id 时 AssemblerEngine 按 id 取首行,其他重复行丢弃。 /// 主表 id 会被复制 N 次。配 dedup_by=id 时 AssemblerEngine 按 id 取首行,其他重复行丢弃。
/// 详见 docs/algorithm/canonical-fact-layer.md §3.5 附录 B 第 3 条。 /// 详见 apps/pac-docs/content/docs/architecture/data-ingestion.mdx §3.5 附录 B 第 3 条。
dedup_by: z.string().optional(), dedup_by: z.string().optional(),
}), }),
......
...@@ -2208,8 +2208,8 @@ export function resolveWriteBatchSize(): number { ...@@ -2208,8 +2208,8 @@ export function resolveWriteBatchSize(): number {
* ⚠️ 并行时注意: * ⚠️ 并行时注意:
* - 数据完整性不受影响(cohort = disjoint 患者;source_event_id + fact version partial UNIQUE 双保险) * - 数据完整性不受影响(cohort = disjoint 患者;source_event_id + fact version partial UNIQUE 双保险)
* - checkpoint resume 禁用(完成乱序,靠幂等从头重跑) * - checkpoint resume 禁用(完成乱序,靠幂等从头重跑)
* - 需 bump Prisma 连接池:DATABASE_URL?connection_limit=N×(write_batch 并发数+余量), * - 连接池自动随并发放大(PrismaService.withCohortDerivedPool 按 PAC_COHORT_CONCURRENCY 推导
* 否则并发 createMany/bulkWrite 排队等连接(不损坏数据,只是变慢) * connection_limit),无需手动配;即便排队也只是变慢、不损坏数据
* - 内存 N× 单 cohort(单 cohort ~330MB;并发 4 ≈ 1.3GB,14GB 机器有余量) * - 内存 N× 单 cohort(单 cohort ~330MB;并发 4 ≈ 1.3GB,14GB 机器有余量)
*/ */
export function resolveCohortConcurrency(): number { export function resolveCohortConcurrency(): number {
......
...@@ -8,14 +8,14 @@ import { TransformsSchema } from '../transforms/transforms.schema'; ...@@ -8,14 +8,14 @@ import { TransformsSchema } from '../transforms/transforms.schema';
* 由 PAC 这边的 yaml(transforms + assemblers)做形态改造 + 翻译。 * 由 PAC 这边的 yaml(transforms + assemblers)做形态改造 + 翻译。
* *
* v2.2 新增: * v2.2 新增:
* - 顶层 `transforms[]` — Layer A.5 声明式形态改造(5 operator 白名单) * - 顶层 `transforms[]` — Layer A.5 声明式形态改造(6 operator 白名单)
* - SQL 退化为"等价 csv 单表 dump 的 SELECT"(只列选 + WHERE 过滤) * - SQL 退化为"等价 csv 单表 dump 的 SELECT"(只列选 + WHERE 过滤)
* - JSON 数组拆行 / 字段派生 / 多列推断 / 关键词分流 全在 transforms * - JSON 数组拆行 / 字段派生 / 多列推断 / 关键词分流 全在 transforms
* - 实现双源真等价(直连 ClickHouse 或 csv 文件,下游无感知) * - 实现双源真等价(直连 ClickHouse 或 csv 文件,下游无感知)
* *
* v2.1 ClickHouse 直连模式:跳过 dump 文件中间步,PAC 直接 SQL 拉到内存。 * v2.1 ClickHouse 直连模式:跳过 dump 文件中间步,PAC 直接 SQL 拉到内存。
* *
* 详见 docs/algorithm/canonical-fact-layer.md §五(Layer A.5)。 * 详见 apps/pac-docs/content/docs/architecture/data-ingestion.mdx §五(Layer A.5)。
*/ */
/// 原始表声明 — 文件 + 表名(供 assembler 索引) /// 原始表声明 — 文件 + 表名(供 assembler 索引)
...@@ -150,8 +150,8 @@ export const ColdImportManifestSchema = z ...@@ -150,8 +150,8 @@ export const ColdImportManifestSchema = z
sql_source: ClickHouseSourceSchema.optional(), sql_source: ClickHouseSourceSchema.optional(),
/// **Layer A.5 transforms**(可选)— 在 sources 之后、AssemblerEngine 之前跑。 /// **Layer A.5 transforms**(可选)— 在 sources 之后、AssemblerEngine 之前跑。
/// 5 operator 白名单:project / split_json_array / derive / route_by_pattern / pick_first_nonzero /// 6 operator 白名单:project / split_json_array / derive / route_by_pattern / pick_first_nonzero / filter
/// 详见 docs/algorithm/canonical-fact-layer.md §五。 /// 详见 apps/pac-docs/content/docs/architecture/data-ingestion.mdx §五。
transforms: TransformsSchema.optional(), transforms: TransformsSchema.optional(),
/// PAC 写的 assembler 配置清单(每个 canonical resource 一份) /// PAC 写的 assembler 配置清单(每个 canonical resource 一份)
......
...@@ -51,13 +51,14 @@ export class PaymentParser implements Parser { ...@@ -51,13 +51,14 @@ export class PaymentParser implements Parser {
// String() 强转 — host 偶有数字型卡券值(.trim 直接调会炸)。 // String() 强转 — host 偶有数字型卡券值(.trim 直接调会炸)。
const cardTypeName = String(c.cardTypeName ?? '').trim() || null; const cardTypeName = String(c.cardTypeName ?? '').trim() || null;
const cardName = String(c.cardName ?? '').trim() || null; const cardName = String(c.cardName ?? '').trim() || null;
// 折扣锚点(D.1.3):诊所可控折扣额(元)→ 分;折扣率 = 1 - 折扣/应收(feature 算)。 // 折扣锚点(D.1.3):诊所可控折扣额;折扣率 = 1 - 折扣/应收(feature 算)。
// 单位归一交给 normalizer(三字段已登记 payment.moneyFields,按 amountUnit 转分),这里只求和。
// ⚠️ 排除 discount_insurance_rate(保险方付款,非诊所价格折扣;不进"价格底线"锚点)。 // ⚠️ 排除 discount_insurance_rate(保险方付款,非诊所价格折扣;不进"价格底线"锚点)。
const discYuan = const discSum =
Number(c.discountDept ?? 0) + Number(c.discountDept ?? 0) +
Number(c.discountCompany ?? 0) + Number(c.discountCompany ?? 0) +
Number(c.discountCard ?? 0); Number(c.discountCard ?? 0);
const discountCents = Number.isFinite(discYuan) ? Math.round(discYuan * 100) : 0; const discountCents = Number.isFinite(discSum) ? Math.round(discSum) : 0;
const settlementProject = String(c.settlementProject ?? '').trim() || null; const settlementProject = String(c.settlementProject ?? '').trim() || null;
return [ return [
......
...@@ -20,6 +20,15 @@ function resolveSourceUnit(row: Record<string, unknown>, field?: string): string ...@@ -20,6 +20,15 @@ function resolveSourceUnit(row: Record<string, unknown>, field?: string): string
} }
/** /**
* push 路径:source_unit 由事件信封携带,已写进 canonical row 的 `sourceUnit`(可为空串)。
* 优先用它;cold/pull 的 canonical row 没有该键 → 返回 undefined,回退到按列(brand)解析。
*/
function explicitSourceUnit(row: Record<string, unknown>): string | undefined {
const v = row.sourceUnit;
return typeof v === 'string' ? v.trim() : undefined;
}
/**
* PipelineDispatcher — "canonical tables → transaction + fact" 的共享内核。 * PipelineDispatcher — "canonical tables → transaction + fact" 的共享内核。
* *
* 三入口共用(canonical-fact-layer.md §五 4 入口统一管道): * 三入口共用(canonical-fact-layer.md §五 4 入口统一管道):
...@@ -81,7 +90,8 @@ export class PipelineDispatcher { ...@@ -81,7 +90,8 @@ export class PipelineDispatcher {
const patientRows = (input.tables.patients ?? []) as Record<string, unknown>[]; const patientRows = (input.tables.patients ?? []) as Record<string, unknown>[];
for (const row of patientRows) { for (const row of patientRows) {
try { try {
const sourceUnit = resolveSourceUnit(row, input.identityNamespaceField); const sourceUnit =
explicitSourceUnit(row) ?? resolveSourceUnit(row, input.identityNamespaceField);
const patient = await this.upsertPatient(row, input.hostId, input.tenantId, sourceUnit); const patient = await this.upsertPatient(row, input.hostId, input.tenantId, sourceUnit);
m.patientsUpserted++; m.patientsUpserted++;
// 患者主档变更也算"事件",触发 persona 重算(do_not_contact_status 等画像依赖它) // 患者主档变更也算"事件",触发 persona 重算(do_not_contact_status 等画像依赖它)
...@@ -119,7 +129,8 @@ export class PipelineDispatcher { ...@@ -119,7 +129,8 @@ export class PipelineDispatcher {
__source_row?: Record<string, unknown>; __source_row?: Record<string, unknown>;
} & Record<string, unknown>; } & Record<string, unknown>;
const rawRow = explicitSource ?? canonicalRow; const rawRow = explicitSource ?? canonicalRow;
const sourceUnit = resolveSourceUnit(rawRow, input.identityNamespaceField); const sourceUnit =
explicitSourceUnit(canonicalRow) ?? resolveSourceUnit(rawRow, input.identityNamespaceField);
const txn = this.synthesizer.synthesize({ const txn = this.synthesizer.synthesize({
rawRow, rawRow,
canonicalRow, canonicalRow,
......
...@@ -32,7 +32,7 @@ export class HmacVerifier { ...@@ -32,7 +32,7 @@ export class HmacVerifier {
timestampHeader: string | undefined; timestampHeader: string | undefined;
signatureHeader: string | undefined; signatureHeader: string | undefined;
rawBody: string; rawBody: string;
}): Promise<{ hostId: string; hostName: string; tenantHint: string }> { }): Promise<{ hostId: string; hostName: string }> {
const { hostIdHeader, timestampHeader, signatureHeader, rawBody } = input; const { hostIdHeader, timestampHeader, signatureHeader, rawBody } = input;
if (!hostIdHeader) throw new UnauthorizedException('缺 X-PAC-Host-Id header'); if (!hostIdHeader) throw new UnauthorizedException('缺 X-PAC-Host-Id header');
...@@ -82,11 +82,8 @@ export class HmacVerifier { ...@@ -82,11 +82,8 @@ export class HmacVerifier {
throw new UnauthorizedException('签名不匹配'); throw new UnauthorizedException('签名不匹配');
} }
// tenant hint:demo / mock 走 t_demo,其余 t_default(后续接 host 配置) // tenant(集团)由 push 事件 / pull 数据自带,不在验签层推断。
const tenantHint = return { hostId: host.id, hostName: host.name };
host.name.includes('demo') || host.name.includes('mock') ? 't_demo' : 't_default';
return { hostId: host.id, hostName: host.name, tenantHint };
} }
/** /**
......
import { Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { SyncDirection, SyncStatus } from '@pac/types'; import { SyncDirection, SyncStatus } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { PipelineDispatcher, type EmitsResolver } from '../pipeline/pipeline-dispatcher.service'; import { PipelineDispatcher, type EmitsResolver } from '../pipeline/pipeline-dispatcher.service';
...@@ -99,12 +99,34 @@ export class PushReceiverService { ...@@ -99,12 +99,34 @@ export class PushReceiverService {
async receive(input: { async receive(input: {
hostId: string; hostId: string;
hostName: string; hostName: string;
tenantId: string;
events: PushEvent[]; events: PushEvent[];
}): Promise<PushEventsResponse> { }): Promise<PushEventsResponse> {
const { hostId, hostName, tenantId, events } = input; const { hostId, hostName, events } = input;
const firstSourceId = events[0]?.sourceEventId ?? 'unknown';
const triggeredBy = `push:${hostName}:${firstSourceId}`; // 集团 = 事件携带的 tenantId(同登录授权口径);一批要求同属一个集团。
const tenantId = events[0]?.tenantId ?? '';
if (!tenantId || events.some((e) => e.tenantId !== tenantId)) {
throw new BadRequestException(
!tenantId ? 'push 事件缺 tenantId' : '同一批 push 事件必须同属一个集团(tenantId)',
);
}
// host→tenant 一致性校验:tenantId 必须是该 host 已建立的集团之一。
// 注:跨 host 隔离已由所有键的 host_id 保证(host 写不进别人数据);此处只防 host 自己
// 填错 tenant slug 把数据散进新分区。新 host(尚无任何数据)首次放行,由本次写入建立其集团;
// 接新集团请先经 cold-import / pull 建立,push 再跟上。
const knownTenants = await this.prisma.patient.groupBy({
by: ['tenantId'],
where: { hostId },
});
if (knownTenants.length > 0 && !knownTenants.some((k) => k.tenantId === tenantId)) {
throw new BadRequestException(
`tenantId='${tenantId}' 不是 host=${hostName} 已建立的集团(已知 ` +
`${knownTenants.map((k) => k.tenantId).join(' / ')});新集团请先经 cold-import / pull 建立`,
);
}
const triggeredBy = `push:${hostName}:${events[0]?.subjectId ?? 'unknown'}`;
// ─── 1. SyncLog start ─── // ─── 1. SyncLog start ───
const syncLog = await this.prisma.syncLog.create({ const syncLog = await this.prisma.syncLog.create({
...@@ -199,12 +221,15 @@ export class PushReceiverService { ...@@ -199,12 +221,15 @@ export class PushReceiverService {
// canonical row = payload + 元字段拍平 // canonical row = payload + 元字段拍平
const canonicalRow: Record<string, unknown> = { const canonicalRow: Record<string, unknown> = {
...e.payload, ...e.payload,
externalId: e.subjectRef, externalId: e.subjectId,
clinicId: e.clinicId, clinicId: e.clinicId,
occurredAt: e.occurredAt, occurredAt: e.occurredAt,
...(e.patientRef ? { patientExternalId: e.patientRef } : {}), // 源组织命名空间来自事件信封 → dispatcher 优先取它(防跨命名空间撞号)
// 给 synthesizer 用的元字段 sourceUnit: e.sourceUnit ?? '',
_sourceEventId: e.sourceEventId, ...(e.patientId ? { patientExternalId: e.patientId } : {}),
// 末次修改时间 → synthesizer 合幂等键(内容变升版本)
...(e.updatedAt ? { updatedAt: e.updatedAt } : {}),
// 给 synthesizer / dispatcher 用的元字段
_action: e.action, _action: e.action,
_subjectType: e.subjectType, _subjectType: e.subjectType,
// ⭐ 真原文留底:host event payload(不含 PAC 加的 meta 字段) // ⭐ 真原文留底:host event payload(不含 PAC 加的 meta 字段)
......
...@@ -61,7 +61,7 @@ export class PushController { ...@@ -61,7 +61,7 @@ export class PushController {
// raw body 优先(main.ts 应配 rawBody preserve);否则用 JSON.stringify(已 parsed)兜底 // raw body 优先(main.ts 应配 rawBody preserve);否则用 JSON.stringify(已 parsed)兜底
const rawBody = req.rawBody ? req.rawBody.toString('utf8') : JSON.stringify(body); const rawBody = req.rawBody ? req.rawBody.toString('utf8') : JSON.stringify(body);
const { hostId, hostName, tenantHint } = await this.verifier.verify({ const { hostId, hostName } = await this.verifier.verify({
hostIdHeader, hostIdHeader,
timestampHeader, timestampHeader,
signatureHeader, signatureHeader,
...@@ -69,12 +69,12 @@ export class PushController { ...@@ -69,12 +69,12 @@ export class PushController {
}); });
// body 单独走 zod 校验(Nest 全局 ZodValidationPipe 已挂) // body 单独走 zod 校验(Nest 全局 ZodValidationPipe 已挂)
// 集团 tenantId 由事件携带(receiver 内解析校验),不再由 host 名兜底。
const parsed = PushEventsRequestSchema.parse(body); const parsed = PushEventsRequestSchema.parse(body);
return this.receiver.receive({ return this.receiver.receive({
hostId, hostId,
hostName, hostName,
tenantId: tenantHint,
events: parsed.events, events: parsed.events,
}); });
} }
......
...@@ -15,13 +15,20 @@ import { z } from 'zod'; ...@@ -15,13 +15,20 @@ import { z } from 'zod';
* - payload → 业务字段(canonical 已映射,如 appointmentAt / status / doctorId 等) * - payload → 业务字段(canonical 已映射,如 appointmentAt / status / doctorId 等)
*/ */
export const PushEventSchema = z.object({ export const PushEventSchema = z.object({
subjectType: z.string().describe('CanonicalSubjectType: patient/appointment/...'), subjectType: z.string().describe('CanonicalSubjectType,见事件枚举:appointment/diagnosis/...'),
subjectRef: z.string().min(1).describe('宿主侧本资源的 externalId'), action: z.string().describe('canonical Action,见事件枚举'),
patientRef: z.string().min(1).optional().describe('关联 patient externalId;subjectType=patient 时可省'), /// 本记录在宿主侧的 id(= canonical externalId,进幂等键)。
clinicId: z.string().min(1).describe('发生诊所(立柱必填)'), subjectId: z.string().min(1).describe('本记录在宿主侧的 id(= canonical externalId)'),
action: z.string().describe('canonical Action: created/updated/canceled/...'), /// 所属患者 id;subjectType=patient(主档自身)时可省。
sourceEventId: z.string().min(1).describe('宿主侧唯一事件 id(幂等键)'), patientId: z.string().min(1).optional().describe('所属患者 id'),
/// 集团 id(= 登录授权的 tenantId)。单集团固定值,多集团按归属传。
tenantId: z.string().min(1).describe('集团 id'),
/// 源组织命名空间 id;集团内给患者号消歧,进幂等键防跨命名空间撞号。单一命名空间可省(='')。
sourceUnit: z.string().optional().default('').describe('源组织命名空间 id'),
clinicId: z.string().min(1).describe('发生诊所(操作类必填)'),
occurredAt: z.string().datetime({ offset: true }).describe('ISO 8601 with timezone'), occurredAt: z.string().datetime({ offset: true }).describe('ISO 8601 with timezone'),
/// 末次修改时间;进幂等键 → 内容变升版本、重试不变去重。缺省回退 occurredAt。
updatedAt: z.string().optional().describe('末次修改时间;驱动幂等 / 版本'),
eventSeq: z.number().int().nonnegative().optional().describe('递增序号,可选,用于水位'), eventSeq: z.number().int().nonnegative().optional().describe('递增序号,可选,用于水位'),
payload: z.record(z.string(), z.unknown()).describe('已 canonical 化的业务字段'), payload: z.record(z.string(), z.unknown()).describe('已 canonical 化的业务字段'),
}); });
...@@ -51,12 +58,13 @@ export type PushEventsResponse = z.infer<typeof PushEventsResponseSchema>; ...@@ -51,12 +58,13 @@ export type PushEventsResponse = z.infer<typeof PushEventsResponseSchema>;
* *
* - `source` = 原生源表名(= 宿主数据字典里的表名 / manifest tables[].table) * - `source` = 原生源表名(= 宿主数据字典里的表名 / manifest tables[].table)
* - `rows` = 原生行,PAC 用该 host 的 yaml 跑 transforms + assembler 拆分/映射/落库 * - `rows` = 原生行,PAC 用该 host 的 yaml 跑 transforms + assembler 拆分/映射/落库
* - `tenantId`= 选填;PAC 默认按 manifest 的 brand→tenant 逐行解析(同 pull/reparse) *
* 集团(tenant)/ 命名空间(source_unit)/ 诊所(clinic)都从原生行的对应列**逐行**解析
* (manifest brand→tenant + identity_namespace_field,同 pull / cold-import / reparse),host 不另传。
* *
* 跟 pull / cold-import / reparse 走同一条摄入管线,只是数据入口是 webhook。 * 跟 pull / cold-import / reparse 走同一条摄入管线,只是数据入口是 webhook。
*/ */
export const PushRowsRequestSchema = z.object({ export const PushRowsRequestSchema = z.object({
tenantId: z.string().min(1).optional(),
source: z.string().min(1).describe('原生源表名(= manifest tables[].table)'), source: z.string().min(1).describe('原生源表名(= manifest tables[].table)'),
rows: z rows: z
.array(z.record(z.string(), z.unknown())) .array(z.record(z.string(), z.unknown()))
......
...@@ -6,7 +6,7 @@ import { TransformEngine } from './transform-engine'; ...@@ -6,7 +6,7 @@ import { TransformEngine } from './transform-engine';
* *
* 5 operator(白名单):project / split_json_array / derive / route_by_pattern / pick_first_nonzero * 5 operator(白名单):project / split_json_array / derive / route_by_pattern / pick_first_nonzero
* *
* 详见 docs/algorithm/canonical-fact-layer.md(W3 末加 Layer A.5 章节)。 * 详见 apps/pac-docs/content/docs/architecture/data-ingestion.mdx(W3 末加 Layer A.5 章节)。
*/ */
@Module({ @Module({
providers: [TransformEngine], providers: [TransformEngine],
......
import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule, type OpenAPIObject } from '@nestjs/swagger';
import { cleanupOpenApiDoc } from 'nestjs-zod';
import { wrapOpenApiResponses } from './wrap-openapi';
/**
* OpenAPI 单一来源 —— 运行时(main.ts 挂 /api/docs)与文档站(dump CLI 生成静态 spec)
* 共用同一份构建逻辑,保证「文档 = 代码」零漂移。
*/
/** 全局路由前缀 */
export const GLOBAL_PREFIX = 'pac/v1';
/** 不挂前缀的路径(健康检查 / API 文档 / Bull Board UI) */
export const GLOBAL_PREFIX_EXCLUDE = [
'health',
'api/docs',
'api/openapi.json',
// Bull Board UI(@bull-board/nestjs 自己挂 Express handler,不走 Nest 路由前缀)
'admin/queues',
'admin/queues/*path',
];
/** tag → 中文标签 + 说明(文档站侧栏分组 / Scalar UI 用)。未列出的 tag 原样保留。 */
const TAG_DESCRIPTIONS: Record<string, string> = {
auth: '登录换票、会话、数据权限(orgScope)。',
facts: '患者事实写入(事件账本)与查询。',
patient: '患者主档、检索、时间线。',
persona: '患者画像与特征。',
plan: '随访计划:生成、分配、回收、统计。',
assistant: '实时坐席助手 / 会话辅助。',
sync: '数据摄入与增量同步。',
push: '触达结果回推 host(HMAC 验签)。',
admin: '后台管理:映射缺失、AI 调用、host 自助。',
mcp: 'MCP(Model Context Protocol)工具端点。',
internal: '内部任务(日报预览 / 触发等)。',
health: '健康检查。',
};
/**
* nestjs 只在 operation 上挂 tag,不生成顶层 `tags` 列表 —— 而文档站按 tag 分组、
* Scalar UI 也靠它排序。这里从所有 operation 收集 tag,补成顶层 `tags`(带中文说明)。
*/
function ensureTopLevelTags(doc: OpenAPIObject): OpenAPIObject {
const seen = new Set<string>();
for (const pathItem of Object.values(doc.paths ?? {})) {
if (!pathItem) continue;
for (const method of ['get', 'post', 'put', 'patch', 'delete'] as const) {
const op = pathItem[method];
for (const t of op?.tags ?? []) seen.add(t);
}
}
doc.tags = [...seen]
.sort()
.map((name) => ({ name, ...(TAG_DESCRIPTIONS[name] ? { description: TAG_DESCRIPTIONS[name] } : {}) }));
return doc;
}
/** 构建 PAC 的 OpenAPI 文档:zod 清洗 + 统一响应包络 + 顶层 tags。 */
export function buildPacOpenApiDocument(app: INestApplication): OpenAPIObject {
const config = new DocumentBuilder()
.setTitle('PAC API')
.setDescription(
'Patient Analysis Center — three-pool framework (facts in, plans out, agents inside).',
)
.setVersion('0.1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', name: 'Authorization', in: 'header' },
'accessToken',
)
.build();
return ensureTopLevelTags(
wrapOpenApiResponses(cleanupOpenApiDoc(SwaggerModule.createDocument(app, config))),
);
}
...@@ -2,12 +2,38 @@ import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/commo ...@@ -2,12 +2,38 @@ import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/commo
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { tenantGuardExtension } from './tenant-guard.extension'; import { tenantGuardExtension } from './tenant-guard.extension';
/**
* 连接池随 cohort 并发自动放大 —— 免去手动给 DATABASE_URL 追加 `?connection_limit=`。
*
* 病根:并发旋钮是 PAC_COHORT_CONCURRENCY(N 个 cohort runner 共享一个池),
* 但 Prisma 默认池大小是 `核数×2+1`,与 N 无关 → 一调并发就得手动调池。这里把两者联动:
* - 未设 / =1(常驻 API 进程)→ 不改 URL,走 Prisma 默认池;
* - >1(摄入是独立 process)→ connection_limit = N×6+5,封顶 40(守住 Postgres max_connections=100);
* - URL 里已显式写了 connection_limit → 尊重,不覆盖(逃生口)。
* 摄入是独立进程,只有它拿大池,常驻服务仍是小池,天然隔离。
*/
function withCohortDerivedPool(rawUrl: string | undefined): string | undefined {
if (!rawUrl) return rawUrl;
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return rawUrl; // 非标准 URL(如 unix socket)— 交给 Prisma 原样处理
}
if (url.searchParams.has('connection_limit')) return rawUrl;
const n = Math.max(1, parseInt(process.env.PAC_COHORT_CONCURRENCY ?? '1', 10) || 1);
if (n <= 1) return rawUrl;
url.searchParams.set('connection_limit', String(Math.min(n * 6 + 5, 40)));
return url.toString();
}
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name); private readonly logger = new Logger(PrismaService.name);
constructor() { constructor() {
super({ super({
datasourceUrl: withCohortDerivedPool(process.env.DATABASE_URL),
log: [ log: [
{ level: 'warn', emit: 'event' }, { level: 'warn', emit: 'event' },
{ level: 'error', emit: 'event' }, { level: 'error', emit: 'event' },
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
* - plan-asset-generate: 生成 PlanScript + PlanSummary(jobName = planId 串行) * - plan-asset-generate: 生成 PlanScript + PlanSummary(jobName = planId 串行)
* *
* 三个队列各自 jobName 锁 per-queue,跨队列允许并发(数据依赖天然)。 * 三个队列各自 jobName 锁 per-queue,跨队列允许并发(数据依赖天然)。
* 详见 docs/three-layer-model.md "事件驱动主路径"。 * 详见 apps/pac-docs/content/docs/architecture/three-layer-model.mdx "事件驱动主路径"。
*/ */
export const QueueName = { export const QueueName = {
PERSONA_RECOMPUTE: 'persona-recompute', PERSONA_RECOMPUTE: 'persona-recompute',
......
...@@ -19,7 +19,7 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro ...@@ -19,7 +19,7 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro
/** /**
* Queues — BullMQ 三个队列的统一注册入口。 * Queues — BullMQ 三个队列的统一注册入口。
* *
* 触发链路(详见 docs/three-layer-model.md): * 触发链路(详见 apps/pac-docs/content/docs/architecture/three-layer-model.mdx):
* pipeline-dispatcher 写完 transaction * pipeline-dispatcher 写完 transaction
* → enqueue persona-recompute (jobName=patientId) * → enqueue persona-recompute (jobName=patientId)
* → worker 串行跑 → 完成时 * → worker 串行跑 → 完成时
......
/**
* Push 多命名空间去重 — TransactionSynthesizer source_event_id 单元测试。
*
* 坐实:集团内同一条记录(同 subjectId + updatedAt),不同 source_unit(品牌/区域命名空间)
* → 不同 source_event_id → 不会被去重折叠成一条(否则两品牌同号患者的事件会互相覆盖)。
*
* 纯函数测试,不跑 DB(去重最终由 patient_transactions partial UNIQUE 兜底,本测试验证 KEY 正确)。
*/
import { TransactionSynthesizer } from '../src/modules/sync/pipeline/transaction-synthesizer';
import type { EmitsConfig } from '../src/modules/sync/assembler/assembler.schema';
describe('push | 多命名空间去重(source_event_id)', () => {
const synth = new TransactionSynthesizer();
const emits: EmitsConfig = {
action: 'appointment_created',
subjectType: 'appointment',
occurredAtField: 'occurredAt',
};
const make = (sourceUnit: string) => ({
rawRow: {},
emits,
resource: 'appointments',
hostId: 'h1',
tenantId: 'grp_001',
patientIdResolver: () => undefined,
sourceContext: 'push:test',
sourceUnit,
canonicalRow: {
externalId: 'apt-1',
clinicId: 'C001',
occurredAt: '2026-01-01T00:00:00+08:00',
updatedAt: '2026-01-02T00:00:00+08:00',
},
});
test('同 subjectId/updatedAt,不同 sourceUnit → 不同 source_event_id(不撞号)', () => {
const a = synth.synthesize(make('瑞尔'));
const b = synth.synthesize(make('瑞泰'));
expect(a).not.toBeNull();
expect(b).not.toBeNull();
expect(a!.sourceEventId).not.toBe(b!.sourceEventId);
expect(a!.sourceEventId).toContain(':瑞尔:');
expect(b!.sourceEventId).toContain(':瑞泰:');
});
test('完全相同(含 sourceUnit)→ 同 source_event_id(会被去重)', () => {
expect(synth.synthesize(make('瑞尔'))!.sourceEventId).toBe(
synth.synthesize(make('瑞尔'))!.sourceEventId,
);
});
test("单命名空间(sourceUnit='')→ key 稳定且不含品牌段", () => {
expect(synth.synthesize(make(''))!.sourceEventId).toBe(
'push:test:appointments::apt-1:2026-01-02T00:00:00+08:00',
);
});
});
...@@ -4,7 +4,7 @@ import './globals.css'; ...@@ -4,7 +4,7 @@ import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'PAC Workbench', title: 'PAC Workbench',
description: 'Patient Analyze Center workbench', description: 'Patient Analysis Center workbench',
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
......
...@@ -63,7 +63,7 @@ docker compose -f docker-compose.prod.yml exec pac-service sh ...@@ -63,7 +63,7 @@ docker compose -f docker-compose.prod.yml exec pac-service sh
| `systemd/pac-service.service` | NestJS 后端 systemd unit 模板 | | `systemd/pac-service.service` | NestJS 后端 systemd unit 模板 |
| `systemd/pac-web.service` | Next.js 前端 systemd unit 模板 | | `systemd/pac-web.service` | Next.js 前端 systemd unit 模板 |
`docs/deployment-data-ingest.md` 配合看 — 那份讲数据 / 监控,本目录讲部署机制。 `apps/pac-docs/content/docs/operations/deployment.mdx` 配合看 — 那份讲数据 / 监控,本目录讲部署机制。
--- ---
...@@ -184,7 +184,7 @@ sudo -u pac bash -c 'cd /opt/pac/apps/pac-service && pnpm recompute-persona -- - ...@@ -184,7 +184,7 @@ sudo -u pac bash -c 'cd /opt/pac/apps/pac-service && pnpm recompute-persona -- -
sudo -u pac bash -c 'cd /opt/pac/apps/pac-service && pnpm recompute-plans -- --host=jvs-dw' sudo -u pac bash -c 'cd /opt/pac/apps/pac-service && pnpm recompute-plans -- --host=jvs-dw'
``` ```
之后 cron(02:30)自动维护增量,见 `docs/deployment-data-ingest.md` §三。 之后 cron(02:30)自动维护增量,见 `apps/pac-docs/content/docs/operations/deployment.mdx` §三。
--- ---
...@@ -204,7 +204,7 @@ sudo -u pac bash /opt/pac/deploy/deploy.sh staging # 同 deploy 流程,只是 ...@@ -204,7 +204,7 @@ sudo -u pac bash /opt/pac/deploy/deploy.sh staging # 同 deploy 流程,只是
## 监控 / 告警 ## 监控 / 告警
详见 `docs/deployment-data-ingest.md` §四。关键: 详见 `apps/pac-docs/content/docs/operations/deployment.mdx` §四。关键:
- `journalctl -u pac-service -f | grep -E "ERROR|dw-lag"` 看错误 + 滞后告警 - `journalctl -u pac-service -f | grep -E "ERROR|dw-lag"` 看错误 + 滞后告警
- `psql -U pac -d pac -c "SELECT status, count(*) FROM sync_logs WHERE started_at > now()-'1d'::interval GROUP BY status;"` 看同步成功率 - `psql -U pac -d pac -c "SELECT status, count(*) FROM sync_logs WHERE started_at > now()-'1d'::interval GROUP BY status;"` 看同步成功率
- `/admin/queues` Bull Board 看队列 - `/admin/queues` Bull Board 看队列
...@@ -120,6 +120,18 @@ services: ...@@ -120,6 +120,18 @@ services:
ports: ports:
- "127.0.0.1:3100:3100" - "127.0.0.1:3100:3100"
pac-docs:
build:
context: .
dockerfile: apps/pac-docs/Dockerfile
target: prod
restart: always
environment:
NODE_ENV: production
PORT: 3102
ports:
- "127.0.0.1:3102:3102"
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
...@@ -126,6 +126,25 @@ services: ...@@ -126,6 +126,25 @@ services:
- /app/apps/pac-web/node_modules - /app/apps/pac-web/node_modules
- /app/apps/pac-web/.next - /app/apps/pac-web/.next
pac-docs:
build:
context: .
dockerfile: apps/pac-docs/Dockerfile
target: dev
container_name: pac-docs
restart: unless-stopped
environment:
NODE_ENV: development
PORT: 3102
ports:
- "3102:3102"
volumes:
- ./apps/pac-docs:/app/apps/pac-docs
- /app/node_modules
- /app/apps/pac-docs/node_modules
- /app/apps/pac-docs/.next
- /app/apps/pac-docs/.source
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
......
# docs/
PAC 的文档已迁移到独立文档站 **`apps/pac-docs`**(Fumadocs)。
- 本地预览:`pnpm --filter @pac/docs dev` → http://localhost:3102
- 文档源:`apps/pac-docs/content/docs/**`(MDX,单一来源)
- API 参考:由 `pnpm docs:openapi` 从 NestJS spec 自动生成
## `_archive/`
历史快照:迁移前的原始 `.md`(含 v1/v0 旧版、周报、已合并的算法/场景文档)。
**只读留档,不再维护**;现行内容以 `apps/pac-docs` 为准。
---
title: Canonical 事实层
description: 把任意宿主的任意数据形态经 4 层规范化收束到唯一可信的 fact.content。
icon: Binary
---
> **版本**:v2 · W3 末(W2 末 v1 设计 → W3 末实施 + 4 入口统一管道补完)
> **作者**:luoqi
> **状态**:✅ 已交付实施(详见 §六 / §九)
>
> **本文定位**:`docs/algorithm/potential-treatment-recall.md` 的**基础架构补充**。potential-treatment-recall 讲"召回算法",本文讲"算法可信的前提:fact 怎么保证 canonical"。
>
> **形成背景**:W2 末扒 P011 真实数据发现 `raw_payload → content` 链路 7 个真实问题(详见附录 A);W3 落地实施后,跑通 DW 真实 1000 患者 cohort → 63,781 transactions / ~60,000 facts / 0 failed。
>
> **v2 增量**(相对 v1):
> - 加 **Layer A.5 transforms**(6 operator 白名单)— SQL 形态改造下放 yaml,真双源等价
> - 加 **canonical 第 11 category `review`**(承载复查/拆线/暂观)
> - 加 enum_mapping `_default` 兜底键(长尾值统一处理)
> - 加 **4 入口统一管道**(cold / DW 直连 / pull / push 共用 transforms+assembler+parser)— §十 详
---
## 一句话核心结论
> **PAC 的算法 / Persona / 前端**只读一个东西:`patient_facts.content`(由 PAC 标准 schema 强约束)。
>
> 任何宿主的任何数据形态,经过 **3 层规范化(结构 → 语义 → 信号)** 在 `fact.content` 收束,**单一可信源,单一收口**。
---
## 一、三大架构原则
### 原则 1 · 职责清晰(每层只做一类事)
canonical 化分 **4 层**(W3 末加 Layer A.5;算法层在 fact.content 下游消费,不在本文范围):
| 层 | 单一职责 | 不该做的 |
|---|---|---|
| **Layer A.5 transforms**(yaml,per-host,W3 末新增) | 形态改造:JSON 拆行 / 字段派生 / 关键词分流 / 多列推断 / 行过滤 | ❌ 任意 JS 表达式 / eval(只 6 operator 白名单) |
| **Layer A AssemblerEngine**(yaml,per-host) | 字段命名翻译 + 枚举翻译 + 结构映射(host N 张表 ↔ PAC 资源) | ❌ 派生 / 文本抽取 / 复杂业务(那是 A.5 的活) |
| **Layer B Parser + Zod Schema**(per fact_type) | 语义规范化(字段派生 / 单位转换)+ schema 强校验 | ❌ 字段名翻译(那是 A 的活)/ 自由文本抽取 |
| **Layer C Signal Extractor**(LLM + 规则,异步,W5+) | 自由文本 / 多源融合 → 结构化 signals | ❌ 改 fact 主体字段(只增补 normalized_signals[]) |
**口诀**:**形态归 A.5 / 翻译归 A / 校验归 B / 抽取归 C** — 各管一类语义,不重叠不复制。
(A.5 + A 都用 yaml 声明,但 A.5 管"怎么变"、A 管"叫什么",界限是"是否改变行的数量/结构":拆行/路由/过滤 → A.5;只改字段名/值 → A。)
**纪律**:算法层(scenario / Persona / 前端)是 fact 的下游**消费方**,只读不写。
**为什么加 Layer A.5(W3 末)**:原 v1 设计只让 SQL 做形态改造(ARRAY JOIN / JSONExtract / multiIf 关键词),但这违反"双源等价"承诺 — 真换 csv 单表 dump 模式,这些 SQL 操作全部跑不了。Layer A.5 把这些操作下放 yaml 声明式 6 operator,SQL 退化为"等价 csv 单表 dump 的 SELECT",任意切换数据源不动 yaml。
### 原则 2 · 中间过程可审计
每层产物**必须留底**,任何 fact 算错都能反查:
```
host raw ──→ patient_transactions.raw_payload (留 host 原文,W2 已有)
transforms 中间表 ──→ (内存中,不持久化;TransformEngine 详细 log)
canonical row ──→ patient_transactions.canonical_payload (W2 末新增,W3 已实施 ✅)
fact.content ──→ patient_facts.content (强 schema)
normalized_signals→ patient_facts.content.normalized_signals[] (W5+)
```
✅ **canonical row 留底已落地**(`patient_transactions.canonical_payload` Json? 字段,W3 已上线)。
### 原则 3 · 单一收口(重复多处改集中一处)
每种"东西"只有一个**唯一定义源**:
| 东西 | 单一源 |
|---|---|
| 诊断 / 治疗 enum 字典 | `packages/types/src/canonical-codes.ts` |
| fact.content schema | `apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts` |
| 写 fact 的入口 | `FactWriter.writeDraft()`(其他人不许 prisma.patientFact.create) |
| 写 transaction 入口 | `TransactionSynthesizer` |
| 入队 | `QueueProducer` |
| 调 AI | `AiCallRunner` |
| scenario 时间窗配置 | `apps/pac-service/src/modules/plan/engine/scenario-config.ts` |
**违反单一收口** = code review block。
---
## 二、9 道闸保证 `fact.content` 唯一可信(W3 末更新,从 5 道扩到 9 道)
```
host raw rows
[闸 5] 6 operator 白名单(transforms yaml) ← 不允许任意 JS / eval
canonical row (留底 transaction.canonical_payload)
[闸 4] enum_mapping 闭集(canonical-codes 字典)+ _default 兜底 ← 长尾自动 drop
Parser FactDraft
[闸 1] FactContentSchema zod 校验 ← 写库前过,失败抛错不入库
[闸 2] FactWriter 唯一写入入口 ← 其他人不许直写 prisma
[闸 3] PAC canonical-codes.ts 字典 ← enum 值闭集,新增走 PR
patient_facts.content
[闸 7] subject_id partial UNIQUE ← 同 fact 多 active 版本冲突
[闸 8] dedup_by yaml 兜底 ← DW ETL 炸行
[闸 9] event_seq BigInt 水位 ← Persona 重算丢漏
[闸 6] CI 防漂移测试 53 个 ← PR 合并前(canonical-fact-layer + transforms + dispatcher)
算法 / Persona / 前端 只读 fact.content(不读 raw_payload / canonical_payload)
```
> ⭐ 实际 CI 跑通 53 个测试(W3 末);9 道闸全部落地,**0 个手工 review**。
### 闸 1 · FactContentSchema 强校验
每个 fact_type 一份 zod schema(详见 §五.B 设计):
```typescript
// fact-content-schemas.ts
import { PACDiagnosisCode, PACTreatmentCategory, FDIToothPosition } from '@pac/types';
export const FactContentSchemas = {
diagnosis_record: z.object({
code: PACDiagnosisCode, // ⭐ 强枚举,字典之外的值进不去
name: z.string().nullable(),
tooth_position: FDIToothPosition.nullable(),
severity: z.enum(['mild', 'moderate', 'severe']).nullable(),
onset_date: z.string().nullable(),
source_encounter_external_id: z.string(),
}),
treatment_record: z.object({
category: PACTreatmentCategory, // ⭐ 强枚举
subtype: z.string().nullable(),
tooth_position: FDIToothPosition.nullable(),
status: z.enum(['planned', 'in_progress', 'completed', 'cancelled']),
started_at: z.string().nullable(),
completed_at: z.string().nullable(),
source_encounter_external_id: z.string(),
related_diagnosis_subject_id: z.string().nullable(),
}),
encounter_record: z.object({
encounter_external_id: z.string(),
doctor_id: z.string().nullable(),
notes: z.string().nullable(),
// diagnoses / treatments 不嵌套了,由 diagnosis_record / treatment_record 独立承担 ⭐
}),
emr_record: z.object({ /* ... */ }),
image_record: z.object({ /* ... */ }),
// ...
} as const;
```
`FactWriter.writeDraft()` 写库前必过 schema,违反抛 `SchemaViolationError`,**脏数据进不了库**。
### 闸 2 · FactWriter 唯一入口
```typescript
// 全代码库唯一允许写 fact 的入口
export class FactWriter {
async writeDraft(...) {
const schema = FactContentSchemas[draft.type];
const validated = schema.parse(draft.content); // 闸 1 校验
// ... fact version 流逻辑 ...
await this.prisma.patientFact.create({ data: ... });
}
}
```
CI 加 lint rule:除 FactWriter 文件,其他文件出现 `prisma.patientFact.create` / `update` = build fail。
### 闸 3 · canonical-codes.ts 字典闭集
```typescript
// packages/types/src/canonical-codes.ts
export const PACDiagnosisCodes = {
K02: { name_zh: '龋病', icd10_range: 'K02.*' },
K04: { name_zh: '牙髓 / 根尖周炎', icd10_range: 'K04.*' },
K05: { name_zh: '牙周炎 / 牙龈炎', icd10_range: 'K05.*' },
K08: { name_zh: '牙列丢失 / 缺牙', icd10_range: 'K08.*' },
// 业务码(ICD 不分但 PAC 算法需要)
IMPLANT_FAILURE: { name_zh: '种植失败', business: true },
ORTHO_RELAPSE: { name_zh: '正畸反弹', business: true },
// 推荐码(EMR 文本抽取产物)
IMPLANT_RECOMMENDED: { name_zh: '建议种植' },
CROWN_RECOMMENDED: { name_zh: '建议戴冠' },
// ...
} as const;
export type PACDiagnosisCode = keyof typeof PACDiagnosisCodes;
```
**所有 yaml mapping / parser / scenario / 前端 label 都引用这个文件**,新增码必走 PR + 业务方 + 牙医 review。
### 闸 4 · CI 防漂移
```typescript
// tests/canonical-fact.spec.ts
describe('Canonical Fact Layer', () => {
test('all mock data passes FactContentSchemas', () => {
for (const draft of MOCK_FACT_DRAFTS) {
expect(FactContentSchemas[draft.type].safeParse(draft.content).success).toBe(true);
}
});
test('all yaml enum_mapping targets exist in canonical-codes', () => {
const yamls = loadAllAssemblerYamls();
for (const yml of yamls) {
for (const v of Object.values(yml.enum_mapping ?? {})) {
expect(Object.keys(PACDiagnosisCodes).includes(v)).toBe(true);
}
}
});
});
```
PR 改 yaml / parser / scenario,CI 自动跑全套验证,漂移立挂。
### 闸 5 · 单向消费(算法不读 raw_payload)
代码 review 红线:
- ❌ scenario SQL 不能 LIKE `raw_payload->>` 任何字段
- ❌ Persona feature 不能直接读 `patient_transactions.raw_payload`
- ❌ AI prompt 不能拼 raw_payload 文本(用 fact.content)
- ✅ 唯一例外:cold-import replay CLI 可以读 raw 重跑(运维通路)
---
## 三、PAC 标准字典 — 集团统一,宿主随意
### 3.1 关键定义澄清
> **"集团统一"** = **PAC 系统统一**(所有宿主接 PAC 看到的都是 PAC canonical 字典)。
>
> **宿主端**可以任意用:ICD-10 / 自定义码 / 中文 / 自由文本 / 实表 / 视图,**字段命名也不要求按 PAC 规范**。Layer A yaml 负责把宿主形态翻译到 PAC 字典。
**这点最重要的工程含义**:跟宿主沟通时**不要求对方按 PAC 标准改字段命名**——那是 yaml 的活。宿主只要"字段全给 + 含义说清 + 提供更新时间列"就够。
### 3.2 PAC 字典设计原则
```
不绑死任何外部标准,但借 ICD-10 命名做骨架
+ 加 PAC 业务码(国际标准没有但算法需要的)
+ 保持小集合(50-200 个码,够用就行)
```
### 3.3 字典内容(初版,候选,W3 跟牙医 review)
#### 诊断码
```typescript
export const PACDiagnosisCodes = {
// 临床主类(借 ICD-10 K00-K14 大类)
K02: { name_zh: '龋病' },
K03: { name_zh: '牙体硬组织其他疾病' },
K04: { name_zh: '牙髓 / 根尖周炎' },
K05: { name_zh: '牙周炎 / 牙龈炎' },
K06: { name_zh: '牙龈 / 牙槽骨疾患' },
K07: { name_zh: '牙颌面异常' },
K08: { name_zh: '牙列丢失 / 缺牙' },
K09: { name_zh: '颌骨囊肿' },
K10: { name_zh: '颌骨其他疾病' },
K11: { name_zh: '唾液腺疾患' },
K12: { name_zh: '口腔黏膜疾患' },
K13: { name_zh: '唇 / 口腔黏膜其他疾患' },
K14: { name_zh: '舌疾患' },
// PAC 业务码(治疗失败 / 投诉相关)
IMPLANT_FAILURE: { name_zh: '种植失败 / 周围炎' },
ORTHO_RELAPSE: { name_zh: '正畸反弹' },
ENDO_FRACTURE: { name_zh: '根管牙折裂' },
// 治疗推荐码(EMR 文本抽取产物,临床上很重要)
IMPLANT_RECOMMENDED: { name_zh: '建议种植' },
CROWN_RECOMMENDED: { name_zh: '建议戴冠' },
FILLING_RECOMMENDED: { name_zh: '建议充填' },
SRP_RECOMMENDED: { name_zh: '建议牙周基础治疗' },
EXTRACTION_RECOMMENDED:{ name_zh: '建议拔除' },
ANNUAL_REVIEW_RECOMMENDED: { name_zh: '建议年度复查' },
ORTHO_CONSULT_RECOMMENDED: { name_zh: '建议正畸咨询' },
} as const;
```
#### 治疗类别 + 子类(W3 末扩到 11 类)
```typescript
export const PACTreatmentCategories = {
endodontic: { name_zh: '牙髓 / 根管' },
restorative: { name_zh: '充填 / 嵌体' },
prosthodontic: { name_zh: '修复 / 冠桥' },
implant: { name_zh: '种植' },
periodontic: { name_zh: '牙周' },
orthodontic: { name_zh: '正畸' },
surgical: { name_zh: '外科' },
pediatric: { name_zh: '儿牙' },
preventive: { name_zh: '预防' },
cosmetic: { name_zh: '美学' },
/// ⭐ W3 末加 — 承载复查/拆线/暂观/无治疗等"流程性事实"
/// 实测 DW EMR.treat_plan 大量含"常规复查 12k / 拆线 1.5k / 暂观 1.8k / 无治疗 1.5k"等
/// 这些动作是真实事实(医生做了某节点 / 临床判断本次不动手),不该塞 preventive(污染"已做预防"判定)
/// scenario 算法**不消费**(不算"已做治疗"),fact 层保真
review: { name_zh: '复查 / 流程' },
} as const;
```
#### 牙位(FDI 标准)
```typescript
// FDI 牙位:11-48(恒牙)+ 51-85(乳牙)
export const FDIToothPositions = ['11','12','13','...','48','51','52','...','85'] as const;
```
#### 其他
- ExecutionOutcome(已有)
- DoNotContactReason(已有)
- 其他按需扩展
### 3.4 维护流程
```
新增码:PR
├─ 提议人:产品 / 业务方 / 牙医 / PAC 工程
├─ Reviewer:至少 1 牙医 + 1 业务方 + 1 PAC 工程
├─ 必须填:含义 / 临床触发场景 / 算法用法 / 中文名
└─ 合并后:CI 自动检查相关 yaml / parser / scenario 引用
废弃码:仅标 deprecated(不删,历史数据要)
```
### 3.5 宿主内部多租户 / 多品牌的隔离(W2 末从瑞尔 DW 学到)
接入瑞尔集团 DW 时发现:**一个 host 内部可能存在多 brand / 多租户**,所有业务 ID(`patient_id` / `appointment_id` / `doctor_id` 等)**只在 brand 内唯一,跨 brand 会重复**。
实际数据:
```
单 host(瑞尔集团 DW)内 2 个 brand:
瑞尔 → 3 家诊所(杭州大厦 / 杭州高德 / 北京朝阳公园)
瑞泰 → 2 家诊所(通善学前街 / 上海世纪公园)
跨 brand ID 重复实例:
patient_id = 1310616
+ brand=瑞尔 → 田雨嘉(9 岁儿童 / 杭州大厦)
+ brand=瑞泰 → 陈振志(44 岁成人 / 上海世纪公园)
完全不同的两个人共享同一 patient_id
```
**PAC 落地:brand 映射到 `tenant_id`**(直接利用 PAC 现有的 `(host_id, tenant_id, external_id)` 三段隔离):
```yaml
# Layer A yaml(瑞尔 DW 接入)
brand_to_tenant:
"瑞尔": ruier
"瑞泰": ruitai
```
落地后:
- `patients` 表 `unique(host_id, tenant_id, external_id)` 自动防冲突
- 算法 SQL 永远 `WHERE host_id=? AND tenant_id=?`,两个 brand 完全隔离
- 客服 / 主管 / dashboard 通过 JWT.tenantId 限定,看不到对方品牌数据
- 跨品牌身份合并(未来"瑞尔的张三 = 瑞泰的张三")走 `Patient.mergedIntoId` 自引用
**纪律**:接入新宿主时,如果发现"业务 ID 跨某维度重复",**那个维度就是 tenant 维度,塞 `tenant_id`**,不要在 fact 主键里拼前缀。
---
## 四、fact 结构重塑 — 从第一性原理推
### 4.1 问题:当前 fact_type 设计对吗?
**现状**:
- `encounter_record` 内嵌 `diagnoses[]` + `treatments[]` + `next_steps[]`
- `treatment_plan`(planned)独立存在
**问题**:
1. 诊断 / 治疗是临床独立实体,捆在 encounter 里**违背独立语义**
2. 治疗周期长(种植 3-6 个月),跨多次接诊,**嵌在第一次 encounter 里追踪不到状态变化**
3. SQL 查"该患者所有 K08 诊断" → 遍历所有 encounter 的 JSONB 数组,**索引不友好**
4. 修改一个诊断 → 整个 encounter 进新版本,**粒度太粗**
5. 业务上同一颗牙跨年度多次诊断,**追踪是核心需求**
### 4.2 第一性原理推导
**原理 1 · fact 是"患者世界发生了什么"**
- 诊断 = "患者被诊断有 X 病"(独立事件,有自己的生命周期)
- 治疗 = "患者被做了 Y 治疗"(独立事件,跨多次接诊)
- 接诊 = "患者来了一次诊所"(只是容器 / 时间锚点)
这 **3 件事是不同语义粒度**,不该捆在一起。
**原理 2 · PAC 算法的查询路径都是"按诊断 / 治疗"维度查**
- "这个患者有 K08 吗?" → 查诊断
- "做过种植吗?" → 查治疗
- "最近一次接诊里有啥?" → EMR 视角,不是召回视角
**原理 3 · 通用性 / 宿主多样性**
- 宿主 A:接诊 + 诊断子表 + 治疗子表(典型结构化系统)
- 宿主 B:只有"病历"表,诊断/治疗都在自由文本(小诊所)
- 宿主 C:治疗有独立生命周期表(种植中心)
- 三种形态,**PAC 都应该看到统一 fact**
**原理 4 · 版本流粒度**
- 治疗"in_progress → completed" 是治疗自己的状态,**应该治疗 fact 自己进新版本**,不该带动 encounter
- 诊断的 severity / 进展 也独立演化
**原理 5 · 证据链清晰**
- 一条 plan 的证据 = [diagnosis_record 36 K08] + [emr_record 建议种植] + [image_record CBCT 符合]
- 独立 fact 互相引用比"挖嵌套 JSONB"清晰
### 4.3 决策:**拆**
```diff
- encounter_record (内嵌 diagnoses[]/treatments[]/next_steps[])
+ encounter_record (只装接诊元数据:doctor_id, notes, location, started_at)
+ diagnosis_record (独立, kind=actual, content={code,name,tooth_position,severity,...})
+ treatment_record (独立, kind=actual/planned, content={category,subtype,status,...})
treatment_plan ⛔ 废弃 — 合并进 treatment_record(用 kind=planned 替代)
review_plan ⛔ 废弃 — 合并进 treatment_record(category=review)
+ recommendation_record (独立, kind=planned, content={code,tooth_position,doctor_suggested,window_days,...})
```
### 4.4 新 fact_type 全集(精简版,14 个)
| fact_type | kind | 业务含义 | 主要字段 |
|---|---|---|---|
| **encounter_record** | actual | 接诊事件(只元数据) | encounter_external_id, doctor_id, notes |
| **diagnosis_record** ⭐ 新 | actual | 诊断(独立) | code(PAC 字典), tooth_position, severity, source_encounter |
| **treatment_record** ⭐ 新合并 | actual / planned | 治疗(独立,含计划/已做) | category, subtype, status, planned_for, started_at, completed_at |
| **recommendation_record** ⭐ 新 | planned | 医生建议(EMR / 影像抽出来的"建议种植"等) | code, tooth_position, doctor_suggested_at, window_days |
| **emr_record** | actual | 病历(主诉 / 现病史 / 治疗计划 自由文本) | diagnosis_text, treatment_plan, doctor_advice |
| **image_record** | actual | 影像(metadata + finding) | modality, finding, tooth_positions, encounter_external_id |
| **appointment_record** | actual | 预约 | scheduled_at, status, type |
| **appointment_plan** | planned | 预约计划 | planned_for, type |
| **order_record** | actual | 收费单 | items[], amount_cents |
| **payment_record** | actual | 收款 | amount_cents, channel |
| **refund_record** | actual | 退费 | amount_cents, reason |
| **recharge_record** | actual | 充值 / 储值卡 | amount_cents, card_type |
| **complaint_record** | actual | 投诉 | content, severity |
| **referral_record** | actual | 转介绍 | referrer_patient_id, channel |
| **consultation_record** | actual | 咨询(400) | content, source_channel |
| **visit_registration_record** | actual | 挂号 | doctor_id, registered_at |
**核心变化**:
- ✅ diagnosis / treatment 从 encounter 嵌套提升为独立 fact
- ✅ treatment_plan / review_plan 合并进 treatment_record(用 kind=planned + category 区分)
- ✅ 新增 recommendation_record 承接"医生建议"信号(EMR 文本 / 影像 finding 抽取产物)
### 4.5 关联怎么挂?
```
encounter_record (subject_id = "encounter_record:enc_001")
│ via patient_facts.transactionIds[] (反查 transaction → encounter)
│ via content.source_encounter_external_id (正查 diagnosis → encounter)
diagnosis_record (subject_id = "diagnosis_record:enc_001_dx_1")
treatment_record (subject_id = "treatment_record:tx_001")
emr_record (subject_id = "emr_record:emr_001", content.encounter_external_id="enc_001")
image_record (subject_id = "image_record:img_001", content.encounter_external_id="enc_001")
```
通过 `source_encounter_external_id` 字段反查,**不嵌套不冗余**。
### 4.6 P011 患者在新模型下长什么样
> P011 是 PAC 内部 `data/mock-demo/` 里的演示样本(36 牙缺失场景),不是宿主真实数据。这里用它说明 fact 新结构;W3 切真实 DW 数据后,所有 patient_id 会换成真实值,但 schema 形态完全一致。
```
patient: P011, 36 牙缺失场景
fact 1: encounter_record:enc_P011_exam
{ encounter_external_id: "enc_P011_exam", doctor_id: "doc_wang",
notes: "36 牙缺失,建议种植修复,患者考虑中" }
fact 2: diagnosis_record:enc_P011_exam_dx_1 ⭐ 独立
{ code: "K08", name: "牙缺失", tooth_position: "36",
source_encounter_external_id: "enc_P011_exam" }
fact 3: emr_record:emr_P011_exam
{ diagnosis_text: "36 牙缺失,牙槽骨条件良好",
treatment_plan: "建议单颗种植 36",
doctor_advice: "2-3 月内启动种植治疗" }
fact 4: image_record:img_P011_cbct_36
{ modality: "cbct", finding: "36 区骨高度 12mm,符合种植条件" }
fact 5: recommendation_record:emr_P011_exam_rec_1 ⭐ 新,Layer C 抽取
{ code: "IMPLANT_RECOMMENDED", tooth_position: "36",
extracted_from: "emr_record:emr_P011_exam",
extracted_by: "llm", confidence: 0.95,
window_days: 90, doctor_suggested_at: "2026-03-19" }
(无 treatment_record:K08 尚未治疗)
```
### 4.7 SQL 查询(新模型)— 漂亮 + 索引友好
```sql
-- "找所有有 K08 诊断但没启动 implant 治疗的患者(过去 180 天)"
SELECT p.id
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
WHERE p.active AND NOT pp.do_not_contact AND NOT pp.deceased
AND EXISTS (
SELECT 1 FROM patient_facts dx
WHERE dx.patient_id = p.id
AND dx.type = 'diagnosis_record'
AND dx.status = 'active'
AND dx.content->>'code' = 'K08' -- ⭐ 直接打字段,可建索引
AND dx.occurred_at BETWEEN now() - '180d' AND now() - '30d'
)
AND NOT EXISTS (
SELECT 1 FROM patient_facts tx
WHERE tx.patient_id = p.id
AND tx.type = 'treatment_record'
AND tx.kind = 'actual'
AND tx.content->>'category' = 'implant' -- ⭐ 直接打
);
-- 加分项:跨 diagnosis 和 recommendation 联合
OR EXISTS (
SELECT 1 FROM patient_facts rec
WHERE rec.patient_id = p.id
AND rec.type = 'recommendation_record'
AND rec.content->>'code' = 'IMPLANT_RECOMMENDED' -- ⭐ EMR 抽出来的强信号
AND rec.content->>'tooth_position' = dx.content->>'tooth_position'
)
```
**比当前 `EXISTS (SELECT 1 FROM jsonb_array_elements(...))` 干净 10 倍**。
### 4.8 拆分代价
| 代价 | 评估 |
|---|---|
| fact 行数增加 | 一次接诊原 1 行 → 1 + N + M 行(N=诊断数,M=治疗数);P011 这种从 1 → 1+1=2 行;高产患者从 1 → 1+5+5=11 行。**接受**,fact 表行数本就不是瓶颈 |
| Parser 复杂度 | encounter.parser 拆成 encounter / diagnosis / treatment 3 个 parser,**符合 SRP** |
| 历史数据迁移 | 一次性 script:扫旧 encounter_record,拆 diagnoses[] / treatments[] 出来生成新 fact;1 d 工作量 |
| API 影响 | 详情页查"该 plan 的证据链" → join 多 fact,加几行 SQL |
**总代价可控,长期收益 >> 一次性投入**。
### 4.9 跟 data-model 文档的冲突
文档 §八 当前定义:
```
- encounter_record(content 含 diagnoses[]/treatments[]/next_steps[] 衍生)
- treatment_plan(planned)
- review_plan(planned)
```
新模型:
```
- encounter_record(只元数据)
- diagnosis_record(actual,新)
- treatment_record(actual/planned,合并旧 treatment_plan + review_plan)
- recommendation_record(planned,新)
```
**做法**:更新 [data-model](/docs/architecture/data-model) §八 + 加 schema migration(新增 fact_type enum 值,无需改表结构,因为 type 是 String)。
---
## 五、4 层规范化架构(W3 末加 Layer A.5)
复盘前面所有讨论,最终架构:
```
┌──────────────────────────────────────────────────────────────────┐
│ host 原始数据(无限多样性) — 4 入口取数(§十) │
│ - 宿主 A:标准 5 张表 + 结构化码 │
│ - 宿主 B:1 张病历表,诊断/治疗全自由文本 │
│ - 宿主 C:大宽表 + 分隔符串 │
│ - DW 视图:聚合 / 已 ETL(瑞尔 jvs-dw 实际形态) │
└────────────────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Layer A.5 · transforms(yaml 驱动,W3 末新增) │
│ 职责:形态改造 — 6 operator 白名单(纯函数,无副作用) │
│ 解决:host 原始形态(嵌套 JSON / 多列推断 / 关键词分流)→ 整理 │
│ 能力(6 operator): │
│ ① project 选列 / 改名 │
│ ② split_json_array JSON 数组拆行(EMR.diag 1 行→N 行) │
│ ③ derive substring / concat / lower / default │
│ ④ route_by_pattern 按字段值分流到多输出表(建议/复查/治疗) │
│ ⑤ pick_first_nonzero 多个数值列推单列(17 个 pay_*→channel) │
│ ⑥ filter 多字段 AND 行过滤(in_time non-null) │
│ 输出:整理后的 raw tables(row 数组,字段命名仍 host 原生) │
│ 纪律:**不允许任意 JS 表达式 / eval**;撞墙必加 operator + PR │
└────────────────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Layer A · AssemblerEngine(yaml 驱动,per-host) │
│ 职责:结构映射 + 字段命名 + 枚举翻译 │
│ 解决:整理好的表 → PAC 18 类 fact_type 固定结构 │
│ 能力: │
│ ✓ 一张表 → 一个 canonical resource(primary.table 索引) │
│ ✓ field_mapping(字段命名) │
│ ✓ enum_mapping(自定义码 → PAC 字典 + `_default` 兜底) │
│ ✓ join_arrays(1:N 子表嵌入 canonical 数组,极少用) │
│ ✓ dedup_by(炸行兜底) │
│ ✓ emits(action / subjectType / occurredAtField) │
│ 输出:canonical row(留底到 patient_transactions.canonical_payload)│
│ 纪律:per-host yaml,**不写业务逻辑 / 不调 LLM**(那是 A.5/C 的活)│
└────────────────────────────────┬─────────────────────────────────┘
canonical row(留底到 transaction.canonical_payload)
┌──────────────────────────────────────────────────────────────────┐
│ Layer B · Parser + FactContentSchema(per fact_type,集团统一) │
│ 职责:语义规范化 + 派生 + schema 强校验 │
│ 能力: │
│ ✓ 字段最终命名(camelCase → snake_case 等) │
│ ✓ 单位归一化(yuan → cents) │
│ ✓ 派生字段(treatments[i].expectedNextStep → next_steps[]) │
│ ✓ 引用 canonical-codes.ts 字典(枚举闭集) │
│ ✓ FactWriter 入库前 zod schema 校验 │
│ 输出:fact.content(强 schema 化,可信) │
│ 纪律:per fact_type,**不读 host 自由文本** │
└────────────────────────────────┬─────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Layer C · Signal Extractor(异步,LLM + 规则,per fact_type) │
│ 职责:从 fact.content 的自由文本里抽结构化信号 │
│ 能力: │
│ ✓ extract_clinical_signals AiCall(LLM 抽) │
│ ✓ 关键词兜底 │
│ ✓ 产 recommendation_record 等新 fact │
│ ✓ 或回写 fact.content.normalized_signals[] │
│ 输出:fact.content(增强后)+ recommendation_record(派生 fact) │
│ 纪律:per fact_type,**不改 fact 主体字段,只增补 signals 字段** │
└────────────────────────────────┬─────────────────────────────────┘
━━━━━ fact.content ⭐ 单一收束点 ⭐ ━━━━━
下游消费(算法 / Persona / 前端 / AI)
• 永远只读 fact.content
• 不碰 raw_payload / canonical_payload / host 原表
• SQL 永远 SELECT FROM patient_facts
```
### 5.2 字段级翻译对照表(P011 缺牙演示样本 → 召回的完整字段路径)
下面是真实字段 4 段翻译的完整对照,**任何一处错位就会导致召回算法失效**:
| 业务概念 | host raw 字段 | canonical 字段(临时) | fact.content 字段(持久化) | 算法 SQL 查询路径 |
|---|---|---|---|---|
| 接诊 ID | `tb_encounter.encounter_no` | `externalId` | `encounter_record.content.encounter_external_id` + fact `subject_id` 后缀 | `pf.subject_id = 'encounter_record:enc_P011_exam'` |
| 患者 ID | `tb_encounter.patient_pid` | `patientExternalId` | (反查 `patients.external_id`)→ fact 表 `patient_id` UUID | `pf.patient_id = <UUID>` |
| 诊所 | `tb_encounter.clinic_code` | `clinicId` | fact 表 `clinic_id` 列 + 各 content 不重复存 | `pf.clinic_id = 'c_hz_dasha'` |
| 医生 | `tb_encounter.doctor_code` | `doctorId` | `encounter_record.content.doctor_id` | `content->>'doctor_id'` |
| 诊断码 | `tb_dx.dx_code = 'K08.1'` | `diagnoses[i].code = 'K08.1'` | `diagnosis_record.content.code = 'K08'` ⭐ 字典闭集 | `content->>'code' = 'K08'` |
| 诊断牙位 | `tb_dx.tooth_no = '36'` | `diagnoses[i].toothPosition = '36'` | `diagnosis_record.content.tooth_position = '36'` | `content->>'tooth_position' = '36'` |
| 治疗类型 | `tb_tx.tx_type = 'C01'` | `treatments[i].category = 'implant'` ⭐ enum_mapping | `treatment_record.content.category = 'implant'` | `content->>'category' = 'implant'` |
| 治疗状态 | `tb_tx.tx_status = '1'` | `treatments[i].status = 'completed'` ⭐ enum_mapping | `treatment_record.content.status = 'completed'` | `content->>'status' = 'completed'` |
| 病历自由文本 | `tb_emr.treatment_plan = '建议单颗种植 36'` | `treatmentPlan = '建议单颗种植 36'` | `emr_record.content.treatment_plan` | **算法不直接查**,Layer C 抽 → recommendation_record |
| 医生建议(LLM 抽出) | (从 EMR 文本派生) | (Layer C 产物) | `recommendation_record.content.code = 'IMPLANT_RECOMMENDED'` ⭐ PAC 业务码 | `content->>'code' = 'IMPLANT_RECOMMENDED'` |
| 影像 finding | `tb_image.finding = '36 区骨高度 12mm'` | `finding = '36 区骨高度 12mm'` | `image_record.content.finding` | **算法不直接查**,可作为 LLM 抽取输入 |
**关键纪律**:
1. **算法 SQL 永远查 `fact.content`,不查 raw_payload / canonical_payload**
2. **每个字段的"最终命名"由 `fact-content-schemas.ts` 强制约束**,parser 改字段名 = zod 校验挂掉
3. **enum 值必须 ∈ canonical-codes.ts 闭集**(`K08`/`implant`/`IMPLANT_RECOMMENDED`),自由扩展走 PR
---
## 六、实施回顾(W2 末规划 → W3 末落地)
### 6.1 实施清单(W2 末规划项,全部完成 ✅)
| 维度 | W2 末规划 | W3 末实际产出 |
|---|---|---|
| canonical row 留底 | `transaction.canonical_payload` 字段 | ✅ `patient_transactions.canonical_payload` Json? |
| fact.content schema 校验 | FactContentSchemas + zod 前置 | ✅ 17 个 fact_type schema + FactWriter 唯一入口 |
| PAC 字典集中度 | canonical-codes.ts 单一源 | ✅ K00-K14 + 业务码 + 推荐码 + 11 治疗 category(含 review) |
| fact_type 设计 | 拆出 diagnosis/treatment/recommendation 独立 | ✅ encounter 退化只元数据,3 个独立 fact_type 落地 |
| recommendation_record | 新增 | ✅ Layer A.5 route_by_pattern 抽"建议X" → 55 行入库 |
| 算法读 fact | scenario JOIN 独立 fact_type | ✅ 10 子规则 K00–K09 全覆盖 + tooth-overlap union-find 合并 |
| 多品牌 / 多租户隔离 | yaml `brand_to_tenant` 映射 | ✅ 瑞尔 → tenant=ruier;瑞泰另一份 manifest |
| CI 漂移防护 | schema + yaml + canonical-codes 联立测试 | ✅ 53 测试通过(transforms 49 + canonical-fact 19 - 重叠) |
| **Layer A.5 transforms**(W3 新加) | — | ✅ 6 operator 白名单 + 49 单元测试 |
| **review category**(W3 新加) | — | ✅ 第 11 个,2,714 行 fact 入库 |
| **enum_mapping `_default` 兜底**(W3 新加) | — | ✅ AssemblerEngine.applyEnum 支持 |
| **4 入口统一管道**(W3 新加) | — | ✅ dispatcher emitsResolver(§十) |
### 6.2 仍 deferred 项(P2,不阻塞 W4)
| 项 | 状态 | 触发时机 |
|---|---|---|
| raw_payload 子表嵌套(yaml raw_groupArray) | 🟡 不急 | DW 仍单表 dump 就不需要 |
| replay CLI | 🟡 不急 | 真正 yaml 改大版时再做 |
| Layer C LLM 自由文本抽取 | 🔵 W5+ | 接 host EMR 自由文本时启动 |
| Real HTTP Pull(api-key / login) | 🔵 W5+ | 接 5i5ya / Friday 时实装 |
| Push 形态 B(host raw payload) | 🟡 按需 | host 不愿做 canonical 翻译时 |
---
## 七、给宿主的"接入边界"对话
### 7.1 跟宿主沟通的核心
> "**PAC 不要求你改 schema / 改命名 / 改自定义码 / 选 view 还是实表**。
>
> 你怎么存就怎么给我,只要你能告诉我:
> 1. 字段命名是什么(我们做 yaml 翻译)
> 2. 自定义码的含义(我们做 enum 翻译)
> 3. 自由文本的语言风格(我们用 LLM 抽信号)
> 4. 每张表有"更新时间"列(单调递增,PAC 增量同步用,这是 ETL 工程基本要求)
>
> 我们在 PAC 内部统一成集团标准,**你的代码 / DB 一行都不用动**。"
### 7.2 宿主端按形态分类应对
| 宿主形态 | 接入方式 |
|---|---|
| 标准多表 + 结构化码(理想宿主) | yaml field_mapping + enum_mapping,1 d 接入 |
| 自定义码(`诊002`) | 在 enum_mapping 加翻译(`诊002 → K02`),2 d 接入 |
| 一张大宽表 + 分隔符串 | Layer A.5 `derive` 拆 + `split_json_array` 拆行,1-2 d |
| 一张病历表 + 全自由文本 | Layer A.5 `route_by_pattern` 关键词分流 + Layer C LLM 抽取,3-5 d(W5+) |
| **嵌套 JSON 数组字段**(W2 末 DW 实例) | Layer A.5 `split_json_array` 拆行,半天接入(jvs-dw 实测) |
| **ADS 出口层实表**(W2 瑞尔 DW 实际形态) | yaml 对实表写 mapping,性能反而比 view 好。需要确认数据形态(炸行 / 命名 / 子表嵌套) |
| **多 brand / 租户合并在同 host** | brand 映射到 `tenant_id`(详见 §3.5),业务 ID 跨 brand 重复天然不冲突 |
**宿主侧的工作总是"提供数据 + 解释含义",不是"按 PAC 改 schema"**。
### 7.2bis 实战避坑(W2 瑞尔 DW 接入,W3 接入新宿主时复用)
跟宿主对接时**必问**(避免反复返工):
1. **业务 ID 唯一性边界**:`patient_id` / `appointment_id` 等在什么粒度内唯一?(host 内 / brand 内 / clinic 内?)
2. **每张表都有"更新时间"列**吗?ETL 写入时刷新?(PAC 增量同步靠这列)
3. **表是 view 还是物理实表**?ETL 频率(实时 / 小时 / T+1)?
4. **子表是嵌套在主表的 Array 字段,还是 JOIN 炸行**?(炸行就 dedup 兜底 + 反馈 ETL 改)
5. **PII 字段**(姓名 / 手机 / 投诉黑名单)有没有,合规约束(保留期 / 加密)?
6. **数据规模 + 时间跨度**:能跑出多少候选患者?多少历史数据?
7. **每个分类字段有没有稳定的语义枚举码**?(诊断 / 治疗 / 结算类别)
- 数仓常见坑:**只给中文 name(利于显示),没给可计算的语义 key**;即便有"码",也可能是每条记录的随机主键 hash(防重用),不是类别码。
- 影响:PAC 只能退到"中文 name → enum_mapping 白名单"+ W5+ LLM 兜底,白名单需持续维护、永远有长尾。
- 反馈方向:推动宿主在明细层补**稳定语义码**(诊断规范填 ICD;治疗归一到集团项目字典),从源头解决。
8. **写入是 upsert 当前态,还是 append 事件流?状态变更留不留痕?**(W3 末瑞尔 DW 预约表实战)
- 瑞尔 DW 预约表是**混合**:① 同一 `appointment_id` 的状态流转(预约→到诊→结算)是**就地 upsert**——只剩终态,中间态丢失;② **改约**则是**新建一条新 id + 把旧 id 标"已改约"**——改约历史以"已改约"行留痕。
- 影响:`created/arrived/cancelled` 等 in-place 流转,**cold-import 只看终态**(动作流要靠增量 pull 多次比对前后态,或 host push / append 事件日志);改约历史可见但**"已改约"≠"有效预约"**,务必映射成独立状态(rescheduled),否则虚高有效预约(瑞尔已改约占 44.7%)。
- 反馈方向:行为分析(爽约率/改约频率/漏斗)需 **append 事件流**;当前态表只够"看现状"。
9. **时间字段的粒度与拆分**(W3 末瑞尔预约时间实战)
- 坑:约定"日期"与"时分"可能分列存(瑞尔 `appo_time` 只到天 Date,真正时分在 `appo_datetime_str` "13:00:00-14:00:00" + `appo_time_period` 时长);只取日期会让同日多次改约"看着重复"。
- 处理:transforms 拼出精确 Timestamptz(日期 + 起始时分);区分"约定时刻"(scheduled)与"实际发生时刻"(arrived=签到 in_time),两者落 `plannedFor` / `occurredAt`。
- 顺带:`appo_complaint_category`(种植/正畸/…)这类"预约科目"是**就诊意向强信号**,值得标准化进 fact,别只当展示字段丢掉。
10. **"已做治疗"的凭据表与牙位粒度**(W3 末潜在召回 SQL 审计)
- 坑:同一概念(治疗)在 host 有**两个口径**——"治疗计划"(`treat_plan`,含牙位,但≠已做)和"已结算收费"(`fact_settlement_out`,花钱才算做,但**按收费项记账,无牙位列**)。
- 影响:潜在治疗召回的"排除已做治疗"必须信结算(actual),而结算无牙位 → **牙位级排除做不了,只能 patient 级**(同 patient 做过同类治疗即排,会粗化:A 牙做过种植会挡掉 B 牙缺口)。
- 缓解:排除条件加**时间方向**(`treatment.occurred_at >= 诊断时间`),只排"诊断后才启动"的治疗,避免历史旧治疗误排新诊断(实测 K08 误伤 33%)。
- 反馈方向:推动 DW 在结算明细补 `tooth_position`,或用 `treat_plan.planCode` 关联结算行,才能升级到牙位级排除。
11. **时间窗别一杆当两用:入池边界 vs 优先级权重要分开**(W3 末同审计)
- 坑:把"黄金窗"(算优先级用,如缺牙 30-180 天)直接当**入池硬边界**(SQL `BETWEEN`),会把超上界的患者直接踢出池 —— 缺牙拖 1 年比拖 3 个月更该召,却因"超 180 天"漏掉(实测超窗 3 个全 `>360` 天,窗内仅 2 个)。
- 处理:入池**不设硬上界**,黄金窗只喂给打分器算**新鲜度因子**(W7 v3:过窗衰减到 0.4;旧称 timeWindowFactor);超窗患者照常入池、分数低、排后面。下界保留(刚诊断 `<start` 天还在考虑期,不急召)。
12. **fact 标准主体由 PAC 定,别被单一宿主牵着走 + 摄入断链体检**(W3 末审计 emr/影像)
- 原则:`fact.content` schema = PAC 自己定的临床通用语义全集(所有宿主的统一投影目标)。
某宿主映射不上某字段 → 留 null,**绝不因"这个 host 没有 / 拆走了"从标准删字段**。
例:emr_record 必须保留 `diagnosis_text` / `treatment_plan`(小诊所病历常是自由文本),
哪怕瑞尔 DW 把诊断 / 治疗拆成了结构化数组(这俩字段对瑞尔留 null)。
- 判断依据 = **语义普适性(跨宿主通用),不是来源**:host 的设计若普适就大方采纳为标准
(如瑞尔预约 8 态多为通用就诊生命周期 scheduled/arrived/completed/cancelled/no_show/rescheduled,
配得上 PAC 标准);PAC 自拍的若不普适也要砍。来源是 host 还是 PAC 不重要。
- 真正的反模式:为贴合某 host 而**删通用字段**(如删 diagnosis_text)或**把 host 私有字段塞进标准**。
正确是逐项问"是否跨宿主普适" —— 这是 PAC 团队(懂业务 + 临床)的持续判断活,非机械规则。
- **摄入断链体检**(三处任一断 → 主体静默变空壳,无报错):
① SQL 没 SELECT 该列;② yaml `field_mapping` 没映射(parser 读 canonical 名 → undefined → null);
③ assembler 没注册进 `manifest.assemblers`(整个主体不进 dispatcher)。
- 实测踩坑:emr_record 803 条自由文本**全 null**(②yaml 漏映射 + parser 字段名对不上);
image_record **0 条**(①file_url 无 transform + ③无 image.yaml 注册)。修复后 emr 文本全有、影像 756 条。
- 体检方法:逐 fact_type 抽查 `content` 各字段非空率;非空率为 0 的字段顺管道三处查断点。
13. **字段名 + 注释只能参考,真实含义靠数据推**(W3 末 billing_date 实战)
- 坑:host 字段名/comment 经常误导,顾名思义会选错字段。
- 实战:settlement 4 个时间字段 `rq` / `settlement_time` / `billing_date` / `created_date`,
字面看 `billing_date` 像"开单日"(以为偏行政),`rq` 像"日期"(以为主用)。PAC 起初选 rq → 治疗时间 vs 诊断时间口径不一致(rq 是结算操作日,可能延后 1-64 天)。
- 用数据推真实含义:
- billing_date **99.96%** 等于同 encounter 的 emr.rq(接诊日)→ **真实含义 = 治疗发生当天**
- 同 encounter 的多行 settlement 共享同一 billing_date(distinct=1)→ 一次接诊一次开单(治疗事件级)
- rq 跟 billing_date **97% 同天**,但偶有延后 → 是结算操作日
- 结论:PAC 治疗时间应该用 `billing_date`(跟诊断时间同口径 = 接诊日)。
- 推导方法(可复用):① 字段类型 + 取值范围 + null 率;② 同行多字段对比(rq vs billing_date 差异分布);
③ 跨表 join 验证语义(settlement.billing_date vs emr.rq 是否相等);④ 多行同 key 聚合(同 encounter 多行 distinct);
⑤ 拒绝从命名猜结论。
- 文档化:每个时间/枚举字段在 yaml field_mapping 注释处记录"数据推论 + 充实度 + 选择理由",
避免下任接手又被命名误导。
### 7.3 PAC 字典 vs 宿主码的关系
```
宿主码(host-specific,无所谓) PAC 字典(集团统一,严格)
K02.1 ┐ K02
K02.5 ┤── yaml enum_mapping ──→ K02
诊002 ┤ K02
"龋齿" ┘ K02
tx_type=A01 ┐
tx_type="种植" ┤── enum_mapping ──→ implant
IMPLANT-S ┘
```
**宿主可以随意,PAC 字典是闭集**——保证集团范围内**算法 / dashboard / 报表口径完全一致**。
---
## 八、决策清单(W2 末 D1-D13 + W3 末 D14-D19 新增)
| # | 决策 | 状态 |
|---|---|---|
| D1 | `packages/types/src/canonical-codes.ts` 字典文件,集团统一 enum 真理源 | ✅ |
| D2 | `fact-content-schemas.ts` + 写库前 zod 强校验 | ✅ |
| D3 | `patient_transactions.canonical_payload` 字段持久化中间态 | ✅ |
| D4 | 拆分 fact_type:diagnosis_record / treatment_record / recommendation_record 独立 | ✅ |
| D5 | encounter_record 退化为"只装元数据",诊断/治疗不再嵌套 | ✅ |
| D6 | treatment_plan + review_plan 合并进 treatment_record(用 kind=planned + category 区分) | ✅ |
| D7 | scenario / Persona / 前端 / AI prompt 全部只读 fact.content,**绝不读 raw_payload** | ✅ |
| D8 | 宿主侧不要求改 schema / 改命名 / 用 view 还是实表,PAC 内部 yaml + LLM 自己摆平 | ✅ |
| D9 | canonical-codes.ts 新增码必走 PR + 牙医 + 业务方 review | ✅ |
| D10 | **宿主内 brand / 多租户**(如瑞尔/瑞泰)映射到 PAC `tenant_id`,不在 fact 主键拼前缀 | ✅ |
| D11 | raw_payload 改成"主+子嵌套"(yaml 加 raw_groupArray)— 后续 | 🟡 |
| D12 | replay CLI(局部重算) | 🟡 |
| D13 | CI 防漂移测试 | ✅ |
| **D14**(W3) | **加 Layer A.5 transforms 层**:形态改造从 SQL 下放 yaml 6 operator 白名单 | ✅ |
| **D15**(W3) | **canonical-codes 加第 11 category `review`**:承载复查/拆线/暂观/无治疗 | ✅ |
| **D16**(W3) | **enum_mapping `_default` 兜底键**:长尾值统一映射,parser 跳过 | ✅ |
| **D17**(W3) | **SQL 朴素化**:SQL 退化为"等价 csv 单表 dump",形态改造全归 transforms | ✅ |
| **D18**(W3) | **4 入口统一管道**:cold/DW/pull/push 共用 transforms + assembler + parser,dispatcher 接 `emitsResolver`(§十) | ✅ |
| **D19**(W3) | **fact 标准主体由 PAC 自己定,不被任何单一宿主形态裁剪** | ✅ |
---
## 九、W3 末实际产出
### 9.1 实施清单(已交付)
| # | 产出 | 量 |
|---|---|---|
| 1 | canonical-codes 字典(K00-K14 + 业务码 + 推荐码 + 11 治疗 category 含 review) | 30+ codes |
| 2 | fact-content-schemas(17 fact_type × zod 强校验) | 17 schema |
| 3 | FactWriter 唯一入口 + canonical_payload 双留底 | 1 入口 |
| 4 | fact 拆分:encounter / diagnosis / treatment / recommendation 独立 | 4 新 fact_type |
| 5 | scenario 10 子规则 K00–K09 跑通独立 fact_type | 10 子规则 |
| 6 | brand → tenant_id 多品牌隔离(瑞尔/瑞泰) | 2 brand 实测 |
| 7 | **Layer A.5 transforms 6 operator + 49 单元测试** | 6 operator |
| 8 | **enum_mapping `_default` 兜底键** | AssemblerEngine 改 |
| 9 | **4 入口统一管道**(dispatcher emitsResolver) | §十 |
| 10 | CI 防漂移测试 | 53 个测试通过 |
| 11 | DW 真实 cohort 端到端跑通 | 1000 患者 / 63,781 txn / 0 failed |
| 12 | 卜晓平 plan 87 分(真实数据 K08 缺牙 151 天) | 召回率 3.2% |
### 9.2 跑通的 4 入口
| 入口 | 状态 | 验证方式 |
|---|---|---|
| Cold-import(SQL/CSV) | ✅ jvs-dw 跑通 0 failed | 真 DW + 1000 cohort |
| DW 直连增量 | ✅ 统一 pnpm sync(存量增量同一套 importDirectory) | cohort 分批 + 并发 + bulk write + run_start cursor |
| Mock Pull | ✅ 跑通 + 自带 emitsPerResource | adapter |
| Real HTTP Pull | 🔵 W5+ | 接 5i5ya 时实装 |
| Push 形态 A | ✅ event 自带 emits | dispatchTables resolver |
| Push 形态 B | 🟡 按需 | host 需求触发 |
---
## 十、4 入口统一管道(W3 末新加,对齐 potential-treatment-recall-flow.md)
PAC 的 ingestion 是**一条管道,4 个入口接不同 stage,后续完全共用**:
```
Stage 0 数据获取(产 raw rows)
Stage 1 Layer A.5 transforms(yaml 6 operator,可选)
Stage 2 Layer A AssemblerEngine(yaml field/enum/emits)
Stage 3 TransactionSynthesizer(写 transaction + emits 三件套)
Stage 4 ParserPipeline(per fact_type zod + FactWriter)
```
### 10.1 4 入口切入点对照
| 入口 | Stage 0 | Stage 1 | Stage 2 | Stage 3 emits | Stage 4 |
|---|---|---|---|---|---|
| **Cold-import** | 文件/SQL 朴素 SELECT | ✅ 跑 | ✅ 跑 | yaml | ✅ 共用 |
| **DW 直连增量** | SQL + cursor 水位 | ✅ 跑 | ✅ 跑 | yaml | ✅ 共用 |
| **Pull HTTP** | host endpoints GET | ✅ 跑 | ✅ 跑 | yaml | ✅ 共用 |
| **Push 形态 A** | host 直推 event(canonical payload) | ❌ 跳过 | ❌ 跳过 | **event 自带 action/subjectType** | ✅ 共用 |
| **Push 形态 B** | host 直推 event(raw payload) | ❌ 跳过 | ✅ 跑(per-host push assembler) | event 自带 | ✅ 共用 |
### 10.2 实现纪律
- `PipelineDispatcher.dispatchTables` 加 **`emitsResolver: (resource, row) => EmitsConfig | undefined`** — caller 注入 emits 元数据
- yaml 路径:resolver 按 resource 名查 yaml 注册表(同 resource 内所有行共用 emits)
- push 路径:resolver per-row 读 `row._action` / `row._subjectType`(同 batch 不同 action OK)
- patient 主档:resolver 返回 undefined → 走 upsertPatient 跳过 transaction
- `RawPullPage` 加 `emitsPerResource?: Record<string, EmitsConfig>` — adapter 直接返回 canonical 时填(mock pull / shortcut adapter)
### 10.3 数据源真等价(yaml 单方案保证)
SQL 退化为"等价 csv 单表 dump 的 SELECT":
- ✅ 允许:`SELECT col1, col2 FROM single_table WHERE simple_filter`
- ❌ 禁止:ARRAY JOIN / JSONExtract / multiIf / substring / 跨表 JOIN
切 csv 模式:host 用同 WHERE 跑 SQL 把 csv dump 给 PAC → manifest 改 `csv:` 替代 `sql:` → transforms / assemblers / parsers **一行不动**。
详见 [potential-treatment-recall-flow](/docs/algorithms/scenarios) 的"4 入口统一管道"。
---
## 一句话归纳
> **PAC 的核心资产是 `fact.content` 一个对象**:经过 4 层规范化(形态 / 翻译 / 语义 / 文本)、由 zod schema 强约束、引用 PAC canonical-codes 字典闭集、**集团内全宿主统一**。**4 入口接不同 stage,Stage 3-4 后所有路径汇合**。
>
> 算法 / Persona / 前端 / AI 永远只读这一个对象,**不读 raw,不读 canonical 中间态,不读 host 原表**。任何破坏这条原则的代码,review 不过。
---
## 附录 A · W2 末扒 PAC mock 链路的 7 个发现(P011 演示样本)
(详见 W2 末 review 讨论;沉淀在这里供后续追溯。P011 是 PAC 内部 mock 数据,不是宿主真实数据。)
1. `raw_payload` 只存主表,子表(诊断 / 治疗)数据丢失 → 审计 / replay 困难
2. 字段名 3 套(host / canonical / content),3 处翻译规则散落
3. `encounter_record` 内嵌 `diagnoses[]` / `treatments[]` / `next_steps[]` 违背独立语义
4. `emr_record` / `image_record` fact 已落库但 scenario 完全不查 → P011 这种"EMR 显式建议种植"信号 100% 浪费
5. `next_steps[]` 派生只从 `treatments[]` → P011 没治疗就没 `next_steps`,临床意图丢失
6. canonical row 不留底 → 算错无法回放看中间产物
7. content 无 zod 校验 → parser 字段名漂移会静默失效
## 附录 B · W2 末从瑞尔 DW 实战学到的 4 件事
(对应 §3.5,这里把对话/查询过程的关键发现补全)
1. **DW 给的是 ADS 出口层实表,不是 view**(`dw_group.fact_*_out` 命名,带 _out 后缀)
- DW 内部有更细的 DWD 明细层,但当前对 PAC 隔离没暴露
- PAC 这边能消化(yaml 直接对实表写 mapping),性能反而比 view 好
2. **业务 ID 跨 brand 重复**(详见 §3.5)— `patient_id` / `appointment_id` / `registration_id` 全部跨 brand 重复
- 必须用 `(business_id, brand)` 复合键 JOIN
- PAC 把 brand 映射到 `tenant_id`,后续算法零感知
3. **聚合表炸行风险**:`fact_emr_treatment_out` 因为 JOIN 影像子表没 groupArray,**5 倍行数膨胀**(193 万行 → 39 万真实 EMR)
- 已反馈 DW 修复
- PAC 这边 yaml 加 `dedup_by: id` 兜底
4. **数据齐全度超预期**:DW 后来补的 `fact_emr_treatment_out` 把诊断 / 治疗计划 / 影像 metadata / 自由文本主诉**全部合并到一张表**
- 诊断含牙位(`diag[i].toothPosition`,FDI 标准)
- 治疗计划含标准码(`treat_plan[i].planCode`)
- 影像引用(`file_url` JSON Array,含拍摄时间 + 类型)
- EMR 自由文本(`illness_desc` / `pre_illness` / `doc_order`)
- PAC fact 重塑(§四)的所有 fact_type 都有数据源
---
## 关联文档
| 文档 | 关系 |
|---|---|
| [召回场景](/docs/algorithms/scenarios) | 召回算法侧设计(基于本文 fact 规范化层) |
| [three-layer-model](/docs/architecture/three-layer-model) | 三层模型大局,fact 层是本文聚焦 |
| [data-model](/docs/architecture/data-model) | DB schema 真理源,本文带来的 fact_type 变化要回填进去 |
| [host-integration](/docs/integration/overview) | 宿主接入手册,本文 §七"接入边界对话"补充 |
| [channel-clickhouse](/docs/integration/channel-clickhouse) | DW 直连方案,跟本文 Layer A AssemblerEngine 是上下游 |
---
title: DW 数据源问题清单
description: PAC 审计瑞尔 DW 五张事实表后,给 DW 团队反馈的待改进问题清单。
icon: Database
---
> **背景**:PAC 系统审计 5 张 CH 表(fact_client_out / fact_appointment_out / fact_emr_treatment_out / fact_settlement_out / fact_settlement_mode_out)与 PAC 标准主体的映射,发现以下需 DW 改进的问题。
> **目的**:不是 PAC 单方面诉求,而是数据源质量提升 —— 双方达成共识后由 DW 团队评估实施排期。
---
## 🔴 P0 — 直接影响业务
### 1. fact_client_out 没 phone
- **现状**:无 phone / mobile / tel 字段
- **影响**:PAC 召回硬过滤要求 `phone IS NOT NULL`,DW 不给则当前用假手机号兜底(无法真实触达)
- **诉求**:补 phone(合规允许范围内)
### 2. 字典完整性问题(根本性)
覆盖多种表现:
- **id 缺人类可读 name**:doctor_id / user_id 等数字 id 各事件表不带 name 快照(只 emr 表 doctor_name 有)
- **value 没 key**:settlement_type / treatName / check_name / card_type_name 全是中文名,无稳定语义码,PAC 只能维护中文白名单(98% 命中 + 长尾兜底)
- **枚举值无语义文档**:status (1/2/3) / is_first (0/1/2) / recomend_type / appo_status 各码含义无文档
- **看似有 code 实际不可用**:treat_plan.planCode 是 hash(每条独立,非类别码)
**诉求**:
1. 所有 id 字段在事件表带 name 快照(doctor_name 进 settlement/appointment 表)
2. 类别字段补稳定语义码(诊断填 ICD;治疗归集团项目字典;card 拆 VIP/医保/活动/团购 独立字段)
3. 枚举值含义文档化(status 1=? 2=? 3=?)
### 3. fact_client_out 是 ADS 聚合层(应为 DWD)
| 表 | 实际层级 | 评价 |
|---|---|---|
| fact_client_out(12.9万=患者数)| **ADS 聚合** ❌ | is_dental_loss / first_visit_time / customer_level / favor_doctor 一堆衍生 |
| fact_appointment_out / fact_emr_treatment_out / fact_settlement_out / fact_settlement_mode_out | **DWD 明细** ✅ | 一事件一行 |
- **影响**:PAC 不敢用聚合衍生字段(只取 name/gender/birthday/file_num 原子字段)
- **诉求**:fact_client_out 提供 DWD 版(只给身份原子字段,衍生聚合 PAC 自己算)
### 4. 写入方式:appointment 是混合 upsert + 改约新建,其他未明
- **appointment**:状态流转(scheduled→arrived→completed)是 in-place upsert(只看终态);改约 = 新建 + 老的标"已改约"(部分留痕)。**已改约占 44.7%** 已映射 rescheduled
- **emr / settlement / settlement_mode**:写入方式未明确(append 还是 upsert?)
- **影响**:cold-import 只能看到终态,无法监控 appointment_created/changed/cancelled/fulfilled 等动作事件;爽约率/改约频率/漏斗分析需事件流
- **诉求**:除患者外用 **append 事件流**(状态变更新增行而不是改原行),或提供独立"状态变更日志表"
### 15. ⏰ 时间字段类型违约:`in_time` / `billing_date` 是 `DateTime`(UTC),其余是北京裸串
- **契约**:该数仓所有时间 = **北京时间(naive 字符串)**。PAC 据此一律按北京解析,存 UTC(PAC 内部恒 UTC)。
- **违约**:`fact_appointment_out.in_time``fact_settlement_out.billing_date` 是 ClickHouse **`DateTime` 类型**(内部 UTC 瞬时),其余时间列(created_date/updated_date/appo_datetime_str/rq…)都是 `String`(北京裸串)。
- **后果**:CH 把 DateTime 序列化成裸串(如 `in_time` UTC `2025-09-26 03:09:05` = 北京 11:09,无 `Z` 标记),PAC 把它当北京裸串又 −8,存成 UTC `2025-09-25 19:09`(**比真实慢 8 小时**)。
- 实例:患者王振中,真实到诊北京 11:09(= created_date 11:08 吻合),encounter occurred_at 被弄成 UTC 19:09(本地 03:09),与同次 treatment(北京 11:10)跨了 UTC 自然日,导致 **就诊次数虚增**(2 次,实 1 次),并殃及 lifecycle 分期 / RFM 频次。
- **PAC 立场**:不为错误数据在算法层做时区兼容(本末倒置)。PAC 内部恒 UTC、按契约(北京 naive)解析,这是对的。
- **诉求**:DW 把 `in_time` / `billing_date` 改成与其余列一致的**北京 naive 字符串**(或全库统一带 `Z`/offset 的 tz-aware,二选一,别混)。在宿主修正前,这两列派生的时间(尤其 encounter.occurred_at)系统性慢 8h。
---
## 🟡 P1 — 数据质量
### 5. settlement 在"按牙类"治疗项目上缺 tooth_position
- **现状**:settlement_out 无任何 tooth 字段(完整字段扫描确认);按牙治疗(种植/拔牙/根管/充填)的项目本应带牙位,实际只在 EMR 自由文本(emr.dispose 39% / treat_plan 46% 带 toothPosition)
- **临床合理**:洁牙/X光检查/全身检查等**全口/检查类项目本就无牙位**,不强求
- **影响**:PAC 牙位级排除做不了 —— 36 牙做过种植会挡掉 21 牙缺口召回(只能 patient 级)
- **诉求**(收窄):**只对"按牙类"治疗项目** 在结算明细补 tooth_position(种植 36/拔牙 48 这种);全口类继续无牙位 OK
- **关联键不强求**(临床多对多本质,host 端也做不到 1:1 link;诊断/治疗/结算之间只能"同 encounter + 同 tooth + 名字模糊匹配")
### 6. fact_emr_treatment_out 子表炸行
- **现状**:file_url 子表 JOIN 无 groupArray,**193万 → 39.5万真实 EMR**(5 倍膨胀)
- **PAC 侧**:已用 dedup_by 兜底
- **诉求**:DW 修(groupArray)
### 7. plan 字段 vs treat_plan 语义未文档化
- **现状**:fact_emr_treatment_out 两个治疗计划字段,**65% 完全不同**(395656 行只 4 行相同)
- **影响**:PAC 暂不接 plan(避免重复/过时数据)
- **诉求**:DW 说明两字段语义区别
### 8. settlement 4 个时间字段语义需文档化(数据推论)
| 字段 | 实测语义 | PAC 选用 |
|---|---|---|
| `billing_date` | **99.96% = emr.rq(接诊日)= 治疗发生当天** | ⭐ PAC treatment.occurredAt / payment.paidAt 用这个 |
| `rq` / `settlement_time` | 结算操作日(97% 与 billing_date 同天,偶有延后 1-64 天) | 不用 |
| `created_date` | 记录创建时间 | 不用(元数据) |
- **教训**:字段名"billing_date"字面像"开单日"以为偏行政,实际是治疗发生时的开单 = 治疗日。PAC 起初误选 rq,导致治疗时间 vs 诊断时间口径不一致。
- **诉求**:DW 文档化四个时间字段定义,推荐"治疗实际时间"用 billing_date
---
## 🟢 P2 — 业务覆盖缺口
### 9. 缺投诉 / 转介绍事实表
- **现状**:DW 没 complaint 表;转介绍只有 client_out.recommend_*(聚合,17% 充实)
- **影响**:PAC complaint_record / referral_record fact_type 已定但数据源缺
- **诉求**:DW 提供这两张明细表
### 10. 接诊结束时间缺失
- **现状**:in_time 有(接诊开始),无 out_time(结束)
- **影响**:无法算接诊时长、医生效率
### 11. 多 brand ID 重复(已知坑)
- patient_id / appointment_id 等跨 brand 重复
- PAC 用 brand → tenant_id 解决,接入新 brand 复用此模式
---
## 工程契约
### 12. updated_at 字段需保证(PAC 增量同步 cursor 基础)
- 每张表需有 updated_at 列单调递增刷新(每次写入/修改都更新)
- 这是 PAC 增量 pull 的 cursor 基础
- **诉求**:确认 DW 各表都有此列且 ETL 写入时刷新
### 13. ETL 频率需说明
- 表是 view 还是物理实表?ETL 频率(实时/小时/T+1)?
- 影响 PAC 增量 pull 节奏 + 数据新鲜度
- **诉求**:DW 说明各表 ETL 时延
---
## 沟通方法论(本次审计的收获)
1. **字段名 + 注释只能参考,真实含义靠数据推**(实战:billing_date 字面像"开单日",数据推实际是"治疗发生日")
2. **临床多对多是本质**:诊断/治疗/结算 1:1 强关联是不切实际的诉求(host 也做不到),PAC 接受隐式关联(同 encounter + 同 tooth + 名字模糊)
3. **不要让 host 形态裁剪 PAC 标准**:PAC fact 标准应基于临床通用语义,某 host 没的字段留 null,不删
4. **衍生 vs 事实**:host 给的聚合(is_dental_loss / customer_level / first_visit_time)PAC 不接,PAC 自己从事实算更可信(单一真理源)
### 14. 生产 host 数据混入测试账户
- **现状**:`fact_client_out``client_name='束乾凤test'` 测试账户,跨 18 个月(2024-09→2026-04)
累积 1531 条 `fact_settlement_out` 退费记录(净退费 ¥489.66)
- **影响**:PAC 100-cohort 把它纳入,refund_record 99% 集中在 1 个测试账户,真实业务退费观察被掩盖
- **诉求**:DW 侧标记 / 过滤测试账户(如加 `is_test` 字段或专用 organization_id 隔离),否则
PAC 这边只能"按 client_name LIKE '%test%'" 拍黑名单,跨 host 不可移植
---
title: 实时坐席辅助
description: 客服回访通话时 AI 实时旁听并在屏幕上推下一步话术的设计方案。
icon: Radio
---
import { Callout } from 'fumadocs-ui/components/callout';
<Callout title="设计中">
本页是设计草案(讨论用,未排期),描述 PAC 未来规划的实时坐席辅助能力。内容反映当前设计意图,不代表已落地实现。
</Callout>
> 客服打电话回访患者时,AI 实时旁听通话,在 PAC 屏幕上推"下一步该说什么 / 别忘了提的点 / 异议怎么接",
> 是 Cresta Agent Assist 那类"实时坐席辅助"在 PAC 场景的落地。
---
## 一、目标与边界
**做什么(v1)**:
- 客服与患者通话时,AI 静默旁听,**只在屏幕给文字提示**(不出声、不替客服说话)。
- 提示结合 **该患者的 PAC 上下文**(画像 / 治疗链 / 已生成话术 / 召回理由),这是 PAC 区别于通用 Agent Assist 的核心优势。
- 通话结束:转写 → 自动起草 `plan_execution`(执行结果 / 备注 / 下次跟进),省客服手填。
**明确不做(v1)**:
- ❌ AI 直接和患者对话 / 替客服打电话(那是"AI 外呼",Phase 3 另议,用豆包/Qwen-Omni 端到端语音)。
- ❌ 通话质检 / 情绪分析大盘(Phase 2+)。
- ❌ 多语种(先普通话 + 粤语)。
---
## 二、架构选型:级联(ASR→DeepSeek)优先,实时端到端备选
两条路,v1 选**级联**:
| 维度 | **A. 级联:国内流式 ASR → DeepSeek 教练**(v1 选) | B. 实时端到端:Qwen-Omni-Realtime(文字输出) |
|---|---|---|
| 延迟 | ASR ~0.3s + LLM 首 token ~0.5-1s ≈ **1-2s** | 模型侧 ~234ms,但需全程国内部署 |
| 可控性 | ⭐⭐⭐ 文字转写 → 注入完整 PAC 上下文 → 结构化提示 | ⭐⭐ 上下文塞 systemInstruction,输出结构弱 |
| 复用 PAC | ⭐⭐⭐ 直接走现有 AiCall harness + skills + agent_invocations | 需新链路 |
| 中文电话准确率 | ⭐⭐⭐ 阿里/腾讯/讯飞专用 8k 电话 ASR | ⭐⭐ |
| 成本 | 低(ASR 按分钟 + LLM 小 token) | 高(音频按分钟) |
| 风险 | 低(都是成熟件) | 中(较新) |
**结论**:v1 走 **级联** —— 最可控、复用 PAC 最多、中文最稳、最便宜。Qwen-Omni-Realtime 留作 Phase 2 对照(各跑真实通话 benchmark 再定)。豆包定位"AI 开口跟患者聊",归 Phase 3 外呼。
---
## 三、端到端数据流(级联)
```
客服浏览器(PAC 详情页)
│ ① getUserMedia 抓麦克风(+ 患者远端流,见 §4)→ PCM 16k 帧
▼ WebSocket(/pac/v1/realtime/coach,JWT 鉴权)
┌─────────────────────────── 实时教练服务(国内区,见 §7 部署)───────────────────────────┐
│ ② 音频帧 → 国内流式 ASR(WebSocket)→ 滚动转写(分客服/患者两轨) │
│ ③ 触发器:患者说完一句(VAD 静音)/ 每 ~6s 新内容 → 攒一批 │
│ ④ realtime_coach AiCall(走 AiCall harness): │
│ system = PAC 上下文(画像 + 治疗链 + 已生成话术 + 召回理由 + skills) │
│ user = 最近滚动转写 │
│ → DeepSeek 流式出【结构化提示】→ 落 agent_invocations │
│ ⑤ 提示经 WS 推回浏览器 │
└──────────────────────────────────────────────────────────────────────────────────────┘
│ ⑥ PAC 详情页"实时教练"面板:按 urgency 染色滚动显示
通话结束 → ⑦ 整段转写 → summarize AiCall → 自动起草 plan_execution(outcome/notes/next)
```
---
## 四、音频接入 —— 决定性问题(必须先定)
**浏览器抓麦克风零 telephony 集成**,但"听谁"取决于电话怎么打:
| 客服打电话方式 | 能拿到的音频 | 评价 |
|---|---|---|
| **电脑网页软电话(WebRTC 拨号)** | 客服 + 患者**双轨,原生干净** | ⭐⭐⭐ 最佳。需接语音 CPaaS(阿里云语音/腾讯云)做 WebRTC↔PSTN 桥 |
| 座机 + 耳麦 | **只有客服**(患者声进耳机不进麦) | ⭐ 患者半段丢失,价值大减 |
| 座机 + 免提外放 | 麦克风录双方(有回声/质量差) | ⭐⭐ 凑合 |
| 电脑 + `getDisplayMedia` 抓系统声 | 患者半段(系统声)+ 客服(麦) | ⭐⭐ 要手动授权共享音频,糙 |
**强烈建议走软电话**(电话从 PAC/电脑里打),双轨干净是实时教练质量的前提。
**待确认:你们客服现在电话从哪打?有没有可对接的呼叫中心/CPaaS?** 这条不定,实时路无从落地。
---
## 五、教练 LLM 设计(PAC 的护城河在这)
**开场注入上下文**(通话开始拉一次,塞进 system):
- persona features(价值 / 召回风险 / 沟通偏好)
- 治疗链(该患者诊断了什么、链卡在哪一阶段)
- 已生成的 plan_script(开场/跟进/异议/结束四段)
- plan_reasons(召回理由 + 子场景 + 证据)
- 复用现有 **draft-plan-script 的 skills 体系**(K00-K09 诊断 SKILL + 人群 + 异议库 + 安全规则)
**输出结构(流式)**:
```jsonc
{
"hints": [
{ "type": "say_next", // 建议下一句说什么
"text": "提一下种植 3 个月骨结合期,先约个免费 CT 评估",
"urgency": "high" },
{ "type": "objection_rebuttal", // 患者刚说"太贵" → 接法
"text": "强调分期 + 缺牙不补的邻牙倾斜风险" },
{ "type": "remember", // 别忘了
"text": "该患者是 VIP(累计 5.7w),态度要更尊重" },
{ "type": "compliance", // 合规红线
"text": "未确诊别承诺疗效" }
],
"detected": { "patientConcern": "价格", "stage": "犹豫" }
}
```
**触发节奏**(控成本 + 防闪烁):患者 turn-end(ASR 静音事件)或每 ~6s 新转写,debounce,无新内容不调。
**走 AiCall harness**:`kind:'realtime_coach'`、新 callKey、流式、落 `agent_invocations`(token/cost/审计),safety gate 复用 `playbook-safety-self-check`
---
## 六、需要新增的部件
**后端(实时教练服务)**:
- `RealtimeCoachModule` + WebSocket Gateway `/pac/v1/realtime/coach`(JWT)
- 会话生命周期:start(拉 PAC 上下文)→ 流式音频 → ASR → 攒转写 → 触发 coach AiCall → 推提示 → end
- **ASR 适配层**(服务端代理音频到阿里/腾讯 ASR,密钥不下发浏览器)
- `RealtimeCoachCall`(AiCall):复用 DeepSeek + skills composer
- 数据落库:`call_sessions`(会话)+ `call_transcripts`(转写,PII)+ `call_suggestions`(提示,审计/复盘)
**前端**:
- 麦克风采集(getUserMedia → PCM 帧)+ WS 客户端
- 详情页"实时教练"面板(话术区旁):流式提示、urgency 染色、一键"已说"
- 通话控制(开始/结束辅助)
**数据模型**(新表,延续 PAC 版本流 + 审计账本风格):
```
call_sessions: id, plan_id, patient_id, operator_user_id, started_at, ended_at, status
call_transcripts: id, session_id, speaker(agent/patient), text, ts, asr_confidence (PII,留存策略管控)
call_suggestions: id, session_id, agent_invocation_id, hints(jsonb), shown_at (审计/效果复盘)
通话结束 → 复用 plan_executions(transcript 起草 outcome/notes)
```
---
## 七、部署:实时链路必须在国内区(关键!)
⚠️ **现 PAC 服务器在美国(加州)** —— 但客服和患者在国内。实时音频若走 客服(国内)→ 美国 PAC → 国内 ASR,跨太平洋来回延迟会毁掉体验。
**所以实时教练服务应独立部署在国内区**(阿里云/腾讯云华东),就近接 ASR + DeepSeek:
```
客服(国内)──低延迟──▶ 实时教练服务(国内区)──▶ 国内 ASR + DeepSeek
└──拉患者上下文──▶ PAC API(美国,非实时,可缓存)
```
PAC 主体(美国)只在**开场**提供一次患者上下文(可缓存),不在实时音频回路上。这是个**边缘/相邻服务**,通过 PAC API 取数,不耦合主库。
---
## 八、延迟 / 成本 / 合规
- **延迟预算**(级联,国内):患者说完到提示出现 ~**1-2s**。教练提示场景可接受(不是抢话)。
- **成本**(粗估):ASR ~¥0.4-0.6/分钟 + DeepSeek 提示(每分钟几次小调用)~¥0.05/分钟 → 10 分钟通话 ~¥5-7。需实测核。
- **合规(重)**:录患者通话需**告知同意**;转写是高敏 PII,需加密 + 访问控制 + 留存清理(对齐 `agent_invocations.input_snapshot` 的 N 天清理);需集团法务过一遍。
---
## 九、分期路线
| 阶段 | 内容 | 价值 | 风险 |
|---|---|---|---|
| **Phase 0 · POC**(数天) | 麦克风 → 国内 ASR → DeepSeek 教练 → 简单面板,1 个诊所,验延迟/质量 | 验证可行性 | 低 |
| **Phase 1 · 通话后异步** | 录音 → ASR → summarize → **自动填 plan_execution** | 立竿见影 + 补 PAC"手填 outcome"缺口 + 跑通 ASR 管道 | 低(无实时压力) |
| **Phase 2 · 实时教练** | 流式 ASR + 上下文注入 + 实时面板 + harness + 落库 | 核心功能 | 中 |
| **Phase 3 · AI 外呼(可选)** | Qwen-Omni-Realtime / 豆包端到端语音,AI 直接跟患者讲 | 全自动召回 | 高 |
**建议先做 Phase 1**:不依赖软电话/实时,只要能拿到通话录音文件就能做,价值直接(自动填执行结果),还顺手把 ASR 选型/管道验了,为 Phase 2 铺路。
---
## 十、待确认(决定能不能往下走)
1. **客服电话从哪打?**(座机 / 电脑软电话 / 微信语音)→ 决定能否拿干净双轨音频 —— **最关键**
2. 有无可对接的呼叫中心 / 语音 CPaaS?(决定软电话路 + 录音获取)
3. 录音合规:患者告知同意流程、留存策略,集团法务口径。
4. ASR 选型:阿里 fun-asr / 腾讯 / 讯飞,拿真实诊所通话各测一轮中文准确率。
5. 是否接受新增国内区边缘服务(实时链路不能走美国主机)。
---
## ▎一句话归纳
> **级联(国内流式 ASR → DeepSeek 教练,走 PAC AiCall harness)+ 注入该患者画像/治疗链/话术作上下文 + 实时面板出结构化提示**;音频靠浏览器麦克风(最好电话从电脑软电话打拿双轨);实时链路独立部署国内区,PAC 主体只供一次上下文。**先做 Phase 1(通话后自动填执行结果)** —— 零实时依赖、价值直接、顺带验 ASR 管道。最大未决项是"客服电话从哪打"。
# ADR-0001 · 集团租户模型重构
> 品牌从 tenant 槽搬到 source_unit;orgScope 作为数据权限唯一入口
| | |
|---|---|
| **状态** | Accepted |
| **日期** | 2026-06 |
| **关联** | 登录授权(`orgScope` / `tenant` 的对外契约):`apps/pac-docs/content/docs/integration/auth-login.mdx` |
## 背景(Context)
早期 `tenant_id` 装的是**品牌**(同一集团下的多个品牌),数据权限则让 host 直接传
`source_units` + `clinic_ids`。两个问题:
1. 同一**集团**下的多品牌被切成互不相通的租户,集团级视角无处安放。
2. `source_units` / `clinic_ids` 是 PAC 的**内部过滤维**,却泄漏进了对外契约 ——
host 得理解 PAC 的 schema 词汇才能接入,违反第一性。
## 决策(Decision)
- **`tenant_id` = 集团**(稳定 slug,如 `<group>-grp`),进每张表唯一键 + 每条查询 where。
- **品牌 = `source_unit`** —— 事实的发行命名空间,留在唯一键里,不再当租户。
- **数据权限唯一入口 = `org_scope`** —— host 只传「用户挂在 org 树的哪些节点」(任意层级:
集团 / 区域 / 品牌 / 诊所)。PAC 按该 host 的 org 树 `expandOrgScope` 展开成内部两维
过滤(`source_units` + `clinic_ids`)。host **不再传**内部过滤维。
- org 树**从摄入数据派生**,零硬编码 id,跨环境自适应。
- 展开**fail-closed**:品牌型租户遇到树外 ref → 落 `DENY_SOURCE_UNIT` 哨兵,
绝不退化成「不限品牌」(防跨品牌泄漏,因同集团多品牌共享 tenantId)。
## 后果(Consequences)
- ✅ 集团级视角天然成立;跨品牌隔离由唯一键 + where 硬保证。
- ✅ 契约只暴露 host 唯一知道的东西,接新 host = 写一层薄适配器。
- ✅ JWT 只装通用 `orgScope`,解码出来与契约一致,不泄漏内部 schema。
- ⚠️ 每请求需展开 orgScope(`tenant-scope` 拦截器 / MCP 鉴权),有缓存(10min TTL)。
- ⚠️ 旧数据需清空重摄迁移(已完成:全量患者迁入新模型并验证隔离)。
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
"clean": "turbo run clean && rm -rf node_modules", "clean": "turbo run clean && rm -rf node_modules",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"docs:openapi": "pnpm --filter @pac/service openapi:dump",
"db:generate": "pnpm --filter @pac/service prisma:generate", "db:generate": "pnpm --filter @pac/service prisma:generate",
"db:migrate": "pnpm --filter @pac/service prisma:migrate", "db:migrate": "pnpm --filter @pac/service prisma:migrate",
"db:studio": "pnpm --filter @pac/service prisma:studio", "db:studio": "pnpm --filter @pac/service prisma:studio",
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
* **维护流程**:新增码走 PR,至少 1 牙医 + 1 业务方 + 1 PAC 工程 review。 * **维护流程**:新增码走 PR,至少 1 牙医 + 1 业务方 + 1 PAC 工程 review。
* 必填:含义 / 临床触发场景 / 算法用法 / 中文名。 * 必填:含义 / 临床触发场景 / 算法用法 / 中文名。
* *
* 详见 `docs/algorithm/canonical-fact-layer.md` §三。 * 详见 `apps/pac-docs/content/docs/architecture/data-ingestion.mdx` §三。
*/ */
import { z } from 'zod'; import { z } from 'zod';
......
...@@ -585,7 +585,9 @@ export const CanonicalResourceMeta: Record< ...@@ -585,7 +585,9 @@ export const CanonicalResourceMeta: Record<
booleanFields: [], booleanFields: [],
}, },
payment: { payment: {
moneyFields: ['amount'], // 折扣三件套(诊所可控折扣额)跟 amount 同口径按 amountUnit 归一成分,
// 避免 parser 自己 ×100 硬编码 yuan(fen 宿主会双转)。passthrough 字段,normalizer 按 `k in out` 命中才转。
moneyFields: ['amount', 'discountDept', 'discountCompany', 'discountCard'],
moneyArrayFields: [], moneyArrayFields: [],
datetimeFields: ['paidAt'], datetimeFields: ['paidAt'],
booleanFields: [], booleanFields: [],
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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