Commit 09b9dbb2 by luoqi

feat(cli): 新增 import-patient 单患者摄入+重算;AI 默认模型切 qwen;详情页有原始档案时隐藏假手机号

- import-patient.cli:单患者精确摄入(importPatient SQL 注入 pid+brand)+ persona/plan 重算,
  补 runPullForPatient 对"未摄入新患者"的死结;cursor 隔离(resource=patient_refresh)不扰增量。
- assistant / plan-detail:话术&对话默认模型 deepseek → qwen(qwen3.7-max),去掉 Gemini 选项(未配)。
- plan-detail:配了宿主 VIEW_PATIENT(原始档案)时隐藏 PAC 侧手机号行——真号在宿主档案页,
  PAC 侧为造数假号,防客服误拨。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 6a13bdb4
Pipeline #3369 failed in 0 seconds
......@@ -25,6 +25,7 @@
"reparse:prod": "node --max-old-space-size=8192 dist/cli/reparse.cli.js",
"sync-incremental": "ts-node --transpile-only src/cli/sync-incremental.cli.ts",
"sync-incremental:prod": "node --max-old-space-size=4096 dist/cli/sync-incremental.cli.js",
"import-patient": "ts-node --transpile-only src/cli/import-patient.cli.ts",
"recompute-persona": "ts-node --transpile-only src/cli/recompute-persona.cli.ts",
"recompute-persona:prod": "node --max-old-space-size=8192 dist/cli/recompute-persona.cli.js",
"recompute-plans": "ts-node --transpile-only src/cli/recompute-plans.cli.ts",
......
/**
* Import Patient CLI —— 单患者精确摄入 + 重算画像/计划
*
* `runPullForPatient`(详情页"刷新"按钮)要求患者已存在 PG,对**尚未摄入**的新患者是死结。
* 本 CLI 直接调 ColdImportService.importPatient(SQL 注入 patient_id+brand,upsert 患者),
* 再顺手 persona.recompute + planEngine.recomputeForPatient —— 等价 runPullForPatient 的
* 摄入→重算链路,但去掉"患者须预先存在"的前置,用于本地按单患者复现/调试。
*
* cursor 隔离:importPatient 用 resource='patient_refresh',不读/写增量游标,不扰 daily cron。
*
* Usage:
* pnpm import-patient -- --host=jvs-dw --pid=3275018 --brand=瑞泰
* pnpm import-patient -- --host=jvs-dw --pid=3275018 --brand=瑞泰 --no-recompute # 只摄入
*/
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ColdImportService } from '../modules/sync/cold-import/cold-import.service';
import { PersonaService } from '../modules/persona/persona.service';
import { PlanEngineService } from '../modules/plan/engine/plan-engine.service';
import * as path from 'node:path';
interface Args {
host: string;
pid?: string; // DW patient_id(host 内 externalId)
brand?: string; // 多 brand host(如 jvs-dw)的品牌名 = source_unit 消歧;单 brand host 可省
recompute: boolean;
help: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = { host: 'jvs-dw', recompute: true, help: false };
for (const a of argv) {
if (a === '--help' || a === '-h') args.help = true;
else if (a === '--no-recompute') args.recompute = false;
else if (a.startsWith('--host=')) args.host = a.slice('--host='.length);
else if (a.startsWith('--pid=')) args.pid = a.slice('--pid='.length).trim();
else if (a.startsWith('--brand=')) args.brand = a.slice('--brand='.length).trim();
}
return args;
}
function printHelp() {
// eslint-disable-next-line no-console
console.log(
[
'',
'Import Patient CLI —— 单患者精确摄入 + 重算画像/计划',
'',
'Usage:',
' pnpm import-patient -- --host=<hostName> --pid=<externalId> [--brand=<brand>] [--no-recompute]',
'',
'Options:',
' --host=<name> 宿主名(默认 jvs-dw)',
' --pid=<id> 必填,DW patient_id(host 内 externalId)',
' --brand=<brand> 多 brand host 的品牌名(= source_unit 消歧);单 brand host 可省',
' --no-recompute 只摄入,不重算 persona/plan',
' --help, -h 显示本帮助',
'',
'Example:',
' pnpm import-patient -- --host=jvs-dw --pid=3275018 --brand=瑞泰',
'',
].join('\n'),
);
}
async function bootstrap() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.pid) {
printHelp();
process.exit(args.help ? 0 : 1);
}
const logger = new Logger('import-patient');
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['log', 'warn', 'error'],
});
try {
const prisma = app.get(PrismaService);
const coldImport = app.get(ColdImportService);
const persona = app.get(PersonaService);
const planEngine = app.get(PlanEngineService);
const host = await prisma.host.findUnique({ where: { name: args.host } });
if (!host) throw new Error(`Host '${args.host}' not found(先 seed / admin API 建 host)`);
const dataDir = path.join(
process.env.PAC_INCREMENTAL_DATA_DIR ?? path.resolve(process.cwd(), 'data'),
args.host,
);
// 1. 单患者摄入(SQL 注入 WHERE patient_id+brand;upsert 患者 + 衍生 fact)
logger.log(
`▶ 摄入 host=${args.host} pid=${args.pid}${args.brand ? ` brand=${args.brand}` : ''} dir=${dataDir}`,
);
const r = await coldImport.importPatient(dataDir, {
patientExternalId: args.pid!,
brand: args.brand ?? '',
triggeredBy: `import-patient-cli:${args.pid}`,
});
logger.log(
` 摄入 status=${r.status} patients=${r.totals.patientsUpserted} ` +
`txns=${r.totals.transactionsWritten} dups=${r.totals.duplicates} ` +
`facts(c=${r.totals.factsCreated} s=${r.totals.factsSuperseded} u=${r.totals.factsUnchanged}) ` +
`failed=${r.totals.failed}`,
);
// 2. 定位刚 upsert 的患者(hostId + externalId;多 brand 时按 source_unit=brand 收窄)
const patient = await prisma.patient.findFirst({
where: {
hostId: host.id,
externalId: args.pid!,
...(args.brand ? { sourceUnit: args.brand } : {}),
},
orderBy: { updatedAt: 'desc' },
select: { id: true, externalId: true, name: true, tenantId: true, sourceUnit: true },
});
if (!patient) {
throw new Error(
`摄入后未找到患者(hostId=${host.id} externalId=${args.pid}` +
`${args.brand ? ` sourceUnit=${args.brand}` : ''})— 摄入是否成功?`,
);
}
logger.log(
` 患者:${patient.name ?? '(无名)'} id=${patient.id} tenant=${patient.tenantId} unit=${patient.sourceUnit}`,
);
if (!args.recompute) {
logger.log('▶ --no-recompute:跳过 persona/plan 重算');
return;
}
// 3. Persona 重算(force:跳过水位幂等,确保产新版本)
logger.log('▶ 重算 Persona ...');
const pr = await persona.recompute({
patientId: patient.id,
source: `import-patient-cli:${r.runId}`,
force: true,
});
logger.log(` Persona: ${JSON.stringify(pr)}`);
// 4. Plan 重算(scenario SQL 带 patientId 收窄,只 upsert 该患者 plan)
logger.log('▶ 重算 Plan ...');
const r2 = await planEngine.recomputeForPatient({
hostId: host.id,
tenantId: patient.tenantId,
patientId: patient.id,
});
logger.log(
` Plan: outcome=${r2.outcome} plansCreated=${r2.plansCreated} ` +
`plansClosed=${r2.plansClosed} currentPlanId=${r2.currentPlanId ?? '(none)'}`,
);
logger.log('─────────────────────────────────────────');
logger.log(` DONE patient=${patient.name ?? args.pid}(${patient.id})`);
logger.log('─────────────────────────────────────────');
} catch (err) {
logger.error('import-patient failed:');
logger.error(err instanceof Error ? err.stack ?? err.message : String(err));
process.exitCode = 2;
} finally {
await app.close();
}
}
bootstrap().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(3);
});
......@@ -39,7 +39,6 @@ import {
const MODELS = [
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'qwen', label: '通义千问' },
];
......
......@@ -116,7 +116,7 @@ export async function transcribeAudio(blob: Blob): Promise<string> {
export function useAssistantChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>('idle');
const [model, setModel] = useState('deepseek');
const [model, setModel] = useState('qwen');
const abortRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
......
......@@ -143,8 +143,8 @@ export function PlanDetailApp({
const returnVisits = data.returnVisits ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
// 话术生成模型(具体型号);默认 deepseek-v4-flash
const [scriptModel, setScriptModel] = useState<ScriptModel>('deepseek-v4-flash');
// 话术生成模型(具体型号);默认 qwen3.7-max(极快 · 简洁)
const [scriptModel, setScriptModel] = useState<ScriptModel>('qwen3.7-max');
// 投入档(默认稳健);跟模型并列,客服在重新生成处选
const [scriptTier, setScriptTier] = useState<ScriptTier>('stable');
const { state: streamState, regenerate, abort } = useScriptStream();
......@@ -1003,6 +1003,12 @@ function IdentityCard({
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
// 原始档案跳转 URL(宿主 VIEW_PATIENT 配了才有)。有原始档案入口时隐藏手机号行:
// PAC 侧号码是宿主同步的造数假号,真号在宿主档案页,避免客服误拨假号。
const originalArchiveUrl = resolveActionUrl('VIEW_PATIENT', {
patientId: patient.externalId,
medicalRecordNumber: patient.medicalRecordNumber,
});
return (
<section className="bg-white rounded-lg border border-slate-100 shadow-sm flex-none">
<div className="p-3 flex items-start gap-2.5">
......@@ -1029,22 +1035,16 @@ function IdentityCard({
潜在治疗 →
</button>
{/* 原始档案 —— 宿主 VIEW_PATIENT 跳转(占位 {patientId}/{medicalRecordNumber});未配则不渲染 */}
{(() => {
const url = resolveActionUrl('VIEW_PATIENT', {
patientId: patient.externalId,
medicalRecordNumber: patient.medicalRecordNumber,
});
return url ? (
{originalArchiveUrl ? (
<a
href={url}
href={originalArchiveUrl}
target="_top"
rel="noopener"
className="text-[10.5px] text-teal-700 hover:underline"
>
原始档案 →
</a>
) : null;
})()}
) : null}
</div>
</div>
{patient.tags.length > 0 && (
......@@ -1056,6 +1056,8 @@ function IdentityCard({
))}
</div>
)}
{/* 有原始档案入口 → 隐藏手机号行(真号在宿主档案页,PAC 侧为造数假号) */}
{!originalArchiveUrl && (
<div className="mt-1 flex items-center gap-1.5">
<span className="text-[12px] tabular-nums font-mono text-slate-700">
{patient.phone ?? patient.phoneMasked}
......@@ -1101,6 +1103,7 @@ function IdentityCard({
</span>
)}
</div>
)}
</div>
</div>
</section>
......
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