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: 把全历史事实压缩成 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: 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": "架构",
"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: 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: 响应信封与错误码
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: 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: 发给宿主开发的存量历史数据导出工作单,包含数据类别、上下文配置与数据字典要求。
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": "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)
#
# W4 末重大语义修正(详见 docs/dw-data-source-issues.md):
# W4 末重大语义修正(详见 apps/pac-docs/content/docs/operations/dw-data-issues.mdx):
# - 原方案:从 fact_settlement_out 反推已做治疗(无牙位、颗粒度差、跟收费 1:N)
# - 新方案:从 EMR.treat_plan 字段(真实临床记录,48.7% 带牙位)取本次治疗
# - settlement 不再消费为 treatment_actual,只走 payment.yaml(LTV)+ refund.yaml(风险)
......
......@@ -11,7 +11,7 @@
# 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 @@
"ai:gen-script:prod": "node dist/cli/ai-gen-script.cli.js",
"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: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": {
"seed": "ts-node --transpile-only prisma/seed.ts"
......
......@@ -459,7 +459,9 @@ model PatientTransaction {
/// 用途: 审计 fact 算错时回查中间态; replay yaml 改后局部重跑 parser 不必从 raw 重头算
/// nullable 兼容老数据(初期未实施 canonical 留底);新写入必须填
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")
/// 宿主侧实际发生时间(adapter platform.pullConfig.timezone 解析无时区字符串后转 UTC)
......@@ -529,7 +531,7 @@ model PatientFact {
subjectId String @map("subject_id")
/// `actual`(已发生事实) / `planned`(未来安排)
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
......
......@@ -3,32 +3,32 @@
--
-- 用途:独立于 scenario 代码,用 plan_reasons.signals 反查 patient_facts 交叉验证
-- "该召没召 / 不该召却召"。每次摄入/改算法后跑,看 FP/FN 体量。
-- 独立性:下方 vt_codes 重述一份 code→{resolver/cooldown/⑤d科目/全口/乳牙},
-- 独立性:下方 vt_codes 重述一份 code→{resolver/cooldown/全口/乳牙},
-- 跟 scenario 代码各写一份 → 不一致即暴露(差分)。
-- 性能:把 治疗/召回/诊断/预约 牙位各 unnest 一次进【索引临时表】+ ANALYZE,
-- 性能:把 治疗/召回/诊断 牙位各 unnest 一次进【索引临时表】+ ANALYZE,
-- 再用索引查找做 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-1 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 正畸语境 FP
-- 注:§C/§D 的"残留"需再排 cooldown / 已指派锁 / ⑤d 才是真问题(见 docs 讨论)。
-- 注:§C/§D 的"残留"需再排 cooldown / 已指派锁 才是真问题(见 docs 讨论)。
-- ============================================================
\pset pager off
\set ON_ERROR_STOP on
SET work_mem = '256MB';
-- 牙位级 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
('K08','missing_tooth',30, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['种植','修复'], true),
('K02','caries_no_filling',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY[]::text[], false),
('K03','hard_tissue_damage',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['修复','拔牙'], false),
('K04','endo_no_rct',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY[]::text[], false),
('K01','impacted_tooth',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['拔牙'], false),
('K09','jaw_cyst',14, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric'], ARRAY['拔牙'], false),
('K00','development_eruption',30, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric','orthodontic'], ARRAY['拔牙','修复','种植','正畸','早矫'], false),
('K06','gum_alveolar_lesion',14, ARRAY['periodontic','surgical'], ARRAY['牙周','拔牙'], false);
('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'], 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'], 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'], false),
('K00','development_eruption',30, ARRAY['implant','prosthodontic','restorative','endodontic','surgical','cosmetic','pediatric','orthodontic'], false),
('K06','gum_alveolar_lesion',14, ARRAY['periodontic','surgical'], false);
-- ── 牙位 unnest 一次 → 索引临时表 ──
CREATE TEMP TABLE vt_treat AS
......@@ -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}$';
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)
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();
......@@ -72,14 +64,14 @@ CREATE INDEX ON vt_struct(patient_id, tooth); ANALYZE vt_struct;
-- ── 诊断牙(宇宙):合规 + 过 cooldown,取 LATEST 诊断 ──
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
FROM patient_facts d
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
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}$'
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;
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,
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_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.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,
......
/**
* 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) {
const events = [
{
subjectType: 'patient',
subjectRef: `push-p${randomUUID().slice(0, 6)}`,
clinicId: 'mock-clinic-1',
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(),
updatedAt: now.toISOString(),
payload: {
name: '推送测试-张三',
phone: '13900001111',
......
......@@ -2,13 +2,15 @@ import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { apiReference } from '@scalar/nestjs-api-reference';
import { cleanupOpenApiDoc } from 'nestjs-zod';
import helmet from 'helmet';
import { AppModule } from './app.module';
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() {
// rawBody: true → req.rawBody 保留原始 bytes,push HMAC 验签需要(parsed JSON 字段顺序不稳)
......@@ -24,33 +26,11 @@ async function bootstrap() {
allowedHeaders: ['Authorization', 'Content-Type', 'Accept'],
});
app.setGlobalPrefix('pac/v1', {
exclude: [
'health',
'api/docs',
'api/openapi.json',
// Bull Board UI(@bull-board/nestjs 自己挂 Express handler,不走 Nest 路由前缀)
'admin/queues',
'admin/queues/*path',
],
});
app.setGlobalPrefix(GLOBAL_PREFIX, { exclude: GLOBAL_PREFIX_EXCLUDE });
app.useGlobalFilters(new AllExceptionsFilter());
const config = new DocumentBuilder()
.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)),
);
const document = buildPacOpenApiDocument(app);
app.use('/api/openapi.json', (_req: unknown, res: { json: (b: unknown) => void }) =>
res.json(document),
......
......@@ -10,7 +10,7 @@ import type { Tone } from './tone';
// ⭐ 话术上下文(tier-agnostic):稳健/标准/深度三档共用同一份 ScriptContext;
// 也被实时教练复用(realtime-coach-context)。脊柱 = AiCallRunner,策略 = 各档 AiCall。
// 设计见 docs/algorithm/ai-script-generation.md
// 设计见 apps/pac-docs/content/docs/algorithms/script-generation.mdx
export interface ScriptContext {
// 患者数据(ScriptContext = 三档全集数据,各档组装 user prompt 时各取所需;非审计快照)。
// ⚠️ ID 类不进(关联走 agent_invocations.linked_patient_id 列);真名 name 是数据,
......
......@@ -27,7 +27,7 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor {
private static readonly LATE_MS = 15 * 60_000;
// 注:曾因源 CH 跑 UTC 导致 arrived_at(in_time)摄入早 8h,这里加过 +8h band-aid。
// 已根治 —— 源 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 WAIT_KW = ['不可等候', '时间敏感', '赶时间', '不能等'];
......
......@@ -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。
* 旧 bug:SQL 写 `occurred_at IS NOT NULL` → recommendation 触发(recCodes)全部命不中,死代码。
*
* ⚠️ 牙位无法做"按牙排除"(数据源限制,非 bug):
* "已做治疗"的真凭据是结算表 fact_settlement_out(花钱才算做)→ 落 actual treatment,
* 而结算按收费项记账,**结构上无牙位列**。故排除只能 patient 级(同 patient 做过同类治疗即排)。
* treat_plan 里 46% 有牙位,但落的是 planned(≠已做),不能用于排除。反馈 DW 在结算补牙位再升级。
* 牙位级排除(W4 末已升级 —— 旧注释说"做不到"已废,实现见 ⑤a):
* "已做治疗"的凭据 = actual treatment(花钱结算落地)。排除按牙位 overlap 判:
* - 信号有牙位 + actual 有牙位 → tooth array overlap 命中即排除(牙位来自 EMR.treat_plan,~48.7% 带牙位)
* - 信号无牙位(如 K05 全口诊断)→ 退化到 patient / category 级
* - actual 无牙位(如全口洁治)→ 视为全口覆盖 → 排除该 category 全部牙位
* 注:结算表本身无牙位列;牙位取自 EMR.treat_plan(planned,不当"已做",仅供与 actual 对齐做 overlap)。
* 反馈 DW:在结算补牙位可进一步提精度。
*
* 通用过滤(每个子场景 SQL 自己写,共用片段后续抽):
* active + 非 DNC + 非 deceased(phone 缺失暂不强制,DW 不提供)
......
......@@ -97,7 +97,7 @@ export const AssemblerConfigSchema = z.object({
key: z.string().min(1),
/// **炸行兜底**:若 DW ETL 把 1:N 子表(影像 / 结算明细等)写成 LEFT JOIN 没 groupArray,
/// 主表 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(),
}),
......
......@@ -2208,8 +2208,8 @@ export function resolveWriteBatchSize(): number {
* ⚠️ 并行时注意:
* - 数据完整性不受影响(cohort = disjoint 患者;source_event_id + fact version partial UNIQUE 双保险)
* - checkpoint resume 禁用(完成乱序,靠幂等从头重跑)
* - 需 bump Prisma 连接池:DATABASE_URL?connection_limit=N×(write_batch 并发数+余量),
* 否则并发 createMany/bulkWrite 排队等连接(不损坏数据,只是变慢)
* - 连接池自动随并发放大(PrismaService.withCohortDerivedPool 按 PAC_COHORT_CONCURRENCY 推导
* connection_limit),无需手动配;即便排队也只是变慢、不损坏数据
* - 内存 N× 单 cohort(单 cohort ~330MB;并发 4 ≈ 1.3GB,14GB 机器有余量)
*/
export function resolveCohortConcurrency(): number {
......
......@@ -8,14 +8,14 @@ import { TransformsSchema } from '../transforms/transforms.schema';
* 由 PAC 这边的 yaml(transforms + assemblers)做形态改造 + 翻译。
*
* v2.2 新增:
* - 顶层 `transforms[]` — Layer A.5 声明式形态改造(5 operator 白名单)
* - 顶层 `transforms[]` — Layer A.5 声明式形态改造(6 operator 白名单)
* - SQL 退化为"等价 csv 单表 dump 的 SELECT"(只列选 + WHERE 过滤)
* - JSON 数组拆行 / 字段派生 / 多列推断 / 关键词分流 全在 transforms
* - 实现双源真等价(直连 ClickHouse 或 csv 文件,下游无感知)
*
* 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 索引)
......@@ -150,8 +150,8 @@ export const ColdImportManifestSchema = z
sql_source: ClickHouseSourceSchema.optional(),
/// **Layer A.5 transforms**(可选)— 在 sources 之后、AssemblerEngine 之前跑。
/// 5 operator 白名单:project / split_json_array / derive / route_by_pattern / pick_first_nonzero
/// 详见 docs/algorithm/canonical-fact-layer.md §五。
/// 6 operator 白名单:project / split_json_array / derive / route_by_pattern / pick_first_nonzero / filter
/// 详见 apps/pac-docs/content/docs/architecture/data-ingestion.mdx §五。
transforms: TransformsSchema.optional(),
/// PAC 写的 assembler 配置清单(每个 canonical resource 一份)
......
......@@ -51,13 +51,14 @@ export class PaymentParser implements Parser {
// String() 强转 — host 偶有数字型卡券值(.trim 直接调会炸)。
const cardTypeName = String(c.cardTypeName ?? '').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(保险方付款,非诊所价格折扣;不进"价格底线"锚点)。
const discYuan =
const discSum =
Number(c.discountDept ?? 0) +
Number(c.discountCompany ?? 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;
return [
......
......@@ -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" 的共享内核。
*
* 三入口共用(canonical-fact-layer.md §五 4 入口统一管道):
......@@ -81,7 +90,8 @@ export class PipelineDispatcher {
const patientRows = (input.tables.patients ?? []) as Record<string, unknown>[];
for (const row of patientRows) {
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);
m.patientsUpserted++;
// 患者主档变更也算"事件",触发 persona 重算(do_not_contact_status 等画像依赖它)
......@@ -119,7 +129,8 @@ export class PipelineDispatcher {
__source_row?: Record<string, unknown>;
} & Record<string, unknown>;
const rawRow = explicitSource ?? canonicalRow;
const sourceUnit = resolveSourceUnit(rawRow, input.identityNamespaceField);
const sourceUnit =
explicitSourceUnit(canonicalRow) ?? resolveSourceUnit(rawRow, input.identityNamespaceField);
const txn = this.synthesizer.synthesize({
rawRow,
canonicalRow,
......
......@@ -32,7 +32,7 @@ export class HmacVerifier {
timestampHeader: string | undefined;
signatureHeader: string | undefined;
rawBody: string;
}): Promise<{ hostId: string; hostName: string; tenantHint: string }> {
}): Promise<{ hostId: string; hostName: string }> {
const { hostIdHeader, timestampHeader, signatureHeader, rawBody } = input;
if (!hostIdHeader) throw new UnauthorizedException('缺 X-PAC-Host-Id header');
......@@ -82,11 +82,8 @@ export class HmacVerifier {
throw new UnauthorizedException('签名不匹配');
}
// tenant hint:demo / mock 走 t_demo,其余 t_default(后续接 host 配置)
const tenantHint =
host.name.includes('demo') || host.name.includes('mock') ? 't_demo' : 't_default';
return { hostId: host.id, hostName: host.name, tenantHint };
// tenant(集团)由 push 事件 / pull 数据自带,不在验签层推断。
return { hostId: host.id, hostName: host.name };
}
/**
......
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { SyncDirection, SyncStatus } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { PipelineDispatcher, type EmitsResolver } from '../pipeline/pipeline-dispatcher.service';
......@@ -99,12 +99,34 @@ export class PushReceiverService {
async receive(input: {
hostId: string;
hostName: string;
tenantId: string;
events: PushEvent[];
}): Promise<PushEventsResponse> {
const { hostId, hostName, tenantId, events } = input;
const firstSourceId = events[0]?.sourceEventId ?? 'unknown';
const triggeredBy = `push:${hostName}:${firstSourceId}`;
const { hostId, hostName, events } = input;
// 集团 = 事件携带的 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 ───
const syncLog = await this.prisma.syncLog.create({
......@@ -199,12 +221,15 @@ export class PushReceiverService {
// canonical row = payload + 元字段拍平
const canonicalRow: Record<string, unknown> = {
...e.payload,
externalId: e.subjectRef,
externalId: e.subjectId,
clinicId: e.clinicId,
occurredAt: e.occurredAt,
...(e.patientRef ? { patientExternalId: e.patientRef } : {}),
// 给 synthesizer 用的元字段
_sourceEventId: e.sourceEventId,
// 源组织命名空间来自事件信封 → dispatcher 优先取它(防跨命名空间撞号)
sourceUnit: e.sourceUnit ?? '',
...(e.patientId ? { patientExternalId: e.patientId } : {}),
// 末次修改时间 → synthesizer 合幂等键(内容变升版本)
...(e.updatedAt ? { updatedAt: e.updatedAt } : {}),
// 给 synthesizer / dispatcher 用的元字段
_action: e.action,
_subjectType: e.subjectType,
// ⭐ 真原文留底:host event payload(不含 PAC 加的 meta 字段)
......
......@@ -61,7 +61,7 @@ export class PushController {
// raw body 优先(main.ts 应配 rawBody preserve);否则用 JSON.stringify(已 parsed)兜底
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,
timestampHeader,
signatureHeader,
......@@ -69,12 +69,12 @@ export class PushController {
});
// body 单独走 zod 校验(Nest 全局 ZodValidationPipe 已挂)
// 集团 tenantId 由事件携带(receiver 内解析校验),不再由 host 名兜底。
const parsed = PushEventsRequestSchema.parse(body);
return this.receiver.receive({
hostId,
hostName,
tenantId: tenantHint,
events: parsed.events,
});
}
......
......@@ -15,13 +15,20 @@ import { z } from 'zod';
* - payload → 业务字段(canonical 已映射,如 appointmentAt / status / doctorId 等)
*/
export const PushEventSchema = z.object({
subjectType: z.string().describe('CanonicalSubjectType: patient/appointment/...'),
subjectRef: z.string().min(1).describe('宿主侧本资源的 externalId'),
patientRef: z.string().min(1).optional().describe('关联 patient externalId;subjectType=patient 时可省'),
clinicId: z.string().min(1).describe('发生诊所(立柱必填)'),
action: z.string().describe('canonical Action: created/updated/canceled/...'),
sourceEventId: z.string().min(1).describe('宿主侧唯一事件 id(幂等键)'),
subjectType: z.string().describe('CanonicalSubjectType,见事件枚举:appointment/diagnosis/...'),
action: z.string().describe('canonical Action,见事件枚举'),
/// 本记录在宿主侧的 id(= canonical externalId,进幂等键)。
subjectId: z.string().min(1).describe('本记录在宿主侧的 id(= canonical externalId)'),
/// 所属患者 id;subjectType=patient(主档自身)时可省。
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。
updatedAt: z.string().optional().describe('末次修改时间;驱动幂等 / 版本'),
eventSeq: z.number().int().nonnegative().optional().describe('递增序号,可选,用于水位'),
payload: z.record(z.string(), z.unknown()).describe('已 canonical 化的业务字段'),
});
......@@ -51,12 +58,13 @@ export type PushEventsResponse = z.infer<typeof PushEventsResponseSchema>;
*
* - `source` = 原生源表名(= 宿主数据字典里的表名 / manifest tables[].table)
* - `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。
*/
export const PushRowsRequestSchema = z.object({
tenantId: z.string().min(1).optional(),
source: z.string().min(1).describe('原生源表名(= manifest tables[].table)'),
rows: z
.array(z.record(z.string(), z.unknown()))
......
......@@ -6,7 +6,7 @@ import { TransformEngine } from './transform-engine';
*
* 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({
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
import { PrismaClient } from '@prisma/client';
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()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
datasourceUrl: withCohortDerivedPool(process.env.DATABASE_URL),
log: [
{ level: 'warn', emit: 'event' },
{ level: 'error', emit: 'event' },
......
......@@ -7,7 +7,7 @@
* - plan-asset-generate: 生成 PlanScript + PlanSummary(jobName = planId 串行)
*
* 三个队列各自 jobName 锁 per-queue,跨队列允许并发(数据依赖天然)。
* 详见 docs/three-layer-model.md "事件驱动主路径"。
* 详见 apps/pac-docs/content/docs/architecture/three-layer-model.mdx "事件驱动主路径"。
*/
export const QueueName = {
PERSONA_RECOMPUTE: 'persona-recompute',
......
......@@ -19,7 +19,7 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro
/**
* Queues — BullMQ 三个队列的统一注册入口。
*
* 触发链路(详见 docs/three-layer-model.md):
* 触发链路(详见 apps/pac-docs/content/docs/architecture/three-layer-model.mdx):
* pipeline-dispatcher 写完 transaction
* → enqueue persona-recompute (jobName=patientId)
* → 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';
export const metadata: Metadata = {
title: 'PAC Workbench',
description: 'Patient Analyze Center workbench',
description: 'Patient Analysis Center workbench',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
......
......@@ -63,7 +63,7 @@ docker compose -f docker-compose.prod.yml exec pac-service sh
| `systemd/pac-service.service` | NestJS 后端 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 -- -
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 流程,只是
## 监控 / 告警
详见 `docs/deployment-data-ingest.md` §四。关键:
详见 `apps/pac-docs/content/docs/operations/deployment.mdx` §四。关键:
- `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;"` 看同步成功率
- `/admin/queues` Bull Board 看队列
......@@ -120,6 +120,18 @@ services:
ports:
- "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:
postgres_data:
redis_data:
......@@ -126,6 +126,25 @@ services:
- /app/apps/pac-web/node_modules
- /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:
postgres_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` 为准。
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