Commit aa24154c by luoqi

Merge fact content compatibility readers into test

parents ffce47db 1513cef0
Pipeline #3874 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());
/** 仓库外真实样本的本地离线评估;不启动应用、不写业务库、不调用 LLM。 */
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import Module from 'node:module';
import ts from 'typescript';
import { Prisma, PrismaClient } from '@prisma/client';
import { businessDate } from '@pac/utils/business-date';
import { readFactContent } from '../src/modules/facts/read-model/read-content';
import { normalizedContentSql } from '../src/modules/facts/read-model/read-content-sql';
import { lookupContentNames } from '../src/modules/open/lookup-content';
import { toLookupFact } from '../src/modules/open/lookup-fact';
import { visitFactsOf, visitSpanOf } from '../src/modules/persona/features/visit-facts';
import type {
FeatureExtractorContext,
ActiveFact,
} from '../src/modules/persona/features/feature.interface';
const baseRef = process.env.PAC_FACT_READ_BASE_REF || 'HEAD';
const file = process.argv[2];
if (!file) throw new Error('provide external sample JSON path');
const snapshot = JSON.parse(readFileSync(file, 'utf8'));
const source = execFileSync(
'git',
['show', `${baseRef}:apps/pac-service/src/modules/open/lookup-content.ts`],
{ encoding: 'utf8' },
);
// 以原模块目录解析依赖;只编译当前 Git 基线的可信代码,不执行样本内容。
const filename = resolve(__dirname, '../src/modules/open/lookup-content.baseline.cjs');
const baselineModule = new Module(filename, module);
(baselineModule as any).filename = filename;
(baselineModule as any).paths = (Module as any)._nodeModulePaths(
resolve(__dirname, '../src/modules/open'),
);
(baselineModule as any)._compile(
ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText,
filename,
);
const factSource = execFileSync(
'git',
['show', `${baseRef}:apps/pac-service/src/modules/open/lookup-fact.ts`],
{ encoding: 'utf8' },
);
const factModule = new Module(
resolve(__dirname, '../src/modules/open/lookup-fact.baseline.cjs'),
module,
);
(factModule as any).filename = resolve(__dirname, '../src/modules/open/lookup-fact.baseline.cjs');
(factModule as any).paths = (baselineModule as any).paths;
const originalRequire = factModule.require.bind(factModule);
factModule.require = ((id: string) =>
id === './lookup-content'
? baselineModule.exports
: originalRequire(id)) as typeof factModule.require;
(factModule as any)._compile(
ts.transpileModule(factSource, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText,
(factModule as any).filename,
);
const baselineFact = factModule.exports.toLookupFact as typeof toLookupFact;
const report: any = {
startedAt: snapshot.startedAt,
finishedAt: snapshot.finishedAt,
selection: snapshot.selection,
baseline: execFileSync('git', ['rev-parse', baseRef], { encoding: 'utf8' }).trim(),
hosts: {},
sqlParity: { rows: 0, differences: 0, durationMs: 0 },
publicAliasMapUnchanged: isDeepStrictEqual(
lookupContentNames,
baselineModule.exports.lookupContentNames,
),
};
const rows: Array<{ type: string; content: unknown }> = [];
const now = new Date(snapshot.startedAt);
const date = (value: string | null) => (value ? new Date(value) : null);
const day = (value: Date | null) => (value ? businessDate(value) : null);
function capture(fn: typeof toLookupFact, row: any, host: string) {
try {
return {
data: fn(
{
...row,
host_name: host,
occurred_at: date(row.occurred_at),
planned_for: date(row.planned_for),
valid_until: date(row.valid_until),
},
null,
),
};
} catch (e) {
return { error: (e as Error).name, message: (e as Error).message };
}
}
(async () => {
for (const sample of snapshot.samples) {
const h = (report.hosts[sample.host] ??= {
patients: 0,
factTruncatedPatients: 0,
visitTruncatedPatients: 0,
facts: 0,
types: {},
aliasChanges: 0,
conflicts: 0,
losslessFailures: 0,
apiDifferences: 0,
apiExistingErrors: 0,
activeFacts: 0,
fullHistoryPatients: 0,
emrOnlyPatients: 0,
profileVsFeatureLastDayDifferent: 0,
profileVsFeatureDayCountDifferent: 0,
storedLastVisitVsCurrentDerivationDifferent: 0,
lastDayMultipleEmrDoctors: 0,
anyDayMultipleEmrDoctors: 0,
storedPreferredDoctorPresent: 0,
preferredMatchesEmrFrequency: 0,
encounters: 0,
encounterDoctorIdPresent: 0,
encounterDoctorNamePresent: 0,
visits: 0,
futureVisits: 0,
pastUnvisited: 0,
statusVisitedButNotReachedText: 0,
patientsWithFutureCrowdingOutPast: 0,
top3PastUnvisited: 0,
examples: [],
apiDurationMs: 0,
});
h.patients++;
if (sample.factsTruncated) h.factTruncatedPatients++;
if (sample.visitsTruncated) h.visitTruncatedPatients++;
for (const fact of sample.facts) {
h.facts++;
h.types[fact.type] = (h.types[fact.type] ?? 0) + 1;
const normalized = readFactContent(fact.type, fact.content);
if (!isDeepStrictEqual(normalized.content, fact.content)) h.aliasChanges++;
if (!isDeepStrictEqual(normalized.original, fact.content)) h.losslessFailures++;
h.conflicts += normalized.conflicts.length;
const start = performance.now();
const oldDto = capture(baselineFact, fact, sample.host),
nextDto = capture(toLookupFact, fact, sample.host);
h.apiDurationMs += performance.now() - start;
if ('error' in oldDto) h.apiExistingErrors++;
if (!isDeepStrictEqual(oldDto, nextDto)) h.apiDifferences++;
rows.push({ type: fact.type, content: fact.content });
}
const facts = sample.facts
.filter((f: any) => ['active', 'fulfilled'].includes(f.status))
.map((f: any) => ({
id: f.fact_id,
subjectId: f.subject_id,
type: f.type,
kind: f.kind,
status: f.status,
clinicId: f.clinic_id,
occurredAt: date(f.occurred_at),
plannedFor: date(f.planned_for),
content: f.content,
})) as ActiveFact[];
h.activeFacts += facts.length;
const factsByType = new Map<string, ActiveFact[]>();
for (const fact of facts)
factsByType.set(fact.type, [...(factsByType.get(fact.type) ?? []), fact]);
const emrs = factsByType.get('emr_record') ?? [];
const narrow = visitSpanOf(visitFactsOf({ factsByType } as FeatureExtractorContext));
const broad = visitSpanOf([
...visitFactsOf({ factsByType } as FeatureExtractorContext),
...emrs,
]);
const reasons: string[] = [];
if (!sample.factsTruncated) {
h.fullHistoryPatients++;
if (emrs.length && !narrow.last) {
h.emrOnlyPatients++;
reasons.push('emr_without_visit');
}
if (day(narrow.last) !== day(broad.last)) {
h.profileVsFeatureLastDayDifferent++;
reasons.push('last_day_diff');
}
if (narrow.dayCount !== broad.dayCount) h.profileVsFeatureDayCountDifferent++;
if (sample.patient.last_visit_at?.slice(0, 10) !== (day(broad.last) ?? undefined))
h.storedLastVisitVsCurrentDerivationDifferent++;
const counts = new Map<string, { count: number; last: number }>();
for (const emr of emrs) {
const name = String((emr.content as any)?.doctor_name ?? '').trim();
if (!name) continue;
const old = counts.get(name) ?? { count: 0, last: 0 };
counts.set(name, {
count: old.count + 1,
last: Math.max(old.last, emr.occurredAt?.getTime() ?? 0),
});
}
const ranking = [...counts.entries()].sort(
(a, b) => b[1].count - a[1].count || b[1].last - a[1].last,
);
if (sample.patient.preferred_doctor) {
h.storedPreferredDoctorPresent++;
// 并列不武断选一个;统计保存值是否属于最高频且最近的候选。
const best = ranking[0]?.[1];
if (
best &&
ranking.some(
([n, v]) =>
n === sample.patient.preferred_doctor &&
v.count === best.count &&
v.last === best.last,
)
)
h.preferredMatchesEmrFrequency++;
}
const byDay = new Map<string, Set<string>>();
for (const emr of emrs) {
const name = String((emr.content as any)?.doctor_name ?? '').trim();
if (!emr.occurredAt || !name) continue;
const key = businessDate(emr.occurredAt);
byDay.set(key, new Set([...(byDay.get(key) ?? []), name]));
}
if ([...byDay.values()].some((names) => names.size > 1)) h.anyDayMultipleEmrDoctors++;
const doctors = new Set(
emrs
.filter((f) => f.occurredAt && day(f.occurredAt) === day(broad.last))
.map((f) => String((f.content as any)?.doctor_name ?? '').trim())
.filter(Boolean),
);
if (doctors.size > 1) {
h.lastDayMultipleEmrDoctors++;
reasons.push('multiple_emr_doctors_same_day');
}
}
for (const f of factsByType.get('encounter_record') ?? []) {
h.encounters++;
if ((f.content as any)?.doctor_id) h.encounterDoctorIdPresent++;
if ((f.content as any)?.doctor_name) h.encounterDoctorNamePresent++;
}
const past = sample.visits.filter((v: any) => v.task_date && new Date(v.task_date) <= now);
const top = sample.visits
.slice(0, 3)
.filter((v: any) => v.task_date && new Date(v.task_date) <= now);
h.visits += sample.visits.length;
h.futureVisits += sample.visits.filter(
(v: any) => v.task_date && new Date(v.task_date) > now,
).length;
h.pastUnvisited += past.filter((v: any) => v.status === '未回访').length;
h.top3PastUnvisited += top.filter((v: any) => v.status === '未回访').length;
h.statusVisitedButNotReachedText += sample.visits.filter(
(v: any) => v.status === '已回访' && /未接|无人接|未接通|未回复/.test(v.result ?? ''),
).length;
if (top.length < Math.min(3, past.length)) {
h.patientsWithFutureCrowdingOutPast++;
reasons.push('future_crowds_past');
}
if (reasons.length && h.examples.length < 5)
h.examples.push({
patientHash: createHash('sha256').update(sample.patient.id).digest('hex').slice(0, 12),
reasons,
});
}
const url = process.env.PAC_FACT_READ_DATABASE_URL;
if (!url || !['127.0.0.1', 'localhost'].includes(new URL(url).hostname))
throw new Error('local read-only SQL parity database required');
const db = new PrismaClient({ datasources: { db: { url } } });
try {
const start = performance.now();
for (let offset = 0; offset < rows.length; offset += 100) {
const batch = rows.slice(offset, offset + 100);
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(batch.map((r, n) => Prisma.sql`(${n}::int,${r.type}::text,${JSON.stringify(r.content)}::jsonb)`))}) f(n,type,content) ORDER BY n`);
});
for (const r of result) {
report.sqlParity.rows++;
if (
!isDeepStrictEqual(
r.content,
readFactContent(batch[r.n]!.type, batch[r.n]!.content).content,
)
)
report.sqlParity.differences++;
}
}
report.sqlParity.durationMs = Math.round(performance.now() - start);
} finally {
await db.$disconnect();
}
for (const h of Object.values(report.hosts) as any[])
h.apiDurationMs = Math.round(h.apiDurationMs);
console.log(JSON.stringify(report, null, 2));
if (
Object.values(report.hosts).some((h: any) => h.apiDifferences || h.losslessFailures) ||
report.sqlParity.differences ||
!report.publicAliasMapUnchanged
)
process.exitCode = 1;
})().catch((e) => {
console.error(e.code || e.name);
process.exitCode = 1;
});
/** 本地差分验收:可信 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 { businessDate } from '@pac/utils/business-date';
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
...@@ -567,7 +568,7 @@ export class PlanScriptOrchestrator { ...@@ -567,7 +568,7 @@ export class PlanScriptOrchestrator {
.filter((f) => f.type === 'emr_record' && f.occurredAt) .filter((f) => f.type === 'emr_record' && f.occurredAt)
.sort((a, b) => b.occurredAt!.getTime() - a.occurredAt!.getTime())[0]; .sort((a, b) => b.occurredAt!.getTime() - a.occurredAt!.getTime())[0];
const lastChiefComplaint = 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; null;
// 给每条 reason 查"触发该诊断/建议的医生 + 日期" // 给每条 reason 查"触发该诊断/建议的医生 + 日期"
...@@ -930,18 +931,18 @@ function buildMedicalRecord( ...@@ -930,18 +931,18 @@ function buildMedicalRecord(
return { return {
date: emr.occurredAt ? businessDate(emr.occurredAt) : null, date: emr.occurredAt ? businessDate(emr.occurredAt) : null,
doctorName: str(c.doctor_name), doctorName: str(c.doctor_name),
chiefComplaint: str(c.illness_desc), chiefComplaint: str(storedContentField('emr_record', c, 'illness_description')),
presentIllness: str(c.pre_illness), presentIllness: str(storedContentField('emr_record', c, 'present_illness')),
pastHistory: str(c.past_history), pastHistory: str(c.past_history),
generalCondition: str(c.general_condition), generalCondition: str(c.general_condition),
examFindings: readEmrNarrative(c.exam_findings), examFindings: readEmrNarrative(c.exam_findings),
auxiliaryFindings: readEmrNarrative(c.aux_exam_findings), auxiliaryFindings: readEmrNarrative(storedContentField('emr_record', c, 'auxiliary_exam_findings')),
diagnoses, diagnoses,
disposals: readEmrNarrative(c.disposal), disposals: readEmrNarrative(storedContentField('emr_record', c, 'procedure')),
treatmentNotes: readEmrNarrative(c.treatment_notes), treatmentNotes: readEmrNarrative(storedContentField('emr_record', c, 'treatment')),
doctorAdvice: str(c.doctor_advice), doctorAdvice: str(c.doctor_advice),
treatmentPlanText: emrNarrativeText(c.treatment_plan), treatmentPlanText: emrNarrativeText(c.treatment_plan),
diagnosisText: str(c.diagnosis_text), diagnosisText: str(storedContentField('emr_record', c, 'diagnosis')),
recommendations, recommendations,
plannedTreatments, 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 { ...@@ -12,6 +12,7 @@ import {
import { isRecord, lookupNarrative, LookupContentError } from './lookup-narrative'; import { isRecord, lookupNarrative, LookupContentError } from './lookup-narrative';
import { lookupRelations } from './lookup-relations'; import { lookupRelations } from './lookup-relations';
import { redactJson } from './open-redact'; import { redactJson } from './open-redact';
import { contentAliases } from '../facts/read-model/content-aliases';
/** 新增查询专属字段投影;所有嵌套扩展也按白名单。 */ /** 新增查询专属字段投影;所有嵌套扩展也按白名单。 */
const fields: Record<string, string> = { const fields: Record<string, string> = {
consultation_record: consultation_record:
...@@ -70,23 +71,17 @@ export const lookupContentNames: Record<string, Record<string, string>> = Object ...@@ -70,23 +71,17 @@ export const lookupContentNames: Record<string, Record<string, string>> = Object
...Object.fromEntries(names.split(' ').map((key) => [key, camel(key)])), ...Object.fromEntries(names.split(' ').map((key) => [key, camel(key)])),
doctor_id: 'doctorExternalId', doctor_id: 'doctorExternalId',
...(type === 'emr_record' ? narrativeNames : {}), ...(type === 'emr_record' ? narrativeNames : {}),
...(type === 'image_record' ? { file_url: 'fileLocation' } : {}), // 与内部兼容读模型共用同义字段表。这里只复用旧键的公开名称,
...(type === 'treatment_record' ? { treat_stages: 'stages' } : {}), // ⛔ 不自动接受新的内部键,避免存量同名扩展改变已发布响应。
...(type === 'appointment_record' ...Object.fromEntries(
? { complaint_text: 'chiefComplaint', complaint_category: 'visitReasonCategory' } Object.entries(contentAliases(type)).map(([source, target]) => [source, camel(target)]),
: {}), ),
...(ownIds[type] ? { [ownIds[type]]: 'externalId' } : {}), ...(ownIds[type] ? { [ownIds[type]]: 'externalId' } : {}),
...(type === 'diagnosis_record' ? { name_zh: 'name' } : {}),
...(type === 'recommendation_record' ...(type === 'recommendation_record'
? { ? {
extracted_by: 'extractionMethod',
name: 'recommendation',
doctor_suggested_at: 'suggestedAt', 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 { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType } from '@pac/types'; import { PersonaFeatureKey, FactType } from '@pac/types';
import type { import type {
...@@ -92,7 +93,11 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor { ...@@ -92,7 +93,11 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor {
let waitHit = SpecialAttentionFeatureExtractor.WAIT_KW.some((k) => profileHay.includes(k)); let waitHit = SpecialAttentionFeatureExtractor.WAIT_KW.some((k) => profileHay.includes(k));
for (const f of ctx.factsByType.get(FactType.EMR_RECORD) ?? []) { for (const f of ctx.factsByType.get(FactType.EMR_RECORD) ?? []) {
const c = (f.content ?? {}) as Record<string, unknown>; 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))) { if (SpecialAttentionFeatureExtractor.WAIT_KW.some((k) => text.includes(k))) {
waitHit = true; waitHit = true;
evidenceIds.add(f.id); evidenceIds.add(f.id);
......
import { storedContentField } from '../../facts/read-model/stored-content-field';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType } from '@pac/types'; import { PersonaFeatureKey, FactType } from '@pac/types';
import type { import type {
...@@ -46,8 +47,12 @@ export class TreatmentSensitivityFeatureExtractor implements FeatureExtractor { ...@@ -46,8 +47,12 @@ export class TreatmentSensitivityFeatureExtractor implements FeatureExtractor {
return { return {
id: f.id, id: f.id,
text: [ text: [
c.illness_desc, c.pre_illness, c.past_history, c.general_condition, storedContentField('emr_record', c, 'illness_description'),
c.exam_findings, c.diagnosis_text, c.treatment_plan, c.doctor_advice, c.disposal, 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(' '), ].map((v) => String(v ?? '')).join(' '),
}; };
}); });
......
import { storedContentTextSql } from '../../../facts/read-model/stored-content-field';
import { businessDate } from '@pac/utils/business-date'; import { businessDate } from '@pac/utils/business-date';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
...@@ -410,8 +411,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -410,8 +411,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
sig.content->>'code' AS signal_code, sig.content->>'code' AS signal_code,
-- 诊断原文词(K00 主类目细分用:滞留→外科 / 早失→正畸 / 先天缺→修复…)。 -- 诊断原文词(K00 主类目细分用:滞留→外科 / 早失→正畸 / 先天缺→修复…)。
-- 纯投影,不进任何 WHERE —— 返回行集与加它之前逐行相同。 -- 纯投影,不进任何 WHERE —— 返回行集与加它之前逐行相同。
sig.content->>'name_zh' AS signal_name_zh, ${storedContentTextSql('sig', 'diagnosis_record', 'name')} AS signal_name_zh,
sig.content->>'extracted_by' AS extracted_by, ${storedContentTextSql('sig', 'recommendation_record', 'extraction_method')} AS extracted_by,
sig.content->>'confidence' AS confidence, sig.content->>'confidence' AS confidence,
sig.content->>'code_source' AS code_source, -- 置信度因子:std_code/name_map=医生 / image_ai / null sig.content->>'code_source' AS code_source, -- 置信度因子:std_code/name_map=医生 / image_ai / null
sig.clinic_id AS clinic_id, 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();
});
});
# 内部事实收敛 · 第一阶段本地验收
2026-10-23 · `codex/fact-model-convergence` · 基线 `15d08d4`
**已完成无损字段兼容读模型,并接入两项画像、AI 病历组装和召回字段投影。消费者使用规范名称,按明确的旧存储契约取值;接口响应与业务结果保持兼容。医生角色、末诊统计、回访历史仅评估,未纠错。**
## 1 本轮改动
统一 8 类事实的 15 条同义字段映射。旧键、新键可以进入内部读模型;输出规范化内容,同时保留完整原文和冲突信息。
| 类型 | 无损改名 |
| --- | --- |
| 咨询、投诉 | content → description |
| 诊断 | name_zh → name |
| 治疗 | treat_stages → stages |
| 建议 | name → recommendation;extracted_by → extraction_method |
| 病历 | illness_desc → illness_description;pre_illness → present_illness;aux_exam_findings → auxiliary_exam_findings;diagnosis_text → diagnosis;treatment_notes → treatment;disposal → procedure |
| 影像 | file_url → file_location |
| 预约 | complaint_category → visit_reason_category;complaint_text → chief_complaint |
- 空值、未知扩展、数组、JSON 正文和不确定性原样保留;没有解析、概括或填默认值。
- 新旧键同值可合并;不同值保留两键并报告,null 不被另一字段兜底。
- 不更改来源/关联 ID、医生角色、金额、时间、kind/status、事实身份和版本;不删除未知类型。
- SQL JSONB 表达式和 TypeScript 使用同一别名表,已做本地 PostgreSQL 对照;没有创建数据库函数、视图或迁移。
- 现有开放接口仅复用旧字段到既有公开名称的规则,映射表与基线完全一致。规范化内部键不自动成为公开别名,避免存量同名扩展改变响应;原白名单、关联证据校验、脱敏和时间解释不变。
本轮接入采用明确的旧存储读取器 `storedContentField`:调用方使用规范名称,读取器从同一别名表定位原字段。旧字段缺失仍为缺失、null 仍为 null;同名扩展不会被当作新版正文兜底。完整规范化视图仍单独保留冲突和原文,不能未经检查直接替换原 content。
| 已接入消费者 | 范围 | 保持兼容的方式 |
| --- | --- | --- |
| 治疗敏感、特别关注画像 | 病历主诉、现病史、诊断、处置字段 | 规则、关键词、事实证据及预约时间窗口不变 |
| AI 话术上下文 | 最近主诉与病历正文组装 | 输入字段、JSON 文本解析、诊断及治疗关联逻辑不变;不改提示词 |
| 潜在治疗召回 | 诊断名称、建议提取方式两项 SQL 投影 | 共用字段表生成原来的单字段 SQL;不加入 JSON 聚合、子查询或筛选条件 |
| 开放接口 | 旧键到公开名称 | 原白名单、冲突处理、关联和脱敏不变 |
**这是有限范围的消费者接入,不是全系统存储格式切换。** 治疗链、其他画像、回访历史、旧接口及 writer 未切换。没有开启新格式写入,也没有存储版本自动识别;后续切换仍需逐项验证。
## 2 真实样本与兼容验证
2026-10-23 北京时间 22:53、22:59,从测试环境分别读取两组样本;每条查询 READ ONLY、8 秒限时、串行执行。没有访问生产,没有调用写接口、LLM 或重算任务。
| 样本 | FridaySaaS | 5i5ya | 选择方式 |
| --- | ---: | ---: | --- |
| 主样本患者 | 40 | 40 | 主档有末诊或医生派生值,按患者 UUID 取前 40 位 |
| 主样本当前事实 | 466 | 1,758 | 完整身份选版本头,再按用途区分业务状态 |
| 回访专项患者 | 20 | 20 | 每宿主最多前 400 条回访中的 20 位患者 |
| 回访专项当前事实 | 783 | 2,245 | 同上 |
合计 **120 位不同患者、5,252 条不同事实**;没有触发每患者 501 条事实、101 条回访的截断上限。覆盖咨询、到诊、诊断、治疗、建议、病历、影像、预约、收款和退费十类。投诉的改名使用契约样例验证,本轮真实样本未覆盖投诉。
| 检查 | 结果 |
| --- | --- |
| 规范化原文保留、新旧键冲突 | 原文差异 0;真实样本冲突 0(冲突分支另有测试) |
| SQL / TypeScript 规范化结果 | 5,252 条,差异 0 |
| 基线 / 当前完整 fact DTO | 5,252 条,差异 0;两侧投影错误均为 0 |
| 公开字段别名映射表 | 与 Git 基线完全相同 |
| 两项画像消费者 | 120 人 × 2 项,共 240 次计算,差异 0 |
| AI 病历上下文组装 | 555 份有效病历,差异 0,两侧组装错误均为 0 |
| 召回 SQL 及绑定参数 | 11 个诊断组 × 2 种查询实现 × 3 种患者范围,共 66 组,完全一致 |
消费者对照固定取数时刻;只执行纯画像特征、病历组装和 SQL 构造,不执行画像持久化或召回查询。两项画像对照使用事实输入、空主档附加信息;主档分支本轮未改。召回 SQL 文本和参数完全一致,因此本轮没有引入查询计划变化,不代表新 JSONB 聚合方案已通过生产性能验收。
接口对照在本地运行基线及当前 `toLookupFact`,包含 content 与外层时间输出。本轮没有导出关联流水证据;可靠关联分支用现有契约测试覆盖,不能据此声称逐条完成了线上鉴权、签名 URL 或宿主关联验证。
数据为定向样本,不代表品牌、诊所或全库分布。不同查询不在一个跨全部患者的数据库快照中;结果以取数窗口解释。原始临床数据仅存仓库外权限受限目录 `/tmp/pac-fact-read-review-20261023/`,不提交;本文件只留汇总。
## 3 三项独立影响评估
### 医生角色
- 120 位患者中,99 位保存了 preferredDoctor;这 99 位均可对应到病历中最高频、同频时最近的医生候选。结合代码,这支持“常见病历医生”的来源解释,不能证明患者明确偏好。
- 535 条有效到诊记录均有 doctor_id、均无 doctor_name;当前映射取预约医生 ID。不能因为 ID 有值就将其定义为实际接诊医生。
- 两组样本中共 3 位患者存在同一业务日多位病历医生。样本中未出现在末诊当天,但说明“同日”不是可靠的单次就诊关联。
影响:字段展示、医生关系分析、AI 说法,以及任何把偏好/实际医生作为筛选条件的消费者。**本轮不改 doctor 字段及 preferredDoctor 算法。** 下一步应分别命名“预约医生”“病历医生”“患者明确偏好”,实际接诊医生仍需来源证据。
### 末诊统计
| 指标 | 主样本 Friday(40 人) | 主样本 5i5ya(40 人) | 回访专项 Friday(20 人) | 回访专项 5i5ya(20 人) |
| --- | ---: | ---: | ---: | ---: |
| 主档派生口径与画像特征口径的末诊业务日不同 | 11 | 9 | 0 | 5 |
| 两种口径的到诊天数不同 | 11 | 18 | 0 | 12 |
| 有病历、无窄口径到诊记录 | 10 | 0 | 0 | 0 |
比较的是代码现有两种口径:主档派生使用“到诊/实际治疗/挂号 + 病历”,部分画像使用前者,不加入病历。不是假设将来某种定义已经正确。
另有 13 位 5i5ya 样本的已存主档末诊日与按当前 main 代码重算结果不同。这可能涉及历史快照、水位、时间数据或代码版本,不能都归因于本轮讨论的口径差异;未重算或覆盖这些值。
影响:RFM、生命周期、紧迫度、末诊展示及依赖这些值的召回筛选。不能简单加上全部病历:病历日期也可能是补录或文档活动日期。下一步需要分别确定“证实到诊”和“临床记录活动”,再评估标签/计划变化;本轮没有模拟全量计划重算,也没有调整阈值。
### 回访历史
两组样本共读取 484 条回访记录:
| 观察 | 数量 | 能说明什么 |
| --- | ---: | --- |
| 未来任务日期 | 29 条 | 排程不是实际联系时间 |
| 任务日期已过但状态为未回访 | 131 条 | 日期过滤不能证明已联系 |
| 状态已回访、结果含未接/未回复文字 | 20 条候选 | 状态不能单独证明已接通;具体文本仍需逐条解释 |
| 未来任务挤掉历史前三条 | 10 位患者,均为 5i5ya | 先 LIMIT 3 再过滤未来日期会丢历史上下文;主样本 4 位、专项样本 6 位 |
筛选模拟沿现有日期判断,并加 ID 作为相同日期的稳定排序;这不是实际线上某次 AI 回答的复现,也没有断言 AI 对每条记录都说错。Friday 主样本没有回访记录,专项样本则有 30 条,不能由前一组推断 Friday 未接回访。
影响:患者详情、AI 简报、话术中的历史联系依据。拟另行修正筛选顺序与文案事件角色;实际联系时间缺失时保持未知。**本轮不改历史筛选、不重写来源状态、不合并宿主回访与 PAC 执行。**
## 4 本地验证与后续边界
- 服务及测试 TypeScript 检查通过;离线评估脚本单独做类型检查。
- 兼容读、消费者回归和只读 PostgreSQL,以及原开放查询、版本选择、画像时间边界、召回相关测试统一复验;最终 **315 项通过、8 项既有集成测试跳过**,15 个测试套件通过。
- 另 8 项需独立集成数据库的既有测试未启用,不计入通过数。
- 不启动应用服务;不修改 Prisma schema、摄入映射、parser、writer、画像/召回业务规则、执行/回访流程、数据库或部署配置。
- 未提交、未合并、未部署;主目录及原工作区未提交文件未改动,本地服务未重启。
下一阶段先审核第 3 节业务口径,再决定语义纠错;其余消费者及新版存储写入需分别验收。未知扩展、内部元信息及 SQL 性能仍需保持兼容;本轮 VALUES 对照通过不能代替生产规模查询计划与性能验证。
实现入口:[内部读模型](../../apps/pac-service/src/modules/facts/read-model/README.md) · [定向只读取样](../../apps/pac-service/scripts/audit-fact-read-sample.cjs) · [离线评估](../../apps/pac-service/scripts/evaluate-fact-read.ts) · [消费者差分验收](../../apps/pac-service/scripts/verify-fact-read-consumers.ts) · [完整收敛方案](2026-10-23-internal-fact-model-convergence-review.md)
# PAC 内部事实模型收敛方案
审核稿 · 2026-10-23 · 补充回访与患者实体覆盖
**建议保留当前 11 类业务事实,纠正到诊、关联、时间和证据性质;六类预留模型停止扩展,保留历史兼容。先统一消费者读法,再切换写入,历史数据另行分批治理。**
本稿是目标方案,不是当前实现说明。11 类保留、六类暂停仅指事实类型,不涵盖患者主档、宿主回访、PAC 执行等独立实体;这些实体继续按各自职责保留。
已从本地 main(15d08d4)建立独立分支 `codex/fact-model-convergence`。本稿主体保留目标方案;第一阶段已在本分支实现无损字段读模型及本地对照验证,详见[第一阶段验收](2026-10-23-fact-read-compatibility-local.md)。尚未切换召回/画像消费者,未改摄入、数据库、历史数据或部署。原稿基于 `codex/open-persona-image-access`,本次新增内容已核对 main 的存储、映射和消费者;原功能分支尚有开放接口与分配逻辑差异,实施前需重新核对合并状态,不整批带入。六类预留模型此前核查无实际事实,本次未重新查测试、生产;停用实施前必须重新核对存量、入口和消费者。
## 1 审核结论
| 决策 | 本稿建议 |
| --- | --- |
| PAC 是否需要完整复制宿主实体 | 不需要。来源病历可拆出明确的诊断、治疗、建议;不要求宿主有独立单据,不补造接诊或其他父实体 |
| 哪些必须修 | 病历 ID 冒充接诊 ID、自身 ID 冒充关联、结算编号冒充单据 ID、预约到诊时间冒充预约发生时间、制定时间冒充执行计划时间 |
| 哪些不为统一而改 | 内部 snake_case 与接口 camelCase;事实身份、已有类型编码、事实版本机制、原始证据留存 |
| encounter 怎么定 | 当前两宿主路线定义为“到诊记录”,不宣称医生已开始接诊。编码仍为 encounter_record |
| 医嘱怎么定 | 病历 doctor_advice 继续表示医嘱。当前 order_record 偏费用单据,停止作为成熟“医嘱模型”扩展,待真实来源验证 |
| 影像分析怎么定 | 当前路线继续使用 image.content.analysis + diagnosis_record 信号;不新增重复的独立分析事实,不把影像 AI 发现当成医生确诊 |
| 无数据类型怎么办 | 暂停新接入与对外承诺,保留识别和历史读取;核实无依赖后才删除死实现,不立即删枚举、表中数据或旧接口分支 |
| 回访放在哪里 | 宿主回访保留 patient_return_visits;PAC 执行保留 plan_executions。可以统一展示,不合表、不复制为重复事实 |
| 历史数据是否立即重写 | 否。旧结构先兼容读取,可可靠修复的分批处理;证据不足保留未知,不按日期、姓名或字符串相似度补关联 |
| 开放接口是否一起变 | 不变更已发布字段、类型编码、响应结构。内部标准经现有接口适配层输出;必要的纠错有独立差异清单 |
## 2 模型边界
PAC 事实是“来源有证据支持的患者业务信息”,不要求一条事实对应宿主一张独立表。
允许从一份病历拆出多条有明确依据的诊断、治疗、建议;拆出的身份可以由 PAC 稳定生成,但不能把生成的身份称为宿主原生单据 ID。采集、重试和重放必须保持同一身份,不能依赖数组临时下标或本次时间生成新主体。
下游需要但来源缺少的信息,应作为消费能力限制处理。不能因为规则需要接诊 ID、医生、时间或金额,就填入相近但含义不同的值。算法若确需代理指标,必须在该算法中明确其依据、适用范围与降级结果,不写成来源已确认事实。
### 2.1 保留和重定的 11 类
| 类型编码 | 目标中文名 | 表达什么 | 不表达什么 |
| --- | --- | --- | --- |
| consultation_record | 咨询记录 | 患者咨询内容、意向及相关人员信息 | 医生诊断、已制定治疗计划 |
| encounter_record | 到诊记录 | 来源明确支持的一次患者到诊活动 | 自动等同医生接诊开始、完整就诊会话、全部临床记录的必需父实体 |
| diagnosis_record | 诊断与病症发现记录 | 来源记载的病症或影像发现,保留证据性质 | 统一认定为医生确诊;代码映射成功不等于诊断获确认 |
| treatment_record | 治疗与治疗计划记录 | 已执行治疗或明确的治疗安排,以 kind 区分 | 付费即已治疗;建议即计划;计划制定即已预约执行 |
| recommendation_record | 医生建议记录 | 来源医生明确提出的建议,或从其原文提取的建议 | PAC 自己生成的新建议;必然存在执行日期的治疗安排 |
| emr_record | 病历记录 | 病情、检查、诊断、治疗、处置、医嘱等文档内容 | 文档创建时间自动等于全部诊疗动作时间 |
| image_record | 影像记录 | 影像元信息、文件引用及能可靠关联的分析快照 | 文件标识等于长期访问地址;有影像等于有分析 |
| appointment_record | 预约记录 | 约定来诊安排及履约状态 | 实际到诊事件本身,或医生已开始接诊 |
| payment_record | 收款记录 | 一次来源结算中的实收及相关金额信息 | 已完成治疗;应收等于实收;充值等于医疗消费 |
| refund_record | 退费记录 | 实际退费及可靠的原收款关联 | 自动撤销原治疗或诊断 |
| complaint_record | 投诉记录 | 投诉内容、分类、处理进度及跟进事件;保留来源反馈性质 | 好评等所有反馈均算有效投诉;投诉自动设免打扰 |
`diagnosis_record` 保留编码以复用病症码、牙位和现有信号处理,但中文展示按来源区分“诊断”或“影像发现”。本轮不另造一套病症事实类型。
### 2.2 停止扩展的六类
| 类型 | 处理 | 再启用的条件 |
| --- | --- | --- |
| visit_registration_record | 停止作为必接类型,保留旧读法 | 来源有独立挂号身份及业务用途;不能直接拿预约或到诊复制一份 |
| visit_reception_record | 同上 | 来源有独立接待登记及需要单独分析的流程;到诊不是再造接待记录的理由 |
| order_record | 撤回“医嘱”的确定含义,暂停新增写入路线 | 拿真实单据确认是收费单、治疗订单、处方还是申请单;身份、状态、金额和执行关系需重新审核 |
| recharge_record | 概念保留,当前不扩展 | 来源有真实储值交易,定义充值、赠送、扣款及退储值的口径;不混入医疗消费收款 |
| referral_record | 当前优先用明确推荐关系及来源聚合指标,暂停事件模型扩展 | 来源提供独立介绍事件、发生时间或归因变化,且有事件级消费者 |
| image_analysis_record | 当前美亚路线不启用,不重复生成 | 需要独立报告身份、多次分析或版本管理,且来源确实提供相应能力 |
“停用”指停止新能力建设及未经验证的入口开放,不是静默丢弃入站数据。先盘点所有宿主及入口;发现现有合法调用时保留支持,安排独立下线流程。确实不支持的新请求返回明确不支持结果并保留接入审计,不能回成功却丢数据。不能只凭没有已入库事实就认定没有调用或拒收。
检查活动本身可能有业务价值,但本轮不立即增加 inspection_record。先停止把全景片、CBCT 等检查自动当作 preventive 治疗完成;现有影像与病历检查所见保留证据。若确需独立分析无影像的检查活动,再单独定义。
## 3 通用字段定义
以下为目标内部业务契约。落库 content 使用 snake_case,应用类型及外部 DTO 可用 camelCase;命名变换由集中映射完成。外部字段范围不随内部新增字段自动扩大。
### 3.1 身份与来源
| 字段 | 定义与类型 | 处理原则 |
| --- | --- | --- |
| id | string,PAC 事实版本主键 | 保留;不能冒充跨版本业务身份 |
| subject_id | string,PAC 内部稳定主体键 | 保留现有身份算法及命名空间,本轮不重建;身份改造需独立迁移 |
| host_id / tenant_id / source_unit | string,宿主、租户及来源身份范围 | 保留;关联不能只比较一个 external_id |
| patient_id | string,已解析的 PAC 患者身份 | 不凭姓名或同日事件重配患者 |
| clinic_id | string/null,现有来源诊所标识 | 不偷换成 PAC 组织 UUID;在代码类型和文档中明确是来源 ID |
| content.external_id | string/null,本事实的来源记录或适配身份 | 统一替代各类自身 ID 字段。保留已使用的美亚命名空间键;不保证可直接调用厂商 API;源无独立标识时允许 null,不切 subject_id 猜 |
| …_external_id | string/null,所命名实体的来源引用 | 对象必须准确;emr、appointment、encounter、image、payment 不混用 |
| transaction_ids | string[],本事实绑定的来源事件证据 | 保留,可用于迁移解释;不能每次消费都扫描该患者全部原始报文寻找相似记录 |
| content_schema_version | integer,拟新增存储格式版本元数据 | 建议放事实元数据层,缺省视为旧版 1,新契约为 2;与业务 version 分开,不能用部署日期或字段缺失推断版本 |
`external_id` 兼容现有适配身份,原始宿主 ID 和 PAC 稳定主体键不是一回事。将来需要独立暴露原生厂商 ID 时另定义明确字段,不悄悄改变 external_id 的含义。
### 3.2 时间
| 字段 | 固定含义 | 空值与限制 |
| --- | --- | --- |
| occurred_at | 本类事实所表达业务事件的发生时间,具体事件见第 4 节 | 不统一改成数据库创建时间;可靠来源缺失则 null |
| planned_for | 明确约定或计划执行的目标时间 | 不用计划制定时间、建议提出时间兜底 |
| source_created_at | 承载事实的来源记录创建时间 | 诊断可继承父病历、到诊可继承父预约;不代表拆出条目首次出现或医生动作时间;不新增 basis 字段 |
| source_updated_at | 来源更新顺序依据,继续用于乱序保护 | 当前存在少量代理口径,单独登记来源规则;本轮不改变历史裁决水位,不伪称所有来源都有精确更新时间 |
| created_at / updated_at | PAC 本地存储记录时间 | 保留数据库标准列,不代替业务或来源时间 |
| patient_transactions.received_at | 既有原始事件接收落库时间 | 保留;不增加同义列,不用于补业务时间 |
| valid_from / valid_until | 事实适用窗口 | 保留现有机制;不得默认为 source_created_at 或 planned_for;改变过期规则需单独审批 |
时刻统一为带时区的输入并存 UTC;宿主裸时刻按已确认的 Asia/Shanghai 解释。只有日期的来源不得宣称精确到诊时分:沿用现有北京时间日历日口径,兼容视图依据来源契约标明日期精度,不能仅看零点推断。具体业务日期比较用北京时间;未知精度不参与分钟级先后判断。本轮不启动全库日期列改造。
### 3.3 状态、空值与内部附属信息
- `kind` 保留 actual/planned。预约已到诊时 kind=actual 的旧机制本轮不单独重构;到诊计数必须按事实类型及到诊证据,不能统计所有 actual。
- 外层 `status` 保留 active、superseded、cancelled、fulfilled、expired、invalidated 的现有行为。它混合版本和生命周期含义,本轮集中封装“当前版本”“有效业务事实”的查询,不顺手拆列或重写状态机。
- content 中的 `status` 是该业务类型的状态;`source_status` 是原始状态。两者不同,不互相假冒;来源原值不存在就留空。
- `null` 表示未知或无可靠值;`[]` 表示没有可返回条目,不推出正常、无病或从未发生。缺失金额不能默认 0;缺失布尔值不能默认 false,除非已有明确业务规则。
- 金额统一整数分,`…_cents` 明示单位;数量与金额分开。不靠 `Number(value ?? 0)` 把未知值写成零金额事实。
- title、summary 保留内部展示用途,不作为身份、关联或规则判断依据。内部 notes/staff_notes 可保留,不能因对外不公开而删除。
- 通用未知扩展不继续自由进入标准 content。原文留原始事件;现有有效扩展纳入逐类型白名单。不能直接启用 strict 导致旧业务字段无声消失。
## 4 各类型字段口径
字段表列出保留类型的目标业务字段和关键迁移。每类均适用 external_id、来源范围、空值规则。现有标准码、项目字典继续引用共享定义,不另抄一套枚举。原始证据、提取版本等内部元信息按白名单保留,不自动开放。
### 4.1 咨询
| 目标字段 | 定义与类型 | 旧字段处理 |
| --- | --- | --- |
| intents / intent_categories | string[],患者表达的意向及标准分类 | 保留;intent 拼接串从展示侧生成,不再作为第二份权威数据 |
| description | string/null,咨询正文 | content → description |
| is_first_visit | boolean/null,来源初诊标记 | 保留来源口径,不代替 PAC 计算的首次到诊 |
| channel | string/null,咨询来源渠道 | source_channel 按已确认含义迁移;不与收款渠道共享字典 |
| task_director | string/null,来源跟进人原值 | 现有原值究竟 ID 还是名称需逐宿主核实;核实前不改成 …external_id,不通过它建立人员关系 |
| doctor_external_id | string/null,来源关联医生 | doctor_id 改名,角色为咨询关联医生 |
| appointment_external_id | string/null,真实关联预约 | 仅明确引用才填 |
| occurred_at | 咨询发生时间/明确来源咨询日 | consult_date 与其同义同精度时仅保留一份;不同精度或冲突留证据供审核 |
### 4.2 到诊
| 目标字段 | 定义与类型 | 规则 |
| --- | --- | --- |
| external_id | string/null,到诊来源或适配身份 | 两宿主当前取预约 ID,可保留;类型不同不会自动视为同一事实 |
| appointment_external_id | string/null,派生到诊所依据的预约 | 只有来源路线能证实才补,不因 ID 看起来相同直接建立关系 |
| scheduled_doctor_external_id / scheduled_doctor_name | string/null,预约时医生身份及快照 | 已核实来自预约的 doctor_id/name 改归此处 |
| doctor_external_id / doctor_name | string/null,来源明确提供的实际接诊医生 | 当前路线不能用预约医生补齐,未知 null |
| chief_complaint | string/null,来源主诉 | 保留原义 |
| encounter_type / notes | string/null,来源就诊类别及一般备注 | 没提供不编造初复诊、急诊等值 |
| occurred_at | 明确实际到诊时间 | 不用预约日期、创建时间补齐 |
此类型不表达一个必需的“就诊容器”。病历可以有到诊关联,也可以没有;仅有病历不自动生成一条接诊事实。缺少可靠时刻的到诊证据仍可保留,日期统计仅在有明确业务日时参与。
### 4.3 诊断与病症发现
| 目标字段 | 定义与类型 | 旧字段处理 |
| --- | --- | --- |
| code / name | 标准病症码 string/null;来源名称 string/null | name_zh → name;保留原文,不因无码丢证据 |
| code_source | std_code/name_map/llm/image_ai/null | 继续表示编码来源;不能独自充当“医生确认”标志 |
| evidence_type | doctor_record/image_ai/unknown | 拟新增,表示证据性质。仅经来源证明填写;std_code/name_map 不能推导 doctor_record,后者也不代表已终审确诊 |
| tooth_position | string/null,牙位与牙面表达 | 保留可读原文和现有标准化,不把无法解析的整串当单颗 FDI 牙位 |
| severity / onset_date | mild/moderate/severe/null;日期 string/null | 保留;onset_date 为发病日期,不是本条记录时间 |
| doctor_external_id / doctor_name | string/null,来源诊断记录关联医生 | 不拿影像上传人当诊断医生 |
| emr_external_id / encounter_external_id | string/null,分别是病历、到诊引用 | source_encounter_external_id 按证据分流;自身 ID 兜底丢弃为 null |
| image_external_id / analysis_external_id | string/null,真实影像、独立分析引用 | 保留可靠来源;美亚无独立分析 ID 时为空 |
| clinical_signals | 结构化旁注数组,可选 | 保留 code、label、evidence、source_field、negated、severity、treatment_domains、valid_until、review_after 及内部提取溯源;source_field 指向统一后的真实字段 |
| occurred_at | 来源明确的诊断记录/分析形成时间 | 不把父病历创建时刻当成精确诊断动作时间;仅有父记录时间留 source_created_at |
诊断清洗需保留不确定性:当前名称清洗可能去掉尾部问号,目标不得把“龋齿?”处理成来源确定的“龋齿”。能从原证据恢复时恢复;不能恢复时不升级确定性。该项单独加入回归样本。
### 4.4 治疗与治疗计划
| 目标字段 | 定义与类型 | 规则 |
| --- | --- | --- |
| category / subtype | 共享治疗类别;来源项目原文 string/null | 保留;不将影像检查硬归 preventive;未知分类保留原文证据,不臆造类别 |
| status | planned/in_progress/completed/cancelled/failed | 保留现有枚举;完成必须有来源支持,不能由已收费推断 |
| tooth_position | string/null | 同诊断口径 |
| started_at / completed_at | UTC 时刻/null,明确执行开始和完成 | 两种事件不合并;与外层值相同时也不丢事件角色 |
| quantity / unit_name | number/null;string/null | 份数和单位成对解释,不默认 1 |
| stages | string[]/null,已标准化治疗步骤 | treat_stages → stages;继续共享步骤字典 |
| doctor_external_id / doctor_name | string/null,来源执行医生或计划关联医生 | 根据 kind 明确角色,保留来源证据,不从收费医生猜 |
| emr_external_id / encounter_external_id | string/null | 从混用字段按来源证据分开 |
| related_diagnosis_subject_id | string/null,PAC 内部诊断主体引用 | 仅已有可靠解析时保留,不能改名成来源 ID;不公开 |
| review_window_days | number/null,复查时间窗参数 | 保留;没有可靠起点时不据此生成绝对日期 |
| occurred_at | actual:来源支持的治疗发生时间;planned:明确计划制定时间 | 缺失留空;来源病历时间不自动变成实际治疗时刻 |
| planned_for | 明确安排的执行/复查目标时间 | 计划制定时间不得填入;没有具体日期可为空 |
“医生建议做治疗”保留为 recommendation;有明确计划内容才是 planned treatment。分析规则可把两者作为不同级别的机会信号,但不在摄入时互相冒充。
### 4.5 医生建议
| 目标字段 | 定义与类型 | 旧字段处理 |
| --- | --- | --- |
| code / recommendation | 共享建议码 string/null;医生建议原文 string/null | name → recommendation |
| extraction_method / confidence | rule/llm/doctor_input;number 0..1/null | extracted_by → extraction_method;置信度表示提取可靠度,不是诊断概率 |
| tooth_position / doctor_external_id / doctor_name | 牙位及来源建议医生信息 | 按已证实来源保留 |
| emr_external_id / encounter_external_id | 病历/到诊来源引用 | 按证据分流 |
| source_fact_subject_id | string/null,提取所据 PAC 主体 | 内部保留,不冒充宿主 ID |
| window_days | number/null,建议跟进窗口 | 不是确定预约日期 |
| occurred_at | 明确建议提出时间 | doctor_suggested_at 同义时归此;旧 planned_for 若来自提出时间,仅兼容解释为发生时间 |
| planned_for | 明确安排的后续目标时间 | 一般建议为空,不用建议提出时刻填充 |
### 4.6 病历
目标段落保留来源含义,内部不为展示丢失原结构。标准内容提供可读正文;结构化原文在绑定的原始事件中保存,读取时不对未知 JSON 擅自概括。
| 原字段 | 目标字段 | 含义,均为 string/null 的标准正文 |
| --- | --- | --- |
| illness_desc | illness_description | 病情描述,可混合主诉与现病史,不冒称纯主诉 |
| pre_illness | present_illness | 现病史 |
| past_history | past_history | 既往史 |
| general_condition | general_condition | 一般或全身情况 |
| exam_findings | exam_findings | 检查所见 |
| aux_exam_findings | auxiliary_exam_findings | 辅助检查所见 |
| diagnosis_text | diagnosis | 本份病历的诊断叙述 |
| treatment_plan | treatment_plan | 治疗计划叙述 |
| treatment_notes | treatment | 本次治疗叙述 |
| disposal | procedure | 本次处置叙述 |
| doctor_advice | doctor_advice | 医嘱,不由 order_record 替代 |
其他保留字段:external_id;doctor_external_id/name(来源病历医生);encounter_external_id(可靠到诊引用);clinical_signals(同诊断的旁注结构);quality_check.source_status/rejection_reason(来源审核状态、退回原因)。内部现有审核动作证据可以保留,但不能把“未发生退回”解释成审核通过。
occurred_at 取明确的病历业务记录日期/时间,不能无条件按 updated_at 变动;来源创建和更新独立保存。JSON 转正文保留牙位、否定、不确定性、数字和顺序;不根据片段重新生成病历摘要。无法识别的格式进入异常隔离并保留原文,不能当空病历成功落库。
### 4.7 影像及分析
| 目标字段 | 定义与类型 | 规则 |
| --- | --- | --- |
| modality | 共享影像模态枚举 | 不按文件后缀猜拍摄类型 |
| tooth_positions | string[]/null | 明确涉及的牙位,不从病症自动反推全片覆盖 |
| finding | string/null,来源描述 | 不等于最终诊断 |
| file_location / file_name / file_type | string/null,来源文件位置、名称、类型原值 | file_url → file_location;不承诺位置就是可访问 URL,不把类型原值冒充 MIME |
| file_refs[] | 文件引用数组/null | provider、connection_id(内部)、file_external_id、role、index;旧 fileId 统一为文件来源 ID;role=original/thumbnail/slice,slice 必须有 index |
| analysis | 单张影像分析快照/null | source、summary、description、analyzed_at、findings[];发现包含 name、tooth_position、code。null 表示没取得可用分析,不推导正常或完成 |
| emr_external_id / encounter_external_id | string/null | 有真实来源才关联;5i5ya 父病历分析不能复制到每张片假装逐片分析 |
| source_doctor_external_id / source_doctor_name | string/null,来源记录所带医生 | 旧 doctor_id/name 若只是父病历医生,归为来源医生,不称拍摄或解读医生;真实角色待来源明确后另定 |
| occurred_at | 明确拍摄时间 | captured_at 同义时归此;只有上传时间不能代作拍摄时刻 |
analysis.findings 与 diagnosis_record 是“分析快照”和“可计算病症信号”两种表达,同一发现不重复累计。现有美亚完整落库方式保持;重试、结果更新和撤销必须沿用各路线已验证能力,不因模型收敛擅自增加厂商不存在的状态或接口。
临时签名 URL 在访问层按权限生成,不落为长期事实,不触发事实新版本。CT 文件与切片访问方式不在本轮改造。
### 4.8 预约
| 目标字段 | 定义与类型 | 规则 |
| --- | --- | --- |
| appointment_type / resource_name | string/null,预约项目及原排班资源名 | 保留;资源不是人时不填医生 |
| doctor_external_id / doctor_name | string/null,预约医生 | 角色只表示约定医生,不表示实际执行医生 |
| visit_reason_category / chief_complaint | string/null,预约事由分类与原文 | complaint_category/text 按当前已确认含义改名,不解释成投诉 |
| duration_minutes / cancellation_reason | number/null;string/null | 预约时长与取消原因 |
| status | scheduled/rescheduled/cancelled/arrived/in_treatment/completed/no_show/walk_in/null | 保留当前标准值;来源业务状态与 PAC 到诊证据分别表达 |
| staff_notes | string/null,内部运营备注 | 内部保留,不因标准化自动对外公开 |
| occurred_at | 宿主预约创建时间 | 取不到 null;到诊由 encounter 表达 |
| planned_for | 约定到诊时间 | scheduled_at 同义时只保留外层权威值 |
| arrived_at | UTC 时刻/null,来源预约中记录的实际到诊时间 | 内部保留供迟到、履约核对;不是第二条到诊计数,不对外重复暴露 |
### 4.9 收款与退费
| 类型/字段 | 定义与类型 | 规则 |
| --- | --- | --- |
| 收款 amount_cents | integer/null,实收金额 | 未知不默认 0;旧“应收当实收”的批次另核,不按新名称自动解释 |
| receivable_cents / discount_cents | integer/null,应收与优惠金额 | 不互相替代;有明确规则才做派生计算 |
| channel | string/null,付款渠道 | 不与咨询或投诉渠道共用枚举 |
| insurance_name / card_type_name / card_name | string/null,保险与卡券来源名称 | 保留真实来源原值 |
| settlement_project | string/null,结算项目描述 | 不据此生成已完成治疗 |
| doctor_external_id | string/null,来源收费关联医生 | 不冒充执行医生 |
| encounter_external_id / order_external_id | string/null | registration_id 不能直接当预约派生 encounter ID;未知不关联 |
| 退费 amount_cents / reason | integer/null,实际退费金额;string/null 原因 | 与应退金额、退款申请区分;未知金额记录不参与金额汇总并记录质量问题 |
| 退费 payment_external_id | string/null,来源原收款记录引用 | related_payment_external_id 改名;不靠金额、日期凑配 |
| occurred_at | 收款/退费实际业务时间 | 记录更新时间不替代交易时间 |
金额未知的处理属于目标内部契约调整;对外现有金额字段若不允许 null,不能偷偷填 0,应保持既有错误/隔离约定或另行审核接口升级。不得以“兼容”为由输出错误财务含义。
### 4.10 投诉
| 目标字段 | 定义与类型 | 旧字段处理 |
| --- | --- | --- |
| number | string/null,投诉业务编号 | 与 external_id 分开 |
| description | string/null,反馈正文 | content → description |
| feedback_kind | complaint/praise/neutral/unknown | 原性质;只有 complaint 且满足有效状态才进入投诉信号 |
| status | open/in_progress/resolved/draft/unknown | 投诉处理状态,不冒充事实版本状态 |
| category | service/clinical/billing/environment/waiting/other/unknown | 标准投诉分类 |
| channel | sms_survey/hotline/third_party/sales/mini_program/unknown | 标准反馈渠道 |
| severity | low/medium/high/null | 来源明确才填,不因正文负面推严重等级 |
| settlement_number | string/null,来源结算业务编号 | related_order_external_id 经路线核验后改归此处,不生成 order 或 payment 关联 |
| related_treatment_subject_id | string/null,可靠 PAC 内部治疗主体引用 | 仅真实解析结果保留,不公开 |
| resolved_at | UTC 时刻/null,解决时间 | 与投诉提出时间是不同事件 |
| source_status/category/concern/channel | string/null,来源原始状态、分类、关注点、渠道 | host_status → source_status;原值缺失不得拿标准值补充 |
| follow_ups[] | 跟进事件数组 | occurred_at、action、source_action、description、actor_external_id、actor_name;每条有自己的时间,保留独立事件 |
| signal_eligible / review_reason | boolean;string/null,内部有效性与质量原因 | 内部保留,不对外扩散 |
| source_updated_at | 投诉主记录来源更新时间 | 若外层乱序水位包含跟进聚合时间,两者不同则保留内容字段并明示主单口径,不能因同名删掉 |
| occurred_at | 投诉提出时间 | 不随解决或跟进时间滚动 |
## 5 患者关系与转介绍
患者关系不只亲属:当前包含 spouse、father、mother、child、grandparent、grandchild、sibling、friend、other。方向仍是“对方是本人的谁”,不自动取逆或补双向边。
转介绍另表达“谁推荐谁”,不能由 friend、亲属或源表名称 referee 推断。未来若有明确来源,关系方向定义为“被推荐人 → 推荐人”,使用独立、明确的推荐关系类型;不混入亲属枚举。推荐人不是本范围患者时保留来源引用,不伪造 PAC 患者或跨租户解析。
当前 referralCount、referralAmountCents 继续是来源聚合指标,供转介绍画像使用;不展开成虚构的逐人介绍事件,不从关系条数重算替代。推荐关系模型新增字段、索引和历史回填不在本轮执行范围内。
## 6 宿主回访与 PAC 执行
**宿主回访记录的是宿主安排的任务及其结果;PAC 执行记录的是对某个召回计划提交的一次处理结果。两者都保留,但不互相冒充,也不自动认定是同一次联系。**
当前两宿主均有 `patient_return_visit` 映射,写入独立表,不生成 PatientFact。Friday 的回访行合入了宿主任务头的日期、状态;任务头本身没有独立摄入。以下“建议名称”仅用于后续内部规范化读对象,当前数据库列和已发布接口均不直接改名。
### 6.1 宿主回访字段
| 当前字段 | 建议名称 | 定义、类型与空值 |
| --- | --- | --- |
| id | id | string,PAC 回访记录主键,不是事实版本 ID |
| hostId / tenantId / sourceUnit | 保留 | string,宿主、租户、来源身份范围;不能省略范围只用 externalId 去重 |
| externalId | externalId | string,来源回访记录 ID;唯一键为上述范围 + externalId,不是宿主任务头 ID |
| patientId | patientId | string,已解析的 PAC 患者;不按姓名补配 |
| clinicId | clinicExternalId | string/null,来源诊所标识;新内部对象明确身份空间,存量接口名称保持兼容 |
| taskDate | scheduledDate | YYYY-MM-DD/null,宿主任务安排日期,按北京时间日历日理解;没有时分秒,不转换成虚构的联系时刻 |
| type | typeName | string/null,来源回访类别的可读名称;现有咨询、术后、常规、自定义、系统召回等,不当作封闭枚举 |
| status | sourceStatus | string/null,来源回访状态;“已回访”不能单独证明已接通 |
| taskStatus | sourceTaskStatus | string/null,来源任务流程状态;已完成、已预约等不等于同一个联系结果 |
| treatmentItems | treatmentDescription | string/null,来源治疗项或主题描述;可能含诊断名称,不是标准治疗码或关联 ID |
| followContent | description | string/null,回访任务内容或主题;不代表实际说过的话 |
| result | result | string/null,来源记录的结果文本;空值不是未联系的确定证明 |
| taskDirectorId / taskDirectorName | assigneeExternalId / assigneeName | string/null,来源任务负责人及名称快照;不能当成患者当前专属客服,也不保证就是实际联系人 |
| sourceCreatedAt | sourceCreatedAt | UTC 时刻/null,来源回访记录创建时间;若来源合并父任务时间,必须在映射契约说明,不称为联系发生时间 |
| sourceUpdatedAt | sourceUpdatedAt | UTC 时刻/null,来源记录更新时间;不解释为最后一次实际联系时间 |
| createdAt / updatedAt | 保留 | PAC 本地记录创建/修改时刻,不增加同义字段 |
来源时间映射现状:5i5ya 使用 `created_date / updated_date`;Friday 使用 `created_gmt_at / updated_gmt_at`。这是当前映射列的证据,不证明所有历史记录均已填满或来源字段从未代用;切换前逐宿主核对来源查询及实值。只有日期的 taskDate 维持 DATE 口径。
Friday 当前把数字状态转成中文,未知值变空串;5i5ya 接收名称。目标读层可把空串统一为 null,但不能因此宣称现有表完整保存了所有宿主原始码。未来若标准化结果分类,先核实原码的留存和来源规则;不从中文近似匹配生成“接通”“预约成功”等确定状态。系统任务负责人 `System` 不生成真人员工关系。
当前没有可靠的实际联系时间字段。任务日期已过、记录被更新、状态已完成,都不足以补出 `contactedAt`;本轮不为此新增一个全空列。展示时写“任务日期”“记录结果”,有未接通结果时明确显示未接通,不能写成“某日已联系患者”。
### 6.2 PAC 执行字段
| 当前字段 | 建议名称 | 定义、类型与约束 |
| --- | --- | --- |
| id | id | string,PAC 执行记录 ID;现有宿主执行回调的 eventId 使用它 |
| hostId / tenantId | 保留 | string,执行所属范围 |
| planId | planId | string,PAC 召回计划 ID;患者通过 plan.patientId 关联,不增造宿主回访 ID |
| executorClinicId | executorClinicExternalId | string,执行所属诊所的来源标识,与计划目标诊所分别表达 |
| operatorUserId | operatorExternalId | string,认证上下文中的宿主操作用户;不因与任务负责人同名而合并 |
| channel | channel | phone / wecom / sms / other,提交时记录的执行渠道 |
| outcome | outcome | 现有 ExecutionOutcome 共享枚举,表示提交的处理结果;保留历史可读值,不在此复制第二套枚举 |
| notes | notes | string/null,本次处理纪要;不是下一次任务描述,不是通话逐字稿 |
| assignmentId | assignmentId | string/null,本次执行归属的分配批次快照;不能用计划当前批次或时间窗重新推断 |
| abandonReasons | abandonReasons | string[],现有共享放弃原因枚举;空数组表示没有记录原因 |
| inaccurateTreatments | inaccurateTreatments | string[],被反馈识别不准的治疗类别码;现状用于统计,不能改作逐治疗抑制开关 |
| scheduledNextAt | nextContactAt | UTC 时刻/null,明确约定的下次回访时间;不是下一次预约到诊,也不证明将来已执行 |
| createdAt | submittedAt | UTC 时刻,执行结果提交入库时间;只在规范化读对象改展示名,不新增重复列、不改历史值 |
执行记录是 PAC 原生数据,不需要伪造 sourceCreatedAt/sourceUpdatedAt。当前模型没有单独的实际联系开始、结束和时长;submittedAt 不能承诺为精确通话结束时间。将来接入真实通话事件时另定义其业务时间与可靠关联,不回写猜测值。
`no_answer` 是一次未接通尝试,`scheduled_next` 是约定再联系,`success_appointed` 是提交预约成功结果,`marked_invalid` 等历史值也要能读。执行存在不等于已接通;执行结果也不能直接冒充宿主真实预约、到诊或治疗事实。
现有执行回调 `occurredAt = execution.createdAt`,保持契约,明确它表示“执行结果提交事件时间”。未来若需要实际联系时间,应另行扩展契约。回调中的 planStatus 当前读取发送时的计划状态,不应解释为该执行发生时的不可变状态快照;本轮不连带改变回调行为。
### 6.3 关系及展示
- 一个患者可以有多条宿主回访,也可以有多个 PAC 计划;一个 PAC 计划可有多次执行。
- 宿主回访与 PAC 执行目前没有可靠的一对一关联。患者相同、同日、同负责人、结果相似,都不能自动合并或去重。
- 未来宿主接收执行回调并保留 PAC eventId,或提供可验证的回访记录引用,才建立明确对应关系;重试沿用同一 eventId。HTTP 接收成功本身不能证明宿主已生成哪条业务记录。
- 回访任务负责人、执行操作人、患者当前专属客服是三种角色。分配批次归属也独立保存,避免后续改派改变历史统计。
- 可提供统一“联系与回访”读视图,保留 `host_return_visit / pac_execution` 来源及各自 ID。宿主任务显示 scheduledDate 与结果,PAC 执行显示 submittedAt 与 outcome,未来安排单独展示;不强填一个统一的“联系发生时间”。
- 没有可靠关联时分别展示并说明来源,不把两条记录计作两次已接通。需要“实际联系次数”指标时先确认数据能否支撑,不能按两表行数相加。
### 6.4 消费者、历史与影响
| 消费者 | 已核实情况与风险 | 实施要求 |
| --- | --- | --- |
| 客服名册/在岗依据 | 已使用 sourceCreatedAt 聚合回访任务;这属于任务活动依据,不是正式员工在岗证明 | 保留现有权限与名册规则,禁止改用 scheduledDate 或把 assignee 改成患者当前专属客服 |
| AI 简报、话术、患者回访展示 | recall-brief 先取 taskDate 最新三条再过滤未来日期;过去日期也未必实际联系,未来任务还可能挤掉历史条目 | 先明确“任务记录”与“联系结果”的筛选、文案,再调整查询顺序;不据日期冒认已联系 |
| 执行提交、计划状态与抑制 | 执行写入伴随计划状态、联系次数、延后、分配归属及回调 | 兼容读层先行,不能把简单字段整理变成执行流程重构;状态迁移和批次统计单独回归 |
| 宿主执行回调 | eventId 为执行 ID,occurredAt 为提交时间;现行字段已对外使用 | 保持字段和重试身份,补足时间说明;不自动把回调记录再次写成新执行 |
| 新旧开放接口 | 原 open-profile 可单独返回 returnVisits;新增患者事实查询不包含这张表和执行表 | 不因本次模型补齐自动扩展开放返回;“事实”与“全部患者业务记录”分开说明 |
宿主回访当前按来源键 upsert,是当前记录快照,不是不可变联系事件流;没有保存下来的任务状态历史不能补造。PAC 执行按既有提交记录保留,不改日期、不重新归批、不自动合并。统一视图先兼容旧数据,无需把两表搬进 patient_facts,也不要求全量重摄。
## 7 其他患者相关实体覆盖
本次覆盖 PAC Prisma 的全部 22 个模型,并补查 FridayAI、宿主操作服务的患者相关边界。下表是模型和代码覆盖,不是所有宿主源表或生产数据完整性证明;新增来源能力仍需单独核实。
| 层次/现有实体 | 收敛决策与边界 | 主要影响 |
| --- | --- | --- |
| 患者身份 Patient | 保留主档及来源身份范围;病历号、手机号都不能跨宿主无条件当唯一键 | 摄入归属、关系解析、认证和所有查询 |
| 当前属性 PatientProfile | 保留渠道、专属客服、偏好、联系限制等;来源属性与派生属性在契约中分别说明 | UI、画像、排除规则;不因开放接口隐藏而删内部字段 |
| 患者关系 PatientRelation | 保留患者之间的关系与方向,见第 5 节;本地 createdAt 不是关系开始时间 | 家庭关系、身份解析;转介绍需独立来源证据 |
| 原始事件 PatientTransaction、证据 IngestEvidence | 保留原报文与处理证据;不是新的临床事实类型 | 幂等、重放、故障排查、历史纠错 |
| 业务事实 PatientFact | 按第 2~4 节收敛,不包揽全部患者业务实体 | 摄入、画像、召回、开放接口 |
| 宿主回访 PatientReturnVisit | 保留任务与结果快照,见第 6 节 | 名册、回访展示、AI 上下文 |
| 画像 Persona、PersonaFeature | 保留派生分析和证据;不是来源确诊或宿主原始属性 | 标签展示、召回条件、患者分析输出 |
| 召回计划 FollowupPlan、PlanReason | 保留机会及原因、证据引用;不是宿主回访任务或医生治疗计划 | 计划生成、状态、有效窗口、反馈及抑制 |
| 计划话术 PlanScript、摘要 PlanSummary | 保留为生成内容,标明依据;不能回流成医生建议或新的来源事实 | AI 展示、缓存刷新、证据定位 |
| PAC 执行 PlanExecution | 保留处理记录,见第 6 节;计划反馈 recallFeedback 是对计划的判断,不与本次执行结果混用 | 执行状态机、统计、回调 |
| 分配 PlanAssignment、事件 PlanEventLog | 保留批次和分配/退回/回收等过程;不把“回收”算作患者回访或联系 | 主管批次、责任归属、历史统计 |
| 计算及操作日志 PersonaRecomputeLog、PlanGenerationLog、AgentInvocation、SyncLog、UserActivity | 保留计算、工具与审计职责;不把日志时间当业务发生时间 | 排障、追溯、运行观测 |
| 宿主配置 Host | 保留集成与授权配置;属于范围及来源约束,不是患者事实 | 身份命名空间、摄入、回调、访问控制 |
| FridayAI 联系人绑定 ContactLink | 保留账号/外部联系人与宿主患者的绑定;其中 self/parent 等是“说话人与患者”的关系,不等于 PatientRelation | 消息归属、代家属沟通、患者上下文;不能迁入患者亲属表代用 |
| FridayAI Conversation、Message、Task、Job、ConversationHandoff | 保留会话、消息、服务任务、调度与转人工;任务存在不证明已发出、收到或联系成功 | AI 服务与人工接管;实际消息和处理时间各按原契约 |
| 宿主操作 Operation 及尝试/回执 | 保留操作意图、授权、发送与结果;queued 或投递成功不直接变成真实预约事实 | 跨系统幂等、操作完成确认;宿主业务结果按既定采集路线进入 PAC |
**这次额外发现的语义问题,实施前需要单独定口径:**
1. `preferredDoctor` 的实现是按病历医生名称出现频次、再按最近时间选取,表示“病历中最常出现的医生”,不是患者明确偏好。建议规范化读对象称 `mostFrequentEmrDoctorName`,旧字段兼容;无可靠人员 ID 时不把同名医生合成一个人员身份。
2. `lastVisitAt` 的派生加入病历,而 RFM/生命周期/紧迫度的 `visitFactsOf` 使用到诊、实际治疗、挂号。两处代码当前确实不同;病历存在也不能普遍证明当天到场。目标区分“已证实到诊”和“临床记录活动”,先统计差异和受影响患者,不能简单取并集统一。
3. `lastVisitDoctor` 目前用末诊同日病历医生作关联。同日不代表同次就诊,不能升级成已证实接诊医生;目标按可靠引用提供实际医生,无引用只作为同日病历医生证据展示。
4. 检查、处方/用药、过敏与既往史等内容不能因缺少独立事实类型而从病历中丢弃。现阶段保留来源段落;只有来源有稳定对象与实际消费者时,再建独立实体。充值、订单、转介绍事件等仍按第 2.2 节处理,不为凑齐名词增加空类型。
上述医生角色、末诊指标及回访历史问题属于业务语义纠错,不是纯字段改名。它们可能改变画像、召回和 AI 说法,应与无损重命名单独验收。
## 8 现有消费者怎么兼容
### 8.1 一个规范化入口,两种存储格式
建立共享的按类型事实读取契约:旧版存储和新版存储都投影为同一内部业务对象。新写入使用新版校验,旧写入暂停切换前仍走旧路径;新旧输入不能靠字段存在与否猜测。
旧字段兼容规则放在统一的按来源映射中,不散落到页面、画像、AI 各处。只有涉及旧数据语义歧义时才读取本事实绑定的证据,限定宿主、租户、患者及来源范围;解析结果可形成可重建的版本关联投影,避免每次召回扫描 raw_payload。原事件继续留作审计,不成为每个算法的第二套业务模型。
旧接口兼容保留的是响应形状与已发布含义,不是恢复已经证实的虚假关联。旧字段无法准确承载新含义时返回契约允许的未知值;若旧契约不允许,应在切换前单独审核兼容升级,不能静默改变定义。
SQL 消费必须纳入同一契约:采用可查询的规范化视图或持久化读投影,并与 TypeScript 规范化结果用相同样本核对。不能只新增一个 TS helper 而让召回 SQL 继续直接读旧 JSON。视图/投影选型先测查询计划、索引和耗时,再决定是否物化,不在本稿承诺零性能成本。
| 消费方 | 兼容措施 | 必须验证 |
| --- | --- | --- |
| 摄入映射、canonical、parser、writer | 按类型类型化;写入前统一校验;关联 nullable;不同格式的内容先归一再比较 | 17 类注册与实际支持清单一致;11 类完整入口样例;6 类停用显式响应;缺值不造假 |
| 画像 | 保留现有业务目标,访问规范化事实;末诊来自可靠到诊或明确临床活动 | RFM、生命周期、紧迫度、时间偏好、迟到、医患关系;不能拿预约创建时间算末诊 |
| 召回 SQL 和治疗链 | 显式取所需业务时间与证据类型;按患者、来源范围及牙位处理;不盲目 COALESCE 发生/计划时间 | 窗口边界、治疗完成、检查误归治疗、影像信号重复、跨病历误关联 |
| 医嘱与病历关联 | 用可靠 emr_external_id;旧同日兜底停止用于绑定“这条诊断的医嘱” | 同日多份病历不串;无关联可展示患者病历列表,但不宣称同次 |
| 工作台、时间轴 | 使用规范化内容及角色;预约建立、约定到诊和实际到诊按事件区别展示 | 旧记录可读;不重复计到诊;未知时间不被赋成今天或预约日 |
| AI 工具、召回话术 | 输出证据性质和真实关联;医生医嘱来源可定位 | AI 发现不呈现成医生确诊,来源医生不误称执行医生 |
| 原有接口、开放患者查询 | 保留独立兼容 DTO,继续字段白名单;新内部字段不自动输出 | 字段/枚举/类型编码不变,原示例与回归样例通过;正确语义的值变化单列审核 |
| 任务、过期处理、重放、导出 | 继续识别现有 status 和身份;必要兼容旧字段输出 | fulfilled 不漏读;重放幂等;旧导出使用者不因重命名漏字段 |
### 8.2 可控差异,不追求所有结果完全相同
纯改名和格式转换应保持画像、召回结果不变。修正错关联、虚假时间、检查被当治疗及医生角色后,部分结果理应改变:必须逐样本说明来源、旧结论、新结论和原因,独立确认后才切换。
本轮不擅自收紧“是否使用影像 AI 信号”的召回规则。先让来源可辨并保持已批准规则;证据类型是否触发不同阈值另行审核。不能把模型治理混成一轮无说明的算法调参。
## 9 历史数据处理
### 9.1 四类数据分开处理
| 分类 | 例子 | 建议 |
| --- | --- | --- |
| 无损同义转换 | name_zh → name、treat_stages → stages、医生来源 ID 的名称 | 先读兼容;需要物理迁移时分批转换并保留备份,不产生新业务事件 |
| 可凭明确证据纠正 | emr_id 被存进接诊字段;settlement_number 被存进 order 字段;预约创建时间 | 从本事实绑定且范围一致的证据恢复;证据冲突留 null,并记可追查的质量原因 |
| 证据缺失或歧义 | 真实接诊医生、准确治疗时刻、旧问号被清洗、单片分析缺单片键 | 不猜、不按同日凑配;保留旧原始值供审核,规范化读取不把它当确定业务值 |
| 影响业务结论 | 检查计治疗、虚假末诊时间、错误病历医嘱关联 | 单独样本审核及影响统计;按受影响患者重算,不能把全库重算捆绑到字段改名 |
### 9.2 迁移约束
1. 不改变 subject_id、患者归属、来源范围或已有版本 id。格式版本与业务版本分开;既有外部 facts.id 指向的版本不得因纯改名无意义地换号。
2. 不对全库跑 reparse。当前 writer 按 content、status 和时间判断新版本,批量改名或补默认字段会造成版本膨胀、磁盘/WAL压力并触发下游计算。
3. 物理迁移必须有独立任务清单、迁移前后摘要、断点、批量限制及条件更新。迁移遇到并发摄入或人工变更时重试或跳过,不能覆盖更新后的行。
4. 当前版本先服务正确读取;旧版本通过格式兼容保留可回溯性。纯格式转换如直接 UPDATE,必须确认新旧 writer 比较均兼容并记录迁移审计;业务证据纠错沿已有版本机制或另审修复机制,不把两者混在一份无审计 UPDATE 中。
5. source_updated_at 是乱序保护水位。本轮不得为了改时间口径直接调低历史水位;旧报文不能覆盖新结果。时间代理值纠正需单独方案和防回退验证。
6. 仅改格式不触发患者业务重算;业务语义发生变化时,对确受影响患者使画像水位失效,并按现有受控任务重算。召回跟随批准的运行方式,不额外启动生产全量计算。
7. 不指望日常增量自动修复所有旧事实。长期不更新的历史记录仍需兼容读取;物理迁移有明确覆盖统计及结束条件。
8. 回滚优先切回旧消费者和旧写入开关,旧业务版本保留。新版写入开启后,回滚版本必须具备新版读取能力;不能直接退到完全不认识新格式的镜像。迁移脚本须支持仅恢复本次且未被后续修改的行。
## 10 实施顺序与验收
当前已完成第一阶段的兼容读模型、15 项无损别名、SQL/TS 本地一致性核验及测试环境定向样本评估。既有开放投影复用同义别名表,保留原有字段准入;两项画像、AI 病历组装及召回的两个投影字段已通过显式旧存储读取器使用规范名称,业务算法不变;并未完成全系统存储格式切换。医生角色、末诊、回访历史仅评估,未纠错。后续切换消费者前仍需审核业务口径;本稿不宣称全库历史已核验、迁移零风险或所有调用已扫描完成。优先实现无损兼容读取,涉及业务结果变化的项分开开关、差异验证,避免一次改动全部启用。
| 阶段 | 工作 | 进入下一阶段的条件 |
| --- | --- | --- |
| A 契约和盘点 | 确认本稿、补齐所有读取点与支持入口、只读统计历史形态 | 11 类及回访/执行边界定稿;停用类型确无未处理调用;每个字段有来源、消费者、空值规则;医生与到诊口径差异已有样本结论 |
| B 兼容读取 | 先部署能识别新旧格式的消费者,写入仍为旧版;规范化影子读取 | 代表性样本覆盖两宿主及美亚;SQL/TS 结果一致;性能可接受;异常不静默吞数据 |
| C 新写入 | 修宿主映射、类型化 parser、字段校验;按宿主/类型小范围开启 | 入库无新增拒收;重复、乱序、部分失败重试通过;业务差异已审核 |
| D 历史治理 | 仅对可可靠恢复的字段与受影响患者分批处理 | 有断点、恢复演练、并发条件更新、负载边界;变更后的画像/召回解释清楚 |
| E 清理 | 移除已无入口依赖的死实现与重复兼容代码 | 有实际使用统计与覆盖证明;仍需读的历史格式保留解码器,不以截止日期强删 |
本地和测试环境至少覆盖:同日多病历、同名患者、跨来源重复 ID、预约未到/到诊无时间、计划无执行日期、诊断无父关联、影像有片无分析、零发现与未知结果、影像 AI 与医生记录同时存在、金额缺失与真实零金额、旧结构重放、过期/撤销/fulfilled 记录、未知扩展与非法嵌套字段。
回访与执行另覆盖:未来任务不冒认历史联系、过去未完成任务、已回访但结果未接通、缺失实际联系时间、系统负责人、任务负责人不同于专属客服、同患者同日两表记录不误合并、执行后改派不改历史批次、回调重复不增新执行、下次回访不当预约、未知来源状态、任务日期跨 UTC 日界。医生与到诊口径另覆盖只有病历无到诊、同日多医生、同名医生以及病历补录。
契约测试必须走“宿主样本 → assembler → canonical → parser → 入库 schema → 消费视图”,不能仅验证每个模块自己构造的样例。字段新增、改名或停用时由 CI 同时检查映射、类型、校验、消费和文档;修掉当前挂号、接待、医嘱、充值之间不一致的定义,或明确从可用写入清单移除,不继续留作貌似可用的代码。
## 11 建议本次确认的边界
- 保留 11 类编码;encounter 中文和业务口径收敛为到诊,诊断显示区分医生记录与影像发现。
- 六类预留模型暂停扩展,旧读法保留;不因无数据直接删全部代码。
- 宿主回访与 PAC 执行分别保留;任务安排日期、结果提交时间不冒充实际联系时间,没有可靠引用不合并。
- 患者主档、关系、画像、计划、执行、分配和 AI 会话各归其层;医生角色及到诊口径纠错单独审核,不随纯改名自动切换。
- 统一自身来源标识、明确关联对象,禁止伪造关联;内部必要证据和备注保留。
- 发生、计划、来源创建/更新各有含义;不恢复 source_created_at_basis,不用本地创建时间补业务时间。
- 同意先读兼容、再新写入、最后历史治理;本次不批准生产迁移、全量回填或召回策略调整。
## 12 核对依据
- [事实类型与现行纪律](../../packages/types/src/enums/index.ts)
- [当前 content 校验](../../apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts)
- [事实存储与时间字段](../../apps/pac-service/prisma/schema.prisma)
- [事实写入与版本比较](../../apps/pac-service/src/modules/sync/pipeline/fact-writer.service.ts)
- [5i5ya 到诊来源](../../apps/pac-service/data/jvs-dw/assemblers/encounter.yaml)及 [Friday 到诊来源](../../apps/pac-service/data/friday/assemblers/encounter.yaml)
- [诊断解析器](../../apps/pac-service/src/modules/sync/pipeline/parsers/diagnosis.parser.ts)、[费用单据解析器](../../apps/pac-service/src/modules/sync/pipeline/parsers/order.parser.ts)、[独立分析解析器](../../apps/pac-service/src/modules/sync/pipeline/parsers/image-analysis.parser.ts)
- [美亚当前落库](../../apps/pac-service/src/integrations/meyer/pac-sink.ts)
- [开放接口关联纠正](../../apps/pac-service/src/modules/open/lookup-relations.ts)、[时间映射](../../apps/pac-service/src/modules/open/lookup-fact.ts)、[内容投影](../../apps/pac-service/src/modules/open/lookup-content.ts)
- [治疗链与医嘱关联](../../apps/pac-service/src/modules/plan/engine/chain-composer.service.ts)、[到诊画像口径](../../apps/pac-service/src/modules/persona/features/visit-facts.ts)
- [前期字段审核和历史核查记录](../_archive/2026-10-meyer-and-patient-facts/2026-10-20-fact-content-field-contract-review.md):历史观测不能替代实施前最新盘点。
- [宿主回访模型与执行模型](../../apps/pac-service/prisma/schema.prisma)、[5i5ya 回访映射](../../apps/pac-service/data/jvs-dw/assemblers/patient_return_visit.yaml)、[Friday 回访映射](../../apps/pac-service/data/friday/assemblers/patient_return_visit.yaml)
- [执行提交与状态处理](../../apps/pac-service/src/modules/plan/execution.service.ts)、[执行回调](../../apps/pac-service/src/modules/plan/execution-callback.service.ts)、[名册回访依据](../../apps/pac-service/src/modules/plan/plan-assignment.service.ts)
- [回访简报筛选](../../apps/pac-service/src/modules/ai/orchestrators/recall-brief.orchestrator.ts)、[旧开放接口回访](../../apps/pac-service/src/modules/open/open-profile.service.ts)、[末诊与医生派生](../../apps/pac-service/src/modules/persona/persona.service.ts)
- [FridayAI 联系人与会话模型](../../apps/ai-service/prisma/schema.prisma)、[宿主操作模型](../../apps/host-service/prisma/schema.prisma)
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