Commit 1513cef0 by luoqi

refactor(facts): add lossless content readers with legacy-compatible consumers

parent 15d08d44
Pipeline #3873 failed in 0 seconds
/**
* 有界只读样本导出:在现有 service 运行目录通过 node 执行;不启动 Nest/队列/重算。
* 输出含临床原文,只可重定向到仓库外权限 0600 的本地文件,不提交。
* 每宿主按患者 UUID 选有末诊/医生派生字段的 40 位患者,不代表全库分布。
*/
const { PrismaClient } = require('@prisma/client');
const db = new PrismaClient();
async function query(sql, ...args) {
return db.$transaction(
async (tx) => {
await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY');
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='8s'");
await tx.$executeRawUnsafe("SET LOCAL lock_timeout='500ms'");
await tx.$executeRawUnsafe('SET LOCAL max_parallel_workers_per_gather=0');
await tx.$executeRawUnsafe("SET LOCAL work_mem='8MB'");
return tx.$queryRawUnsafe(sql, ...args);
},
{ timeout: 11000, maxWait: 2000 },
);
}
(async () => {
const startedAt = new Date().toISOString();
const hosts = await query(
"SELECT id,name FROM hosts WHERE name IN ('friday','jvs-dw') ORDER BY name",
);
const samples = [];
const cohort = process.env.PAC_FACT_SAMPLE_COHORT === 'visits' ? 'visits' : 'profile';
for (const host of hosts) {
const patients =
cohort === 'visits'
? await query(
`SELECT p.id,p.host_id,p.tenant_id,p.source_unit,pp.last_visit_at,pp.last_visit_doctor,pp.preferred_doctor
FROM (SELECT DISTINCT patient_id FROM (SELECT patient_id FROM patient_return_visits WHERE host_id=$1::uuid LIMIT 400) bounded LIMIT 20) chosen
JOIN patients p ON p.id=chosen.patient_id LEFT JOIN patient_profiles pp ON pp.patient_id=p.id
WHERE p.host_id=$1::uuid ORDER BY p.id`,
host.id,
)
: await query(
`SELECT p.id, p.host_id, p.tenant_id, p.source_unit,
pp.last_visit_at, pp.last_visit_doctor, pp.preferred_doctor
FROM patients p JOIN patient_profiles pp ON pp.patient_id=p.id
WHERE p.host_id=$1::uuid AND (pp.last_visit_at IS NOT NULL OR pp.preferred_doctor IS NOT NULL)
ORDER BY p.id LIMIT 40`,
host.id,
);
for (const patient of patients) {
const facts = await query(
`SELECT f.id AS fact_id,f.host_id,f.tenant_id,f.source_unit,f.subject_id,f.version,
f.type,f.kind,f.status,f.occurred_at,f.planned_for,f.clinic_id,f.content,
f.valid_until,f.title,f.summary
FROM patient_facts f WHERE f.patient_id=$1::uuid AND f.host_id=$2::uuid AND f.tenant_id=$3
AND NOT EXISTS (SELECT 1 FROM patient_facts n WHERE n.host_id=f.host_id
AND n.tenant_id=f.tenant_id AND n.source_unit=f.source_unit AND n.subject_id=f.subject_id AND n.version>f.version)
ORDER BY f.id LIMIT 501`,
patient.id,
host.id,
patient.tenant_id,
);
const visits = await query(
`SELECT task_date,type,status,task_status,result,source_created_at,source_updated_at
FROM patient_return_visits WHERE patient_id=$1::uuid AND host_id=$2::uuid AND tenant_id=$3
ORDER BY task_date DESC NULLS LAST,id LIMIT 101`,
patient.id,
host.id,
patient.tenant_id,
);
samples.push({
host: host.name,
patient,
facts,
visits,
factsTruncated: facts.length === 501,
visitsTruncated: visits.length === 101,
});
}
}
console.log(
JSON.stringify({
startedAt,
finishedAt: new Date().toISOString(),
selection:
cohort === 'visits'
? 'first-400-return-visits; up-to-20-distinct-patients-per-host'
: 'profile-has-last-visit-or-doctor; UUID-first-40-per-host',
samples,
}),
);
})()
.catch((e) => {
console.error(JSON.stringify({ error: e.code || e.name }));
process.exitCode = 1;
})
.finally(() => db.$disconnect());
/** 本地差分验收:可信 Git 基线与当前消费者比较;不启动服务、不执行召回 SQL、不调用 LLM。 */
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import Module from 'node:module';
import { isDeepStrictEqual } from 'node:util';
import ts from 'typescript';
import { businessDate } from '@pac/utils/business-date';
import { treatmentCategoryNameZh, readEmrNarrative, emrNarrativeText, lookupDxTreatment } from '@pac/types';
import { storedContentField } from '../src/modules/facts/read-model/stored-content-field';
import { TreatmentSensitivityFeatureExtractor } from '../src/modules/persona/features/treatment-sensitivity.feature';
import { SpecialAttentionFeatureExtractor } from '../src/modules/persona/features/special-attention.feature';
import { TreatmentInitiationRecallScenario } from '../src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario';
import { GAP_PRIMARY_GROUPS } from '../src/modules/clinical-gap/potential-treatment-gap.sql';
import type { FeatureExtractorContext } from '../src/modules/persona/features/feature.interface';
const baseRef = process.env.PAC_FACT_READ_BASE_REF || 'HEAD';
const root = resolve(__dirname, '..');
const base = 'apps/pac-service/';
const source = (path: string, baseline: boolean) => baseline
? execFileSync('git', ['show', `${baseRef}:${base}${path}`], { encoding: 'utf8' })
: readFileSync(resolve(root, path), 'utf8');
function baselineModule(path: string): any {
const filename = resolve(root, path.replace(/\.ts$/, '.baseline.cjs'));
const m = new Module(filename, module) as any;
m.filename = filename;
m.paths = (Module as any)._nodeModulePaths(dirname(filename));
m._compile(ts.transpileModule(source(path, true), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, experimentalDecorators: true },
}).outputText, filename);
return m.exports;
}
// 只提取纯病历组装函数,避免装载 AI 调用运行器;函数内容来自本地可信代码,绝不来自样本。
function medicalRecord(baseline: boolean): (emr: any, facts: any[]) => unknown {
const path = 'src/modules/ai/orchestrators/plan-script.orchestrator.ts';
const s = source(path, baseline);
const ast = ts.createSourceFile(path, s, ts.ScriptTarget.ES2022, true);
const fn = ast.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'buildMedicalRecord');
if (!fn) throw new Error('buildMedicalRecord not found');
const js = ts.transpileModule(fn.getText(ast), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
return new Function('businessDate', 'treatmentCategoryNameZh', 'readEmrNarrative', 'emrNarrativeText', 'storedContentField', `${js}\nreturn buildMedicalRecord;`)(
businessDate, treatmentCategoryNameZh, readEmrNarrative, emrNarrativeText, storedContentField,
);
}
function capture(fn: () => unknown) {
try { return { value: fn() }; }
catch (e) { return { error: (e as Error).name, message: (e as Error).message }; }
}
const report = { patients: 0, featureComparisons: 0, featureDifferences: 0, medicalRecords: 0, medicalDifferences: 0, existingMedicalErrors: 0, generatedQueries: 0, queryDifferences: 0 };
const beforeFeatures = [
new (baselineModule('src/modules/persona/features/treatment-sensitivity.feature.ts').TreatmentSensitivityFeatureExtractor)(),
new (baselineModule('src/modules/persona/features/special-attention.feature.ts').SpecialAttentionFeatureExtractor)(),
];
const afterFeatures = [new TreatmentSensitivityFeatureExtractor(), new SpecialAttentionFeatureExtractor()];
const beforeMedical = medicalRecord(true), afterMedical = medicalRecord(false);
const beforeRecall = new (baselineModule('src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts').TreatmentInitiationRecallScenario)(null);
const afterRecall = new TreatmentInitiationRecallScenario(null as any);
for (const primary of Object.keys(GAP_PRIMARY_GROUPS)) {
const rule = lookupDxTreatment(primary)!;
for (const variant of ['legacy', 'setbased'] as const) {
for (const filter of [{}, { patientId: 'patient' }, { patientIds: ['p1', 'p2'] }]) {
const scope = { hostId: 'host', tenantId: 'tenant', now: new Date('2026-10-23T00:00:00Z'), ...filter };
const a = beforeRecall.buildScenarioSql(scope, primary, rule, variant);
const b = afterRecall.buildScenarioSql(scope, primary, rule, variant);
report.generatedQueries++;
if (!isDeepStrictEqual({sql:a.sql,values:a.values}, {sql:b.sql,values:b.values})) report.queryDifferences++;
}
}
}
for (const file of process.argv.slice(2)) {
const sample = JSON.parse(readFileSync(file, 'utf8'));
for (const patient of sample.samples) {
report.patients++;
const rows = patient.facts.map((f: any) => ({ ...f, id: f.fact_id,
occurredAt: f.occurred_at ? new Date(f.occurred_at) : null,
plannedFor: f.planned_for ? new Date(f.planned_for) : null,
}));
const active = rows.filter((f: any) => ['active', 'fulfilled'].includes(f.status));
const factsByType = new Map<string, any[]>();
for (const f of active) factsByType.set(f.type, [...(factsByType.get(f.type) ?? []), f]);
const ctx = { factsByType, profile: null, appointmentsAll: rows.filter((f: any) => f.type === 'appointment_record'), now: new Date(sample.startedAt) } as FeatureExtractorContext;
for (let i=0; i<beforeFeatures.length; i++) {
report.featureComparisons++;
if (!isDeepStrictEqual(capture(() => beforeFeatures[i].extract(ctx)), capture(() => afterFeatures[i]!.extract(ctx)))) report.featureDifferences++;
}
for (const emr of factsByType.get('emr_record') ?? []) {
const a = capture(() => beforeMedical(emr, active)), b = capture(() => afterMedical(emr, active));
report.medicalRecords++;
if ('error' in a) report.existingMedicalErrors++;
if (!isDeepStrictEqual(a,b)) report.medicalDifferences++;
}
}
}
console.log(JSON.stringify(report, null, 2));
if (report.featureDifferences || report.medicalDifferences || report.queryDifferences) process.exitCode = 1;
import { storedContentField } from '../../facts/read-model/stored-content-field';
import { businessDate } from '@pac/utils/business-date';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
......@@ -567,7 +568,7 @@ export class PlanScriptOrchestrator {
.filter((f) => f.type === 'emr_record' && f.occurredAt)
.sort((a, b) => b.occurredAt!.getTime() - a.occurredAt!.getTime())[0];
const lastChiefComplaint =
((latestEmr?.content as Record<string, unknown> | undefined)?.illness_desc as string | undefined)?.trim() ||
(storedContentField('emr_record', latestEmr?.content, 'illness_description') as string | undefined)?.trim() ||
null;
// 给每条 reason 查"触发该诊断/建议的医生 + 日期"
......@@ -930,18 +931,18 @@ function buildMedicalRecord(
return {
date: emr.occurredAt ? businessDate(emr.occurredAt) : null,
doctorName: str(c.doctor_name),
chiefComplaint: str(c.illness_desc),
presentIllness: str(c.pre_illness),
chiefComplaint: str(storedContentField('emr_record', c, 'illness_description')),
presentIllness: str(storedContentField('emr_record', c, 'present_illness')),
pastHistory: str(c.past_history),
generalCondition: str(c.general_condition),
examFindings: readEmrNarrative(c.exam_findings),
auxiliaryFindings: readEmrNarrative(c.aux_exam_findings),
auxiliaryFindings: readEmrNarrative(storedContentField('emr_record', c, 'auxiliary_exam_findings')),
diagnoses,
disposals: readEmrNarrative(c.disposal),
treatmentNotes: readEmrNarrative(c.treatment_notes),
disposals: readEmrNarrative(storedContentField('emr_record', c, 'procedure')),
treatmentNotes: readEmrNarrative(storedContentField('emr_record', c, 'treatment')),
doctorAdvice: str(c.doctor_advice),
treatmentPlanText: emrNarrativeText(c.treatment_plan),
diagnosisText: str(c.diagnosis_text),
diagnosisText: str(storedContentField('emr_record', c, 'diagnosis')),
recommendations,
plannedTreatments,
};
......
# 事实兼容读取:第一阶段
此层只规范化已确认同义的字段名称,不写数据库,不改变事实身份、版本、状态或时间。15 条别名覆盖 8 类事实;其余类型和字段原样保留。
- `content-aliases.ts`:TS、SQL 和现有开放内容投影共用的别名表。
- `readFactContent(type, value)`:返回规范化 `content`、完整 `original`、`conflicts` 和 `invalidContent`。JSON 段落、数组、空值与未知扩展不被解析或清洗。冲突保留两键,不择一;消费规范化字段前必须检查冲突。
- `storedContentField(type, value, field)` / `storedContentTextSql(alias, type, field)`:已接入消费者按规范字段名读取现行存储;旧键缺失或 null 不回退到同名扩展。SQL 生成原单字段表达式,无逐行 JSON 聚合。
- `normalizedContentSql(alias)`:等价 JSONB 读取表达式,不建函数或视图。调用方同时选取原 content;非法根结构原样保留,TS 用 invalidContent 标记。SQL 调用方应检查根结构及冲突,不能把结果直接认作通过校验的新格式。
旧接口仍用原文及原白名单,只共享“旧键 → 既有公开名称”规则,不自动公开新内部键。未知扩展即使恰好与新内部字段同名,也不能改变旧接口响应。内部读取结果包含敏感备注与扩展,不能直接作为公开 DTO。
治疗敏感/特别关注画像、AI 病历组装及召回的两个展示字段已接入 `storedContentField` / `storedContentTextSql`,仍按明确的旧存储契约取值,业务规则不变。完整规范化 JSONB 表达式仅用于本地对照;其余消费者、回访历史和旧接口未切换。没有新版存储写入、格式版本列、迁移或回填,不能凭字段存在与否判断版本或启用全量字段改名写入。
证据中的 `source_field` 等路径仍指向原始正文。原文和规范化内容分开保存于读结果中,不能把旧证据路径误当成新内容路径。本阶段不重写嵌套证据、关联 ID、医生角色、金额、业务时间或事实状态。
## 验证
服务目录运行:
```sh
pnpm exec jest --runInBand fact-content-read lookup-content lookup-fact
# PAC_FACT_READ_DATABASE_URL 仅配置本地数据库;测试使用 READ ONLY + VALUES,不创建表。
pnpm exec jest --runInBand fact-content-read-postgres
```
`scripts/audit-fact-read-sample.cjs` 仅导出有界真实样本,所有查询 READ ONLY、限时、串行;默认每宿主 40 位有末诊/医生派生字段的患者。`PAC_FACT_SAMPLE_COHORT=visits` 另取最多 20 位回访患者。输出含临床原文,须保存于仓库外权限 0600 的文件;不得提交。
`scripts/evaluate-fact-read.ts <样本文件>` 在本地比较 Git HEAD 开放投影、当前投影及 SQL/TS 读值,并统计三项待决语义问题。它不执行画像重算、召回、外部接口或写操作。样本是定向选择,不能外推全库比例;截至各样本的取数时刻解释结果。
`scripts/verify-fact-read-consumers.ts <样本文件>...` 对比 Git HEAD 与当前两项画像、纯 AI 病历组装以及 66 组召回 SQL 文本/参数。只读本地样本,不启动应用、执行 SQL、调用 LLM 或输出患者正文。
/**
* 阶段一:只收录已确认同义的正文/描述字段;内部标准名统一 snake_case。
* 不改医生角色、身份引用、金额、时间、状态或原文结构。未知字段仍保留。
* 这是读取别名,不是存储版本识别器;不能据此开启新版写入。
*/
export const LOSSLESS_CONTENT_ALIASES: Readonly<Record<string, Readonly<Record<string, string>>>> =
{
consultation_record: { content: 'description' },
diagnosis_record: { name_zh: 'name' },
treatment_record: { treat_stages: 'stages' },
recommendation_record: { name: 'recommendation', extracted_by: 'extraction_method' },
emr_record: {
illness_desc: 'illness_description',
pre_illness: 'present_illness',
aux_exam_findings: 'auxiliary_exam_findings',
diagnosis_text: 'diagnosis',
treatment_notes: 'treatment',
disposal: 'procedure',
},
image_record: { file_url: 'file_location' },
appointment_record: {
complaint_category: 'visit_reason_category',
complaint_text: 'chief_complaint',
},
complaint_record: { content: 'description' },
};
export function contentAliases(type: string): Readonly<Record<string, string>> {
return Object.hasOwn(LOSSLESS_CONTENT_ALIASES, type) ? LOSSLESS_CONTENT_ALIASES[type]! : {};
}
import { Prisma } from '@prisma/client';
import { LOSSLESS_CONTENT_ALIASES } from './content-aliases';
/**
* 与 readFactContent 共用别名表的只读 JSONB 表达式;无函数/视图/迁移 DDL。
* 本阶段供影子核验,不替换召回热路径。调用者保留原 content 作为证据。
* null/数组/标量原样返回,不能把非法正文悄悄转成空对象。
*/
export function normalizedContentSql(alias: string): Prisma.Sql {
if (!/^[a-z][a-z0-9_]*$/i.test(alias)) throw new Error('Invalid fact table alias');
const type = Prisma.raw(`${alias}.type`);
const content = Prisma.raw(`${alias}.content`);
const aliases = JSON.stringify(LOSSLESS_CONTENT_ALIASES);
return Prisma.sql`CASE WHEN jsonb_typeof(${content}) = 'object' THEN (
SELECT COALESCE(jsonb_object_agg(
CASE WHEN mapping.names ? entry.key
AND (NOT (${content} ? (mapping.names ->> entry.key))
OR ${content} -> (mapping.names ->> entry.key) = entry.value)
THEN mapping.names ->> entry.key ELSE entry.key END,
entry.value), '{}'::jsonb)
FROM jsonb_each(${content}) AS entry(key, value)
CROSS JOIN (SELECT COALESCE(${aliases}::jsonb -> ${type}, '{}'::jsonb) AS names) mapping
) ELSE ${content} END`;
}
import { isDeepStrictEqual } from 'node:util';
import { contentAliases } from './content-aliases';
export interface ContentAliasConflict {
source: string;
target: string;
}
export interface FactContentRead {
/** 原值单独保留,字段改名不影响证据和原有 DTO。 */
original: unknown;
content: Record<string, unknown> | null;
conflicts: ContentAliasConflict[];
invalidContent: boolean;
}
/**
* 无 IO、无状态筛选、无兜底值。显式 null 与不存在不同,冲突保留两键并报告。
* 内容仍是内部数据(包括备注和未知扩展),公开接口必须继续走原白名单与关联校验。
*/
export function readFactContent(type: string, value: unknown): FactContentRead {
const original = structuredClone(value);
if (
value === null ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.prototype.toString.call(value) !== '[object Object]'
) {
return { original, content: null, conflicts: [], invalidContent: true };
}
const content = structuredClone(value) as Record<string, unknown>;
const conflicts: ContentAliasConflict[] = [];
for (const [source, target] of Object.entries(contentAliases(type))) {
if (!Object.hasOwn(content, source)) continue;
if (Object.hasOwn(content, target) && !isDeepStrictEqual(content[source], content[target])) {
conflicts.push({ source, target });
continue;
}
content[target] = content[source];
delete content[source];
}
return { original, content, conflicts, invalidContent: false };
}
import { Prisma } from '@prisma/client';
import { LOSSLESS_CONTENT_ALIASES } from './content-aliases';
const storedKeys = Object.fromEntries(
Object.entries(LOSSLESS_CONTENT_ALIASES).map(([type, names]) => [
type,
Object.fromEntries(Object.entries(names).map(([source, target]) => [target, source])),
]),
);
/** 按明确的现行存储契约解析规范名称;不凭字段存在与否猜测存储版本。 */
export function storedContentKey(type: string, field: string): string {
const names = Object.hasOwn(storedKeys, type) ? storedKeys[type] : undefined;
return names && Object.hasOwn(names, field) ? names[field]! : field;
}
/**
* 供渐进迁移的旧存储消费者使用。显式 null、缺失及未知扩展维持旧行为。
* ⛔ 不把同名的新字段作为兜底;新版写入启用前必须另有明确格式契约。
* 原对象不复制、不改写,返回值仍归原对象所有,调用方只读。
*/
export function storedContentField(type: string, value: unknown, field: string): unknown {
return (value as Record<string, unknown> | null | undefined)?.[storedContentKey(type, field)];
}
/** 与 TS 使用同一键解析,生成与旧 SQL 相同的单字段读取,不引入子查询或 JSON 聚合。 */
export function storedContentTextSql(alias: string, type: string, field: string): Prisma.Sql {
const key = storedContentKey(type, field);
if (!/^[a-z][a-z0-9_]*$/i.test(alias) || !/^[a-z][a-z0-9_]*$/i.test(key))
throw new Error('Invalid fact content identifier');
return Prisma.raw(`${alias}.content->>'${key}'`);
}
......@@ -12,6 +12,7 @@ import {
import { isRecord, lookupNarrative, LookupContentError } from './lookup-narrative';
import { lookupRelations } from './lookup-relations';
import { redactJson } from './open-redact';
import { contentAliases } from '../facts/read-model/content-aliases';
/** 新增查询专属字段投影;所有嵌套扩展也按白名单。 */
const fields: Record<string, string> = {
consultation_record:
......@@ -70,23 +71,17 @@ export const lookupContentNames: Record<string, Record<string, string>> = Object
...Object.fromEntries(names.split(' ').map((key) => [key, camel(key)])),
doctor_id: 'doctorExternalId',
...(type === 'emr_record' ? narrativeNames : {}),
...(type === 'image_record' ? { file_url: 'fileLocation' } : {}),
...(type === 'treatment_record' ? { treat_stages: 'stages' } : {}),
...(type === 'appointment_record'
? { complaint_text: 'chiefComplaint', complaint_category: 'visitReasonCategory' }
: {}),
// 与内部兼容读模型共用同义字段表。这里只复用旧键的公开名称,
// ⛔ 不自动接受新的内部键,避免存量同名扩展改变已发布响应。
...Object.fromEntries(
Object.entries(contentAliases(type)).map(([source, target]) => [source, camel(target)]),
),
...(ownIds[type] ? { [ownIds[type]]: 'externalId' } : {}),
...(type === 'diagnosis_record' ? { name_zh: 'name' } : {}),
...(type === 'recommendation_record'
? {
extracted_by: 'extractionMethod',
name: 'recommendation',
doctor_suggested_at: 'suggestedAt',
}
: {}),
...(['consultation_record', 'complaint_record'].includes(type)
? { content: 'description' }
: {}),
},
]),
);
......
import { storedContentField } from '../../facts/read-model/stored-content-field';
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType } from '@pac/types';
import type {
......@@ -92,7 +93,11 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor {
let waitHit = SpecialAttentionFeatureExtractor.WAIT_KW.some((k) => profileHay.includes(k));
for (const f of ctx.factsByType.get(FactType.EMR_RECORD) ?? []) {
const c = (f.content ?? {}) as Record<string, unknown>;
const text = [c.illness_desc, c.diagnosis_text, c.treatment_plan, c.doctor_advice, c.notes].join(' ');
const text = [
storedContentField('emr_record', c, 'illness_description'),
storedContentField('emr_record', c, 'diagnosis'),
c.treatment_plan, c.doctor_advice, c.notes,
].join(' ');
if (SpecialAttentionFeatureExtractor.WAIT_KW.some((k) => text.includes(k))) {
waitHit = true;
evidenceIds.add(f.id);
......
import { storedContentField } from '../../facts/read-model/stored-content-field';
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType } from '@pac/types';
import type {
......@@ -46,8 +47,12 @@ export class TreatmentSensitivityFeatureExtractor implements FeatureExtractor {
return {
id: f.id,
text: [
c.illness_desc, c.pre_illness, c.past_history, c.general_condition,
c.exam_findings, c.diagnosis_text, c.treatment_plan, c.doctor_advice, c.disposal,
storedContentField('emr_record', c, 'illness_description'),
storedContentField('emr_record', c, 'present_illness'),
c.past_history, c.general_condition, c.exam_findings,
storedContentField('emr_record', c, 'diagnosis'),
c.treatment_plan, c.doctor_advice,
storedContentField('emr_record', c, 'procedure'),
].map((v) => String(v ?? '')).join(' '),
};
});
......
import { storedContentTextSql } from '../../../facts/read-model/stored-content-field';
import { businessDate } from '@pac/utils/business-date';
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
......@@ -410,8 +411,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
sig.content->>'code' AS signal_code,
-- 诊断原文词(K00 主类目细分用:滞留→外科 / 早失→正畸 / 先天缺→修复…)。
-- 纯投影,不进任何 WHERE —— 返回行集与加它之前逐行相同。
sig.content->>'name_zh' AS signal_name_zh,
sig.content->>'extracted_by' AS extracted_by,
${storedContentTextSql('sig', 'diagnosis_record', 'name')} AS signal_name_zh,
${storedContentTextSql('sig', 'recommendation_record', 'extraction_method')} AS extracted_by,
sig.content->>'confidence' AS confidence,
sig.content->>'code_source' AS code_source, -- 置信度因子:std_code/name_map=医生 / image_ai / null
sig.clinic_id AS clinic_id,
......
import { storedContentTextSql } from '../src/modules/facts/read-model/stored-content-field';
import { Prisma, PrismaClient } from '@prisma/client';
import { LOSSLESS_CONTENT_ALIASES } from '../src/modules/facts/read-model/content-aliases';
import { normalizedContentSql } from '../src/modules/facts/read-model/read-content-sql';
import { readFactContent } from '../src/modules/facts/read-model/read-content';
const url = process.env.PAC_FACT_READ_DATABASE_URL;
const describePg = url ? describe : describe.skip;
describePg('只读 PostgreSQL 与 TS 规范化一致', () => {
let db: PrismaClient;
beforeAll(() => {
if (!['127.0.0.1', 'localhost'].includes(new URL(url!).hostname))
throw new Error('local database required');
db = new PrismaClient({ datasources: { db: { url } } });
});
afterAll(async () => db?.$disconnect());
test('真实 PostgreSQL VALUES 覆盖每条别名、冲突、空值、结构和未知键', async () => {
const rows = Object.entries(LOSSLESS_CONTENT_ALIASES).flatMap(([type, aliases]) =>
Object.entries(aliases).flatMap(([source, target]) => [
{ type, content: { [source]: { b: 0, a: ['否认', null] }, extra: false } },
{ type, content: { [target]: '新字段', extra: '' } },
{ type, content: { [source]: null } },
{ type, content: { [source]: '甲', [target]: null } },
{ type, content: { [source]: { a: 1, b: 2 }, [target]: { b: 2, a: 1 } } },
]),
);
const extra = [null, [], '非法根结构', {}, { untouched: 0 }].map((content) => ({
type: 'unknown',
content,
}));
const all = [...rows, ...extra];
const result = await db.$transaction(async (tx) => {
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
await tx.$executeRaw`SET LOCAL statement_timeout = '5s'`;
return tx.$queryRaw<Array<{ n: number; content: unknown }>>(Prisma.sql`
SELECT f.n, ${normalizedContentSql('f')} AS content
FROM (VALUES ${Prisma.join(all.map((r, n) => Prisma.sql`(${n}::int, ${r.type}::text, ${JSON.stringify(r.content)}::jsonb)`))}) f(n,type,content)
ORDER BY n`);
});
expect(result).toEqual(
all.map((row, n) => ({
n,
content: readFactContent(row.type, row.content).content ?? row.content,
})),
);
});
test('规范名称的标量 SQL 与原字段读取一致,包括空值和同名扩展', async () => {
const results = await db.$transaction(async (tx) => {
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
await tx.$executeRaw`SET LOCAL statement_timeout = '5s'`;
const results: Array<{ actual: unknown; expected: unknown }> = [];
for (const [type, aliases] of Object.entries(LOSSLESS_CONTENT_ALIASES)) {
for (const [source, target] of Object.entries(aliases)) {
const contents = [{ [source]: '原文', [target]: '扩展' }, { [source]: null, [target]: '扩展' }, { [target]: '扩展' }, { [source]: ['甲', null] }, {}];
results.push(...await tx.$queryRaw<Array<{ actual: unknown; expected: unknown }>>(Prisma.sql`
SELECT ${storedContentTextSql('f', type, target)} AS actual,
f.content->>${source} AS expected
FROM (VALUES ${Prisma.join(contents.map(c => Prisma.sql`(${JSON.stringify(c)}::jsonb)`))}) f(content)`));
}
}
return results;
});
expect(results.length).toBe(75);
for (const row of results) expect(row.actual).toEqual(row.expected);
});
});
import { storedContentField, storedContentTextSql } from '../src/modules/facts/read-model/stored-content-field';
import { LOSSLESS_CONTENT_ALIASES } from '../src/modules/facts/read-model/content-aliases';
import { readFactContent } from '../src/modules/facts/read-model/read-content';
import { normalizedContentSql } from '../src/modules/facts/read-model/read-content-sql';
import { lookupContent } from '../src/modules/open/lookup-content';
const cases = Object.entries(LOSSLESS_CONTENT_ALIASES).flatMap(([type, aliases]) =>
Object.entries(aliases).map(([source, target]) => ({ type, source, target })),
);
describe('无损事实读取契约', () => {
test('别名表不存在链式改名或多个旧键指向同一新键,保持 SQL/TS 同步语义', () => {
for (const aliases of Object.values(LOSSLESS_CONTENT_ALIASES)) {
expect(new Set(Object.values(aliases)).size).toBe(Object.keys(aliases).length);
for (const target of Object.values(aliases))
expect(Object.hasOwn(aliases, target)).toBe(false);
}
});
test.each(cases)('$type.$source → $target,保留结构和证据', ({ type, source, target }) => {
// 无损改名不涉及正文解析;JSON 段落、否定、不确定性都保持原样。
const value = {
[source]: [{ tooth: '16', text: '疑似龋?未治疗', value: 0 }],
extension: { keep: true },
};
const before = structuredClone(value);
const read = readFactContent(type, value);
expect(read.content).toEqual({ [target]: value[source], extension: { keep: true } });
expect(read.original).toEqual(before);
expect(read.conflicts).toEqual([]);
expect(readFactContent(type, read.content).content).toEqual(read.content);
read.content!.extension = false;
expect(value).toEqual(before);
expect(read.original).toEqual(before);
});
test.each(cases)('$type 双字段冲突不挑值,显式 null 也不兜底', ({ type, source, target }) => {
const input = { [source]: '旧值', [target]: null };
const read = readFactContent(type, input);
expect(read.content).toEqual(input);
expect(read.conflicts).toEqual([{ source, target }]);
expect(readFactContent(type, { [source]: null }).content).toEqual({ [target]: null });
expect(readFactContent(type, { [source]: '同义', [target]: '同义' }).content).toEqual({
[target]: '同义',
});
});
test('未知类型、未知字段不丢;标识、金额、角色、日期不改', () => {
const input = {
doctor_id: 'x',
source_encounter_external_id: 'emr',
amount_cents: null,
occurred_at: '2026-10-23',
status: 'fulfilled',
notes: '原始备注',
source_status: '任意值',
};
for (const type of [
'unknown',
'encounter_record',
'payment_record',
'diagnosis_record',
'__proto__',
])
expect(readFactContent(type, input).content).toEqual(input);
});
test.each([null, '正文', [], 0])('非法根结构不伪造为空事实: %p', (value) => {
expect(readFactContent('emr_record', value)).toEqual({
original: value,
content: null,
conflicts: [],
invalidContent: true,
});
});
test('SQL 表别名拒绝插入任意 SQL', () => {
expect(() => normalizedContentSql('f; DROP TABLE patients')).toThrow();
});
test.each([
['diagnosis_record', { name_zh: '龋齿?', code: 'K02', doctor_id: 'd' }],
['treatment_record', { treat_stages: ['canal_filling'], category: 'endo', notes: '内部备注' }],
['recommendation_record', { name: '建议复查', extracted_by: 'doctor_input' }],
[
'emr_record',
{
illness_desc: '不痛',
pre_illness: '三天',
diagnosis_text: '龋?',
disposal: '观察',
doctor_advice: '复查',
},
],
['image_record', { file_url: 'object/key', modality: 'panoramic' }],
['appointment_record', { complaint_text: '牙疼', complaint_category: 'endo' }],
['complaint_record', { content: '等待时间长' }],
])('规范化保留原值供现有接口读取:%s', (type, value) => {
const oldDto = lookupContent(type as string, value, null, null);
const read = readFactContent(type as string, value);
expect(lookupContent(type as string, read.original, null, null)).toEqual(oldDto);
expect(oldDto).not.toHaveProperty('notes');
expect(oldDto).not.toHaveProperty('original');
expect(oldDto).not.toHaveProperty('conflicts');
});
test('内部新名字不自动成为现有公开别名,原来的未知扩展仍不公开', () => {
const input = { pre_illness: '原文', present_illness: '同名内部扩展' };
expect(readFactContent('emr_record', input).conflicts).toHaveLength(1);
expect(lookupContent('emr_record', input, null, null).presentIllness).toBe('原文');
expect(
lookupContent('emr_record', { present_illness: '未知扩展' }, null, null).presentIllness,
).toBeNull();
});
test('对外别名冲突维持拒绝,规范化不能吞掉错误', () => {
expect(() =>
lookupContent('diagnosis_record', { name_zh: '龋', name: '缺牙' }, null, null),
).toThrow();
expect(() =>
lookupContent('emr_record', { pre_illness: '甲', presentIllness: '乙' }, null, null),
).toThrow();
});
});
describe('现行存储契约的字段读取', () => {
test.each(cases)('$type.$target 按旧存储键读取,不把扩展误作新版本', ({type, source, target}) => {
const oldValue = ['原文', { value: 0 }];
const content = { [source]: oldValue, [target]: '同名扩展' };
expect(storedContentField(type, content, target)).toBe(oldValue);
expect(storedContentField(type, { [source]: null, [target]: '扩展' }, target)).toBeNull();
expect(storedContentField(type, { [target]: '扩展' }, target)).toBeUndefined();
expect(storedContentTextSql('f', type, target).sql).toBe(`f.content->>'${source}'`);
expect(storedContentTextSql('f', type, target).values).toEqual([]);
expect(content[target]).toBe('同名扩展');
});
test('未规范化的字段保持原样,空正文不伪造值', () => {
expect(storedContentField('emr_record', { doctor_id: 'd' }, 'doctor_id')).toBe('d');
expect(storedContentField('emr_record', null, 'diagnosis')).toBeUndefined();
expect(storedContentField('unknown', { name: '' }, 'name')).toBe('');
});
test('SQL 字段及表别名只能是标识符', () => {
expect(() => storedContentTextSql('f;', 'emr_record', 'diagnosis')).toThrow();
expect(() => storedContentTextSql('f', 'emr_record', "x' OR true")).toThrow();
});
});
import { TreatmentSensitivityFeatureExtractor } from '../src/modules/persona/features/treatment-sensitivity.feature';
import { SpecialAttentionFeatureExtractor } from '../src/modules/persona/features/special-attention.feature';
import type { FeatureExtractorContext, ActiveFact } from '../src/modules/persona/features/feature.interface';
function context(content: ActiveFact['content']): FeatureExtractorContext {
return {
patient: { id:'p',externalId:'e',name:null,phone:null,gender:null,birthDate:null,active:true },
profile: null, now: new Date('2026-10-23T00:00:00Z'),
factsByType: new Map([['emr_record', [{ id:'f',subjectId:'s',type:'emr_record',kind:'actual',status:'active',occurredAt:null,plannedFor:null,clinicId:null,content }]]]),
appointmentsAll:[],relations:[],potentialGaps:[],
};
}
describe('画像消费者的规范字段读取保持原有业务判断', () => {
test('读取原主诉/处置,继续给出原标签和同一事实证据', () => {
const ctx = context({ illness_desc: '患者看牙紧张,不能等', disposal: '晕针', illness_description: '同名扩展', procedure: null });
const sensitivity = new TreatmentSensitivityFeatureExtractor().extract(ctx);
expect(sensitivity?.data?.types).toEqual(['dental_fear', 'needle_faint']);
expect(sensitivity?.evidence.factIds).toEqual(['f']);
const attention = new SpecialAttentionFeatureExtractor().extract(ctx);
expect(attention?.data?.types).toEqual(['cannot_wait']);
expect(attention?.evidence.factIds).toEqual(['f']);
});
test('缺少旧字段或显式空值不能改读同名扩展而产生新标签', () => {
const ctx = context({ illness_desc:null,illness_description:'晕针,不能等',diagnosis:'恐惧',procedure:'晕血' });
expect(new TreatmentSensitivityFeatureExtractor().extract(ctx)).toBeNull();
expect(new SpecialAttentionFeatureExtractor().extract(ctx)).toBeNull();
});
test('病历 JSON 字符串保持既有关键词扫描,不做医学含义清洗', () => {
const ctx = context({ pre_illness: '[{"message":"看牙紧张"}]', diagnosis_text:'赶时间' });
expect(new TreatmentSensitivityFeatureExtractor().extract(ctx)?.data?.types).toEqual(['dental_fear']);
expect(new SpecialAttentionFeatureExtractor().extract(ctx)?.data?.types).toEqual(['cannot_wait']);
});
test('无病历值维持无标签;不改变预约时间边界', () => {
const ctx = context(null);
expect(new TreatmentSensitivityFeatureExtractor().extract(ctx)).toBeNull();
const extractor = new SpecialAttentionFeatureExtractor();
expect(extractor.extract(ctx)).toBeNull();
expect(extractor.nextBoundaryAt(ctx)).toBeNull();
});
});
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