Commit da4ccf77 by luoqi

Merge exact patient assignment and live receipts into test

# Conflicts:
#	apps/pac-web/src/components/assistant/use-assistant-chat.ts
parents 94dfcc3b 427202a6
Pipeline #3868 failed in 0 seconds
......@@ -153,7 +153,7 @@ icon: Sparkles
| **禁词 + 不承诺** | 不许"保证治好" / 报价 / 写死时间(诊所没排班接口,时间只能给"示例") |
| **失败降级** | 大模型出错或违规 → 退回安全模板,客服永远有东西可用 |
> 📐 **真理源**:提示词组装 `skill-composer.ts` + `skills/*/SKILL.md`(按 frontmatter 声明式匹配);安全规则**单一源** `safety-rules.ts`(机器闸 + prompt 同引,输出后扫禁词 / 加粗时间 / 临床码,命中即兜底);模型默认 `deepseek-v4-flash`;每次落 `agent_invocations`(档位 / 模型 / token / cost / 用了哪些块)。话术块是 MD 文件,**运营可改文案不动代码**。
> 📐 **真理源**:提示词组装 `skill-composer.ts` + `skills/*/SKILL.md`(按 frontmatter 声明式匹配);安全规则**单一源** `safety-rules.ts`(机器闸 + prompt 同引,输出后扫禁词 / 加粗时间 / 临床码,命中即兜底);模型默认 `deepseek-flash`;每次落 `agent_invocations`(档位 / 模型 / token / cost / 用了哪些块)。话术块是 MD 文件,**运营可改文案不动代码**。
> **本质**:话术不是"一个大模板",而是按患者特征拼装 + 大模型润色 + 安全兜底。
......
......@@ -79,7 +79,7 @@ system prompt = `base common`(`shared/skills/_base/common.md`,三档共用:角
## provider 与落账
- **模型**:三档默认 `deepseek-v4-flash`(`?model=` 可覆盖)。
- **模型**:三档默认 `deepseek-flash`(`?model=` 可覆盖)。
- **落账**:每次经 `AiCallRunner` → `invocation-recorder` 两段式落 `agent_invocations`(tier 嵌在 `promptVersion`、`modelProvider`/`modelName`、token、`costYuan`、source);深度档额外 `attachJudge` 落自检质量分。
- **写库**:`plan_scripts` upsert(`content` Markdown、`status='ready'`、`source`、`agentInvocationId`)。
......
......@@ -85,7 +85,8 @@ DW_CLICKHOUSE_PASSWORD=<密码> # env 名须等于 manifest 的
# ─── AI(DeepSeek)───
DEEPSEEK_API_KEY=<key>
DEEPSEEK_BASE_URL=https://api.deepseek.com
AI_DEFAULT_MODEL=deepseek-v4-flash
# 旧 deepseek-v4-flash 配置兼容归一;Flash 成本按高峰价估算,非实际账单
AI_DEFAULT_MODEL=deepseek-flash
# ─── 定时任务 ───
# 注:增量 / 滞后监控 / 兜底扫描的代码默认是"永不"哨兵,必须显式设 env 才会调度
......
......@@ -84,10 +84,10 @@ DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
# 默认主力模型
# local: deepseek-v4-flash (省钱开发)
# staging: deepseek-v4-flash
# production: deepseek-v4-pro (质量优先)
AI_DEFAULT_MODEL=deepseek-v4-flash
# local: deepseek-flash (省钱开发)
# staging: deepseek-flash
# production: deepseek-flash (Pro 可单独评估后配置)
AI_DEFAULT_MODEL=deepseek-flash
AI_REQUEST_TIMEOUT_SEC=60
......@@ -107,7 +107,8 @@ GEMINI_API_KEY=
# GEMINI_LIVE_MODEL=gemini-3.1-flash-live-preview
# 价格表(¥/M tokens)— 调价时改这里重启;不配走默认
# AI_PRICE_TABLE_JSON={"deepseek-v4-pro":{"inHit":0.5,"inMiss":3.6,"out":25},"deepseek-v4-flash":{"inHit":0.07,"inMiss":0.5,"out":2}}
# Flash 默认按官方人民币高峰价保守估算,空闲时段实际为一半;自定义表会整体替换默认表。
# AI_PRICE_TABLE_JSON={"deepseek-flash":{"inHit":0.04,"inMiss":2,"out":8}}
# ─── CORS / iframe ───────────────────────────────────────────────────
......
......@@ -6,7 +6,7 @@
* pnpm ai:gen-script -- --pid=F1001 # 用 patient externalId(自动取该 patient active plan)
* pnpm ai:gen-script -- --pid=F1001 --bust-cache # 跳过缓存重新调
* pnpm ai:gen-script -- --pid=F1001 --dry-run # 不写 PlanScript 表
* pnpm ai:gen-script -- --pid=F1001 --model=deepseek-v4-flash # 切模型
* pnpm ai:gen-script -- --pid=F1001 --model=deepseek-flash # 切模型
*
* 输入完全确定(不重新 pull 患者数据),仅用于验证 prompt + model 改动效果。
*/
......@@ -61,7 +61,7 @@ Usage:
pnpm ai:gen-script -- --pid=<patient-external-id>
pnpm ai:gen-script -- --pid=F1001 --bust-cache # 跳过缓存,真实调 LLM
pnpm ai:gen-script -- --pid=F1001 --dry-run # 不写 PlanScript
pnpm ai:gen-script -- --pid=F1001 --model=deepseek-v4-flash
pnpm ai:gen-script -- --pid=F1001 --model=deepseek-flash
Options:
--plan-id=<uuid> 直接指定 plan id
......@@ -70,7 +70,7 @@ Options:
--tenant=<str> 默认 t_demo
--bust-cache 强制重跑(不走 prompt cache)
--dry-run 只生成不写库
--model=<id> 覆盖默认模型(deepseek-v4-pro / deepseek-v4-flash)
--model=<id> 覆盖默认模型(deepseek-v4-pro / deepseek-flash)
`);
}
......
/** 官方保留的旧 Flash 别名;统一请求名和调用记录,不改历史数据。 */
export function canonicalModelId(modelId: string): string {
return modelId === 'deepseek-v4-flash' || modelId === 'deepseek-v4-flash-vision-exp'
? 'deepseek-flash'
: modelId;
}
import { canonicalModelId } from './ai-models';
export interface AppConfig {
nodeEnv: 'development' | 'test' | 'production';
port: number;
......@@ -15,7 +17,7 @@ export interface AppConfig {
deepseekApiKey: string;
/// DeepSeek base URL(默认官方;私有 / 镜像 endpoint 时覆盖)
deepseekBaseUrl: string;
/// 默认主力模型(deepseek-v4-pro / deepseek-v4-flash)
/// 默认主力模型(deepseek-v4-pro / deepseek-flash)
defaultModel: string;
/// Gemini API key(Google AI Studio;海外需可达 generativelanguage.googleapis.com)
geminiApiKey: string;
......@@ -75,7 +77,7 @@ export function loadConfig(): AppConfig {
ai: {
deepseekApiKey: process.env.DEEPSEEK_API_KEY ?? '',
deepseekBaseUrl: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com',
defaultModel: process.env.AI_DEFAULT_MODEL ?? 'deepseek-v4-flash',
defaultModel: canonicalModelId(process.env.AI_DEFAULT_MODEL ?? 'deepseek-flash'),
geminiApiKey: process.env.GEMINI_API_KEY ?? '',
geminiBaseUrl: process.env.GEMINI_BASE_URL ?? '',
geminiDefaultModel: process.env.GEMINI_DEFAULT_MODEL ?? 'gemini-3.5-flash',
......@@ -108,8 +110,8 @@ export function loadConfig(): AppConfig {
/**
* AI 价格表 — 从 env JSON 读,失败兜底默认值。
*
* 单位:¥/M tokens(已按汇率换算,DeepSeek 公开价 USD * 7.2)。
* inHit: 输入命中 prompt cache 的部分(便宜 ~7x)
* 单位:¥/M tokens。DeepSeek Flash 使用官方人民币高峰价作保守估算,非实际账单。
* inHit: 输入命中 prompt cache 的部分
* inMiss: 输入未命中(全价)
* out: 输出(含 reasoning,DeepSeek 统一计费)
*
......@@ -120,14 +122,16 @@ export function loadConfig(): AppConfig {
* 4. 新调用按新价计
*/
function parsePriceTable(raw: string | undefined): Record<string, { inHit: number; inMiss: number; out: number }> {
// ⚠️ 价格核对于 2026-06(官方页):¥/M tokens。美元价按 ×7.2 折 RMB。
// 除下方单独更新的 Flash 外,其他价格沿用 2026-06 配置:¥/M tokens,美元价按 ×7.2 折 RMB。
// 用各家**标准价**(不含限时促销,促销到期不会少报);要按实付/促销算 → 用 AI_PRICE_TABLE_JSON 覆盖。
// DeepSeek:api-docs.deepseek.com/quick_start/pricing(V4-pro $0.003625/0.435/0.87、V4-flash $0.0028/0.14/0.28)
// Flash 于 2026-10-23 单独核对官方人民币价:高峰 0.04/2/8,空闲为一半。
// https://api-docs.deepseek.com/zh-cn/quick_start/pricing/
// 仍用固定单价保守估算,不模拟节假日峰谷账单;其他模型价格保持原配置。
// Gemini 3.5 Flash:$0.15/1.5/9.0(cache/in/out)
// Qwen3.7-Max(百炼,RMB 直接):缓存输入 1.2 / 输入 12 / 输出 36;现有限时 5 折 = 6/18(促销价走 env)
const DEFAULT = {
'deepseek-v4-pro': { inHit: 0.03, inMiss: 3.13, out: 6.26 },
'deepseek-v4-flash': { inHit: 0.02, inMiss: 1.01, out: 2.02 },
'deepseek-flash': { inHit: 0.04, inMiss: 2, out: 8 },
'gemini-3.5-flash': { inHit: 1.08, inMiss: 10.8, out: 64.8 },
'gemini-2.5-flash': { inHit: 1.08, inMiss: 10.8, out: 64.8 },
// Qwen3.7-Max 是旗舰最贵档(不是便宜模型):标准价 ¥/M 直接填
......
......@@ -56,7 +56,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_plan';
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-plan-v9'; // v9: 去掉钦定段式(给自由度),只留从果推因/层层递进原则;v8: 从果推因+后果不吓唬+3-6段
readonly defaultModelId = 'deepseek-v4-flash';
readonly defaultModelId = 'deepseek-flash';
readonly outputSchema = DeepPlanSchema;
buildPrompt(ctx: ScriptContext) {
return { system: PLAN_SYSTEM, prompt: buildPlanPrompt(ctx) };
......@@ -68,7 +68,7 @@ export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_write';
readonly promptVersion = 'draft_plan_script@2026-07-29-deep-write-v19'; // v19: 自报家门改「我是{诊断医生}医生的助理X」(业务口径:非客服身份)。v18: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly defaultModelId = 'deepseek-v4-flash';
readonly defaultModelId = 'deepseek-flash';
readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
buildPrompt(input: DeepWriteInput) {
......@@ -86,7 +86,7 @@ export class DeepVerifyCall implements AiCall<DeepVerifyInput, DeepVerifyZ> {
readonly kind = 'judge' as const;
readonly callKey = 'draft_plan_script_verify';
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-verify-v10'; // v10: 递进检查去钦定段式(给自由度);v9: 加④患者听得懂(无代码/术语/含糊)
readonly defaultModelId = 'deepseek-v4-flash';
readonly defaultModelId = 'deepseek-flash';
readonly outputSchema = DeepVerifySchema;
buildPrompt(input: DeepVerifyInput) {
return { system: VERIFY_SYSTEM, prompt: buildVerifyPrompt(input) };
......
......@@ -77,7 +77,7 @@ export class DraftPlanScriptCall
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script';
readonly promptVersion = DRAFT_PLAN_SCRIPT_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-flash';
readonly defaultModelId = 'deepseek-flash';
readonly outputSchema = DraftPlanScriptSchema;
readonly safetyRules = safetyRules;
......
......@@ -30,7 +30,7 @@ export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraf
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script';
readonly promptVersion = STANDARD_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-flash';
readonly defaultModelId = 'deepseek-flash';
readonly outputSchema = StandardScriptSchema;
// 显式标 SafetyRule<DeepDraft>,让 runner.run 的 TOutput 推成 DeepDraft(否则会被结构型窄化丢 tone)
readonly safetyRules: ReadonlyArray<SafetyRule<DeepDraft>> = SCRIPT_SAFETY_RULES_SECTIONS;
......
......@@ -52,7 +52,7 @@ export class WecomWriteCall implements AiCall<WecomWriteInput, WecomWriteZ> {
readonly callKey = 'draft_wecom_script_write';
// ⚠️ 换 promptVersion 会让缓存整体失效(这是想要的):流程和形态都变了,旧缓存不该再命中
readonly promptVersion = 'draft_wecom_script@2026-08-05-single-v2';
readonly defaultModelId = 'deepseek-v4-flash';
readonly defaultModelId = 'deepseek-flash';
readonly outputSchema = WecomWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
buildPrompt(input: WecomWriteInput) {
......
......@@ -5,6 +5,7 @@ import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from '@ai-s
import { createOpenAICompatible, type OpenAICompatibleProvider } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
import type { AppConfig } from '../../../config/configuration';
import { canonicalModelId } from '../../../config/ai-models';
/**
* AiProviderService — 模型工厂。
......@@ -31,7 +32,7 @@ export class AiProviderService {
constructor(private readonly config: ConfigService<AppConfig, true>) {
const ai = this.config.get('ai', { infer: true });
this.defaultModel = ai.defaultModel;
this.defaultModel = canonicalModelId(ai.defaultModel);
this.geminiDefaultModel = ai.geminiDefaultModel;
this.qwenDefaultModel = ai.qwenDefaultModel;
......@@ -105,14 +106,14 @@ export class AiProviderService {
/**
* 解析 modelId → vendor 实例 + 规范化具体 modelId(落账 modelName / 计费 priceTable 用规范化后的)。
* 命名约定:
* "deepseek-v4-pro" / "deepseek-v4-flash" / "deepseek"(裸键)→ deepseek
* "deepseek-v4-pro" / "deepseek-flash" / "deepseek"(裸键)→ deepseek
* "gemini-2.5-flash" / "gemini"(裸键) → gemini
* "qwen-max"(将来) → qwen
* 裸键(无 "-" 版本)→ 解析到 config 配的默认型号;带版本 → 原样直传 SDK。
* 裸键(无 "-" 版本)→ 解析到 config 配的默认型号;具体型号 → 直传 SDK(旧 Flash 别名归一为 deepseek-flash)。
*/
resolve(modelId: string): { model: LanguageModel; provider: string; modelId: string } {
if (modelId === 'deepseek' || modelId.startsWith('deepseek-')) {
const canonical = modelId === 'deepseek' ? this.defaultModel : modelId;
const canonical = canonicalModelId(modelId === 'deepseek' ? this.defaultModel : modelId);
return { model: this.deepseek(canonical), provider: 'deepseek', modelId: canonical };
}
if (modelId === 'gemini' || modelId.startsWith('gemini-')) {
......
import { canonicalModelId } from '../../../config/ai-models';
/**
* 成本估算 —— 纯函数,`AiCallRunner` 与助手共用**同一份**口径。
*
......@@ -30,7 +32,11 @@ export function estimateCostYuan(
completionTokens: number,
cachedInputTokens = 0,
): CostResult {
const p = priceTable[modelId];
// 新旧 Flash 名使用同一计价;兼容部署时仍按旧名称配置的自定义价目表。
const canonical = canonicalModelId(modelId);
const p = priceTable[canonical] ?? priceTable[modelId] ?? (canonical === 'deepseek-flash'
? priceTable['deepseek-v4-flash'] ?? priceTable['deepseek-v4-flash-vision-exp']
: undefined);
const price = p ?? priceTable['deepseek-v4-pro'] ?? FALLBACK_PRICE;
// 防御:cached > prompt 不该发生,clamp
const hit = Math.min(Math.max(0, cachedInputTokens), Math.max(0, promptTokens));
......
......@@ -270,7 +270,7 @@ export class PlanScriptOrchestrator {
yield {
type: 'start',
invocationId: '',
modelId: options.modelIdOverride ?? 'deepseek-v4-flash',
modelId: options.modelIdOverride ?? 'deepseek-flash',
promptVersion: 'draft_plan_script@deep-pipeline',
};
let r: DeepScriptResult;
......
......@@ -123,7 +123,7 @@ import { Permission } from '@pac/types';
*/
// ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-22-a';
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-10-23-sheet-receipts';
/**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
......
import { SheetBridgeService } from './sheet-bridge.service';
import { Body, Controller, Logger, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Request, Response } from 'express';
......@@ -53,6 +54,7 @@ interface ChatBody {
* ⚠️ 用 zod 现场解析而不是直接透传:字段少一个模型就会读到 `undefined` 并当成 0。
*/
sheetState?: unknown;
sheetBridge?: boolean;
/**
* 详情页当前开着的那位患者 —— 与 `activeClinicId` 同一条旁路。
*
......@@ -117,6 +119,7 @@ export class AssistantController {
constructor(
private readonly assistant: AssistantService,
private readonly sheetBridge: SheetBridgeService,
private readonly transcriber: TranscribeService,
) {}
......@@ -167,6 +170,13 @@ export class AssistantController {
}
}
@Post('sheet-receipt')
@ApiOperation({ summary: '回传当前用户本轮确认单执行结果' })
sheetReceipt(@TenantScope() scope: TenantScopeContext, @Body() body: { commandId?: string; snapshot?: unknown; message?: string }) {
this.sheetBridge.receive(scope, body.commandId ?? '', body);
return { received: true };
}
@Post('chat')
@ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' })
async chat(
......@@ -188,7 +198,7 @@ export class AssistantController {
};
const ac = new AbortController();
req.on('close', () => ac.abort());
res.on('close', () => ac.abort());
// 流式 artifact:render_artifact 的入参(HTML)逐字生成,边生成边推 → 前端实时"长出"卡片,
// 不必等整段(~30s)生成完。按 toolCallId 累积 JSON 入参,增量提取 html 字段,250ms 节流发送。
......@@ -206,6 +216,9 @@ export class AssistantController {
permissions: user.permissions,
// ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput)
onSideEvent: send,
...(body.sheetBridge === true ? {
sheetCommand: (command: import('./sheet-bridge.service').SheetCommand) => this.sheetBridge.request(scope, command, send, ac.signal),
} : {}),
scope,
// ⭐ 角色层与现场层由服务端按权限装配(见 assistant-prompts 的分层)——
// ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。
......
import { SheetBridgeService } from './sheet-bridge.service';
import { Module } from '@nestjs/common';
import { AiModule } from '../ai/ai.module';
import { AuthModule } from '../auth/auth.module';
......@@ -20,7 +21,7 @@ import { DictationGateway } from './dictation.gateway';
imports: [AiModule, AuthModule, PlanModule],
// 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头)
controllers: [AssistantController, AssistantLabController],
providers: [AssistantService, McpClientService, TranscribeService, DictationGateway],
providers: [SheetBridgeService, AssistantService, McpClientService, TranscribeService, DictationGateway],
exports: [AssistantService], // 供 WeixinAibotModule 等其它入口复用同一个助手大脑
})
export class AssistantModule {}
import type { SheetCommand, SheetReceipt } from './sheet-bridge.service';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
......@@ -10,6 +11,7 @@ import {
TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES,
type SheetSnapshot,
type SheetEditOp,
type TemperatureValue,
} from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service';
......@@ -138,6 +140,7 @@ export interface AssistantChatInput {
* 见 `SheetSnapshotSchema`)。⚠️ 每次请求现取现发,⛔ 这里不许缓存。
*/
sheetState?: SheetSnapshot;
sheetCommand?: (command: SheetCommand) => Promise<SheetReceipt>;
/**
* 详情页当前开着的那位患者 —— **只作指代的默认落点**,⛔ 不注入工具参数
* (注入等于堵死"查另一位",而他随时会问别人)。
......@@ -299,6 +302,13 @@ export class AssistantService {
let guidanceKeys: string[] = [];
/// 本轮出过确认单没有 —— show_sheet 只有在出过之后才有意义
let sheetPushed = false;
let currentRequestId = input.sheetState?.requestId;
const readSheet = async () => {
if (!input.sheetCommand) return { snapshot: null, message: '当前客户端不支持实时回执,请刷新页面后重试' };
const receipt = await input.sheetCommand({ kind: 'read', requestId: currentRequestId });
if (receipt.snapshot) currentRequestId = receipt.snapshot.requestId;
return receipt;
};
tools.propose_assignment = tool({
/**
* 🔴 2026-08-14 按裸台验过的写法重写。删掉的五句各有去处 —— ⛔ 别抄回来:
......@@ -386,6 +396,13 @@ export class AssistantService {
* ⛔ 要恢复"按诊所选",正解是给一个**带名字的 enum**(取值域 = 他有权限的那几家),
* ⛔ 不是把这个裸 id 参数放回来。
*/
patientIds: {
type: 'array', items: { type: 'string', format: 'uuid' }, minItems: 1, maxItems: 100,
description: '指定患者的精确 ID,从患者查询结果取得。点名分配时使用,只选择这些患者,不按人数或人群条件替代。未传时按原批量规则选人。',
},
assignee: {
type: 'string', description: '明确指定的接收人姓名;“我”表示当前登录人。仅与 patientIds 一起使用,不调整其他客服额度。',
},
potentialTreatment: {
type: 'string',
description: POTENTIAL_TREATMENT_DESC,
......@@ -519,6 +536,8 @@ export class AssistantService {
}),
execute: async (args: unknown) => {
const a = (args ?? {}) as {
patientIds?: string[];
assignee?: string;
potentialTreatment?: string;
temperature?: TemperatureValue;
personaTags?: string;
......@@ -535,17 +554,32 @@ export class AssistantService {
if (!clinicId) {
return '当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。';
}
const sheet = await this.proposals.propose(scope, { ...a, clinicId });
if (a.assignee && !a.patientIds?.length) return '指定接收人时请同时提供指定患者';
let assigneeUserId: string | undefined;
if (a.assignee) {
if (a.assignee.trim() === '我') assigneeUserId = scope.userId;
else {
const roster = await this.roster.list(scope, clinicId, { extraUserIds: [scope.userId] });
const matches = roster.agents.filter(x => x.name === a.assignee!.trim());
if (matches.length !== 1) return '接收人未找到或存在同名,本次未生成确认单';
assigneeUserId = matches[0]!.userId;
}
}
const sheet = await this.proposals.propose(scope, a.patientIds
? { clinicId, patientIds: a.patientIds, assigneeUserId, expiresInDays: a.expiresInDays }
: { ...a, clinicId });
// ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)——
// 前端确认时原样回传,这样重连/重试都命中同一个幂等键。
const requestId = `pa_${scope.userId}_${randomUUID()}`;
push({ type: 'assignment_sheet', requestId, sheet });
currentRequestId = requestId;
// 记下这一版有哪几类,供 show_guidance 校验 + 解析成 signalKey(见上面的闭包注释)
// ⚠️ 过滤条件必须与 `modelFacts` 的 `pick` **一致**:只有 action 层进模型上下文。
const actions = sheet.signals.filter((s) => s.tier === 'action');
guidanceKinds = actions.map((s) => GUIDANCE_KIND[s.key] ?? s.key);
guidanceKeys = actions.map((s) => s.key);
sheetPushed = true;
const receipt = await readSheet();
/**
* ⭐ 回给模型的是**结构化事实**,⛔ 不再是成品句子(P2,2026-08-12)。
*
......@@ -562,7 +596,9 @@ export class AssistantService {
*/
return JSON.stringify(
{
确认单已呈现: true,
确认单已呈现: receipt.snapshot != null,
回执: receipt.message,
...(receipt.snapshot ? { 当前确认单: sheetSnapshotFacts(receipt.snapshot) } : {}),
/**
* ⚠️ 事实,不是叮嘱:主管点确认之前一条都没真的分下去。
*
......@@ -573,7 +609,7 @@ export class AssistantService {
* ⚠️ 措辞与提示词里那句「这一下才真的分下去」对齐 —— ⛔ 同一件事别起两个说法。
*/
已经分下去了: false,
...modelFacts(sheet),
...(a.patientIds ? { 选择方式: '指定患者', 已排好: sheet.items.length } : modelFacts(sheet)),
},
null,
2,
......@@ -779,27 +815,27 @@ export class AssistantService {
* 它连累加的材料都没有 —— 卡片上写着 3 天,它嘴上还说 1 天。
* ⇒ 现在两条一起补:回声带上当前值(推),这个工具随时能取(拉)。
*
* ⚠️ 它查的是**随本次请求捎来的那份快照**,⛔ 不是服务端的什么状态 ——
* 服务端没有这个东西,也⛔ 不许为它去开一条"草稿回传"的写路径。
* ⚠️ 无参数:只有一张单可查(现役那张)。⛔ 别加 requestId 之类的参数,
* 每次通过本轮回执通道读取浏览器最新状态;回执不写业务表,不替代确认分配。
* 只有现役那张单可查;明细可按患者或分页读取。⛔ 别加 requestId 之类的参数,
* 模型没有办法知道该填哪个(同 `propose_assignment.clinicId` 被删掉那次的判断)。
*/
tools.get_current_sheet = tool({
description:
'看他眼前那张确认单**现在**什么样:已排好几条、落在几位客服头上、' +
'还有几个待他定、时限几天、带没带福利。' +
'患者姓名、病历号和当前客服归属,以及待分配、移出、时限和福利。' +
'\n什么时候调:他在卡片上动过手(拖人、移出、改时限、改福利)之后,' +
'你要报数、要判断"还差什么"、或者他问"现在什么情况",先调它。' +
'\n⚠️ 卡片上的改动**你看不见** —— ⛔ 别拿出方案那一版的数去算现在的数。' +
'\n⛔ 它不改任何东西,只是看一眼。',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: async () => {
inputSchema: jsonSchema({ type: 'object', properties: {
patient: { type: 'string', description: '只查指定患者的姓名或病历号,精确匹配;不传读取名单。' },
offset: { type: 'integer', minimum: 0, description: '名单起点,默认 0;继续读取用返回的下一页起点。' },
limit: { type: 'integer', minimum: 1, maximum: 100, description: '本次明细条数,默认 50,最多 100。概览仍为整单。' },
} }),
execute: async (args: unknown) => {
// ⚠️ 没有 = **他手上根本没有确认单**,⛔ 别回一个空壳让模型以为是"全 0 的单"
if (!input.sheetState) {
return '他手上现在没有确认单 —— 还没出过,或者那一版已经作废了。';
}
this.logger.log('工具调用:get_current_sheet');
return JSON.stringify(sheetSnapshotFacts(input.sheetState), null, 2);
const receipt = await readSheet();
return JSON.stringify({ 回执: receipt.message, 当前确认单: receipt.snapshot ? sheetSnapshotFacts(receipt.snapshot, (args ?? {}) as { patient?: string; offset?: number; limit?: number }) : null });
},
});
......@@ -815,9 +851,7 @@ export class AssistantService {
'\n患者用姓名或病历号指代,客服用姓名 —— 匹配由界面拿它手里那份单子完成。' +
// ⚠️ 这里说的是**先后**不是**位置**:位置("上面")是他屏幕上的事,你看不见,
// 写成位置等于教模型去描述界面 —— 而 base 里明令 ⛔ 别描述界面位置。
'\n⚠️ 改完**什么都不用补**:界面会把结果直接发进对话,而且**先于你这段话**到他那儿。' +
'⛔ 别说"已完成"(成没成只有界面知道),也⛔ 别说"等结果再告诉您" ——' +
'他读到那句时结果早就在了,顺序是倒的。' +
'\n工具等待界面执行后返回真实回执和当前患者归属;未匹配、部分执行或未收到回执都不能当成成功。' +
'\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' +
'界面会真的改并回报"有几条已经写好的话术作废了 / 几条客服已经打开过");' +
'人员和时限**在这张单上改不了** —— 单子已经在客服手上。' +
......@@ -931,17 +965,10 @@ export class AssistantService {
if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,确认单未改动。';
const bad = checkEditOps(ops, SheetEditOpSchema);
if (bad) return bad;
push({ type: 'assignment_sheet_edit', ops });
/**
* ⚠️ 只回**事实**,⛔ 一个字的指令都不许有。
*
* 🔴 2026-08-13:原来这里写着「请等那句结果,⛔ 不要自己宣布已完成」——
* 模型把它当话说了出去:「已把这条修改交给界面执行,等它回报结果再告诉您。」
* 而界面的结果**就在那句话上面一行**(它是同步注入的),读起来是倒的:
* 结果已经出来了,助手却还在说"等结果"。
* ⇒ 指令归工具描述,返回值只留事实(这条规矩 2026-08-08 就定了,这里漏网)。
*/
return JSON.stringify({ 已交给界面执行: ops.length, 界面已把结果发进对话: true });
if (!input.sheetCommand) return '当前客户端不支持真实回执,确认单未修改;请刷新页面后重试';
const receipt = await input.sheetCommand({ kind: 'edit', requestId: currentRequestId, ops: ops as SheetEditOp[] });
if (receipt.snapshot) currentRequestId = receipt.snapshot.requestId;
return JSON.stringify({ 回执: receipt.message, 当前确认单: receipt.snapshot ? sheetSnapshotFacts(receipt.snapshot) : null });
},
});
......@@ -1194,7 +1221,7 @@ export class AssistantService {
// 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
const resolved = this.provider.resolve(input.modelId ?? 'deepseek');
/**
* ⚠️ 日志打**解析后的型号**(`deepseek-v4-flash`),⛔ 不是逻辑键(`deepseek`)。
* ⚠️ 日志打**解析后的型号**(`deepseek-flash`),⛔ 不是逻辑键(`deepseek`)。
* 裸键会被 `AI_DEFAULT_MODEL` 解析成具体型号,只打裸键的话
* 「这一轮到底跑的哪个模型」从日志里根本答不出来 —— 实测被这个问住过。
*/
......@@ -1211,6 +1238,9 @@ export class AssistantService {
* ⚠️ `scope` 缺失(比如某些内部入口)就整条跳过 —— hostId/tenantId 是必填列。
*/
const toolTrace: ToolTraceEntry[] = [];
// 同一轮的出单、改单、读单串行,防止模型并行调用读到前一版。
let sheetQueue: Promise<unknown> = Promise.resolve();
const sheetTools = new Set(['propose_assignment', 'edit_assignment_sheet', 'get_current_sheet']);
// 统一给**所有**工具(MCP 拉来的 + 本地那几个)套一层计时留痕。
// ⚠️ 装在这里而不是各自 execute 里:漏一个就等于那个工具永远不出现在验收数据里。
for (const name of Object.keys(tools)) {
......@@ -1223,7 +1253,9 @@ export class AssistantService {
t.execute = async (args: any, opts: any) => {
const t0 = Date.now();
try {
const r = await orig(args, opts);
const operation = sheetTools.has(name) ? sheetQueue.then(() => orig(args, opts)) : orig(args, opts);
if (sheetTools.has(name)) sheetQueue = Promise.resolve(operation).catch(() => undefined);
const r = await operation;
toolTrace.push({ name, ok: true, ms: Date.now() - t0, args: briefToolArgs(args) });
return r;
} catch (e) {
......
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { SheetSnapshotSchema, type SheetSnapshot, type SheetEditOp } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
export interface SheetCommand { kind: 'read' | 'edit'; requestId?: string; ops?: SheetEditOp[] }
export interface SheetReceipt { snapshot: SheetSnapshot | null; message: string }
/** 回执仅用于本轮工具核验,不是分配授权,也不写业务数据。 */
@Injectable()
export class SheetBridgeService {
private readonly pending = new Map<string, { owner: string; requestId?: string; finish: (r: SheetReceipt) => void }>();
private owner(s: TenantScopeContext): string {
return JSON.stringify([s.hostId, s.tenantId, s.userId, [...s.clinicIds].sort(), [...s.sourceUnits].sort()]);
}
request(scope: TenantScopeContext, command: SheetCommand, send: (e: Record<string, unknown>) => void,
signal?: AbortSignal, timeoutMs = 12000): Promise<SheetReceipt> {
if (signal?.aborted) return Promise.resolve({ snapshot: null, message: '连接已断开,执行结果未确认' });
const commandId = randomUUID();
return new Promise(resolve => {
const finish = (r: SheetReceipt) => {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
this.pending.delete(commandId);
resolve(r);
};
const abort = () => finish({ snapshot: null, message: '连接已断开,执行结果未确认;重新读取确认单后再操作' });
const timer = setTimeout(() => finish({ snapshot: null, message: '未收到界面回执,执行结果未知;重新读取确认单后再操作' }), timeoutMs);
this.pending.set(commandId, { owner: this.owner(scope), requestId: command.requestId, finish });
signal?.addEventListener('abort', abort, { once: true });
try { send({ type: 'assignment_sheet_command', commandId, ...command }); }
catch { abort(); }
});
}
receive(scope: TenantScopeContext, id: string, raw: unknown): void {
const pending = this.pending.get(id);
if (!pending || pending.owner !== this.owner(scope)) throw new NotFoundException('回执已过期或不属于当前会话');
const body = raw as { snapshot?: unknown; message?: unknown };
const parsed = SheetSnapshotSchema.safeParse(body?.snapshot);
if (parsed.success && pending.requestId && parsed.data.requestId !== pending.requestId) {
pending.finish({ snapshot: null, message: '确认单版本不一致,执行结果未确认' });
return;
}
pending.finish({ snapshot: parsed.success ? parsed.data : null,
message: typeof body?.message === 'string' ? body.message.slice(0, 12000) : '未提供执行说明' });
}
}
......@@ -32,7 +32,7 @@ import { GUIDANCE_KIND } from './assignment-signals';
* ⚠️ **例外一条、且只有一条**:每条引导上的 `开放方式`(见 `GUIDANCE_HOWTO`)。
* 2026-08-17 产品实测定的,破例的理由和它的边界都写在那个常量上。
* ⛔ 别照着它再开第二处 —— 一个"有过先例"的例外会变成没有边界。
* ⛔ 不含任何 planId / 患者姓名 —— 那些走侧信道给界面,模型手里不该有。
* 批量概览不含患者明细;确认单核验工具按需要提供患者及归属。
* ⚠️ 键名用中文:模型照着键名说就是对的,省掉一轮"把 inHandPending 翻译成人话"。
*/
......@@ -264,8 +264,16 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
* 前者是"都处置完了",后者是"不知道"。⛔ 别学别处的 `...(x ? {} : {})` 省略法。
* ⚠️ `已经分下去了` 与 `propose_assignment` 返回值里那个键**同名同义**,⛔ 别起第二个说法。
*/
export function sheetSnapshotFacts(s: SheetSnapshot): Record<string, unknown> {
export function sheetSnapshotFacts(s: SheetSnapshot, query: { patient?: string; offset?: number; limit?: number } = {}): Record<string, unknown> {
const patient = query.patient?.trim();
const patients = s.patients?.filter(p => !patient || p.name === patient || p.medicalRecordNumber === patient);
const offset = Number.isInteger(query.offset) ? Math.max(0, query.offset!) : 0;
const limit = Number.isInteger(query.limit) ? Math.min(100, Math.max(1, query.limit!)) : 50;
return {
确认单版本: s.requestId,
患者明细总数: patients?.length ?? null,
下一页起点: patients && offset + limit < patients.length ? offset + limit : null,
患者明细: patients?.slice(offset, offset + limit).map(p => ({ 姓名: p.name, 病历号: p.medicalRecordNumber, 客服: p.assigneeName, 状态: p.status })) ?? null,
/**
* ⭐ **人群条件排在最前** —— 「这批人是谁」是重算的前提,⛔ 不能让它排在
* 一堆分配结果后面:模型读到「改成 200 人」时第一件要确认的就是这一格是什么。
......
......@@ -126,6 +126,9 @@ export class AssignmentProposalService {
scope: TenantScopeContext,
input: {
clinicId: string;
/** 精确选择患者;接收人可指定,不影响原批量规则。 */
patientIds?: string[];
assigneeUserId?: string;
/** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */
potentialTreatment?: string;
/** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */
......@@ -187,8 +190,14 @@ export class AssignmentProposalService {
*/
const clinicId = resolveClinicId(scope, input.clinicId);
const { potentialTreatment } = input;
const patientIds = input.patientIds ? [...new Set(input.patientIds)] : undefined;
if (patientIds && (!patientIds.length || patientIds.length > 100 || patientIds.some(id => !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)))) {
throw new Error('指定患者应为 1–100 个有效患者 ID');
}
if (input.assigneeUserId && !patientIds) throw new Error('指定接收人必须同时指定患者');
const criteria: CohortCriteria = {
clinicId,
...(patientIds ? { patientIds } : {}),
...(potentialTreatment ? { potentialTreatment } : {}),
...(input.temperature ? { temperature: input.temperature } : {}),
...(input.personaTags ? { personaTags: input.personaTags } : {}),
......@@ -196,10 +205,13 @@ export class AssignmentProposalService {
};
assertCohortCriteria(criteria);
const [roster, baseline] = await Promise.all([
this.roster.list(scope, clinicId),
this.roster.list(scope, clinicId, input.assigneeUserId === scope.userId ? { extraUserIds: [scope.userId] } : {}),
this.resolveBaseline(scope, clinicId),
]);
const agents = roster.agents;
if (input.assigneeUserId && !agents.some(a => a.userId === input.assigneeUserId)) {
throw new Error('指定接收人不在当前诊所可分配名册中');
}
// ── 两个基数:本批人数 N + 时限 ──────────────────────────
// 优先级:本次明确指定 > 沿用上一次 > 首次默认。
......@@ -236,12 +248,12 @@ export class AssignmentProposalService {
input.dailyCalls != null
? Math.min(60, Math.max(1, Math.floor(input.dailyCalls)))
: DAILY_CALLS_PER_AGENT;
const batchSize =
const batchSize = patientIds ? patientIds.length :
input.targetCount != null
? Math.max(0, Math.floor(input.targetCount))
: agents.length * dailyCalls * expiresInDays;
const basis: 'inherited' | 'default' | 'explicit' =
input.targetCount != null || input.expiresInDays != null || input.dailyCalls != null
patientIds || input.targetCount != null || input.expiresInDays != null || input.dailyCalls != null
? 'explicit'
: 'default';
// 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢);空对象 = 撤掉他的精调
......@@ -290,6 +302,9 @@ export class AssignmentProposalService {
this.poolOwnership(scope, criteria, agents, now),
]);
const candidateTotal = counts.total;
if (patientIds && new Set(pool.map(c => c.patientId)).size !== patientIds.length) {
throw new Error('部分指定患者不在当前诊所可分配召回池中,本次未生成确认单');
}
// ⭐ **每一版提案都记下"按什么条件、圈出多少人"** —— 这不是调试日志,是这条生产线的黑匣子。
// 主管连着说三句话就会出三版确认单,人数一版一个样时,唯一能分清
// 「是条件变了」还是「是取数飘了」的证据就是这行(2026-08-06 排查 12→17→13 用的就是它)。
......@@ -373,7 +388,9 @@ export class AssignmentProposalService {
: [...head, ...explore];
// ── ② 落人:专属优先 → 无主补空 → 待分配(两种模式**同一套**)────────
const raw = placeAgents(chosen, agents, dedicated, capOf);
const raw = input.assigneeUserId
? { placed: chosen.map(c => ({ ...c, assigneeUserId: input.assigneeUserId!, assignStrategy: AssignStrategy.MANUAL })), unplaced: 0, pending: [] as PendingItem[] }
: placeAgents(chosen, agents, dedicated, capOf);
// ⭐ 身份(姓名/病历号)在**落人之后**贴上 —— placeAgents 是纯算法,
// 让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。
/// 贴身份的公共部分 —— 已落人的和待分配的用**同一份**,⛔ 别各贴各的(会漂)
......@@ -636,8 +653,8 @@ export class AssignmentProposalService {
: `,没有需要您定的了。`),
};
// ⚠️ 「再筛一刀」单独查库,⛔ 不放进 computeSignals —— 那个函数要保持纯。
const narrow = await this.narrowSignal(scope, criteria, now, input.preferPlaceable === true);
return { ...proposal, signals: computeSignals(proposal as AssignmentProposal, narrow) };
const narrow = patientIds ? undefined : await this.narrowSignal(scope, criteria, now, input.preferPlaceable === true);
return { ...proposal, canRefill: patientIds ? false : proposal.canRefill, signals: patientIds ? [] : computeSignals(proposal as AssignmentProposal, narrow) };
}
/**
......
......@@ -25,6 +25,8 @@ import { labelExistsSql, labelTemperatureExistsSql } from './reason-temperature.
*/
export interface CohortCriteria {
/** 定向分配的精确患者集合,仍受宿主、品牌、诊所和召回池范围限制。 */
patientIds?: string[];
/** 批次不跨诊所 */
clinicId: string;
/** 矩阵 X 轴:画像「潜在治疗」8 类之一 */
......@@ -195,6 +197,7 @@ export function cohortWhereSql(
assertCohortCriteria(c);
return Prisma.sql`
${poolBaseSql(scope, c.clinicId)}
${c.patientIds ? Prisma.sql`AND fp.patient_id = ANY(${c.patientIds}::uuid[])` : Prisma.empty}
${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty}
${
c.temperature && c.potentialTreatment
......
......@@ -117,7 +117,8 @@ describe('确认单草稿态 —— 前端产、请求带、工具读', () => {
expect(CHAT_HOOK).toMatch(/sheetState:\s*useAssistantStore\.getState\(\)\.sheetSnapshot/);
expect(CONTROLLER).toMatch(/SheetSnapshotSchema\.safeParse\(body\.sheetState\)/);
expect(ASSISTANT).toMatch(/tools\.get_current_sheet\s*=\s*tool\(/);
expect(ASSISTANT).toContain('sheetSnapshotFacts(input.sheetState)');
expect(ASSISTANT).toContain('sheetSnapshotFacts(receipt.snapshot)');
expect(ASSISTANT).not.toContain('sheetSnapshotFacts(input.sheetState)');
});
test('⭐ 解不出来就当没有 —— ⛔ 不许把半份快照喂给模型(缺的字段会被读成 0)', () => {
......
import { AssignmentProposalService } from '../src/modules/plan/assignment-proposal.service';
import { cohortWhereSql } from '../src/modules/plan/cohort-filter';
const pid = '11111111-1111-4111-8111-111111111111';
const scope = { hostId: 'h', tenantId: 't', userId: 'admin', clinicIds: ['c'], sourceUnits: ['brand'] };
function setup(found = true) {
const query = jest.fn(async (sql: { strings: string[]; values: unknown[] }) => {
const text = sql.strings.join(' ');
if (text.includes('count(DISTINCT')) return [{ n: BigInt(found ? 1 : 0) }];
return found ? [{ planId: 'plan', patientId: pid, priorityScore: 85 }] : [];
});
const service = new AssignmentProposalService({ $queryRaw: query,
patient: { findMany: async () => [{ id: pid, name: '于常瑞', medicalRecordNumber: 'demo', preferences: null }] },
planAssignment: { findFirst: async () => null },
} as never, { list: async () => ({ agents: [{ userId: 'admin', name: '管理员', inHand: 39 }, { userId: 'other', name: '其他客服', inHand: 0 }], rosterNote: '' }) } as never, {} as never);
return { service, query };
}
describe('指定患者分配与批量规则隔离', () => {
it('仅选指定患者,直接给管理员,不调整其他客服额度', async () => {
const { service, query } = setup();
const result = await service.propose(scope, { clinicId: 'c', patientIds: [pid], assigneeUserId: 'admin' });
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({ patientId: pid, assigneeUserId: 'admin', assignStrategy: 'manual' });
expect(result.agentOverrides).toEqual({});
expect(result.target).toBe(1);
expect(result.canRefill).toBe(false);
for (const [sql] of query.mock.calls) expect(sql.values).toContainEqual([pid]);
});
it('有患者不在可分配范围时整单拒绝,不换成别人补足人数', async () => {
await expect(setup(false).service.propose(scope, { clinicId: 'c', patientIds: [pid], assigneeUserId: 'admin' })).rejects.toThrow('不在当前诊所');
});
it('跨诊所与未知接收人拒绝', async () => {
await expect(setup().service.propose(scope, { clinicId: 'other', patientIds: [pid] })).rejects.toThrow();
await expect(setup().service.propose(scope, { clinicId: 'c', patientIds: [pid], assigneeUserId: 'outsider' })).rejects.toThrow('名册');
});
it('指定患者的 SQL 保留品牌、租户和召回池约束', () => {
const sql = cohortWhereSql(scope, { clinicId: 'c', patientIds: [pid] }, new Date());
expect(sql.sql).toContain('assignee_user_id IS NULL');
expect(sql.sql).toContain('source_unit');
expect(sql.values).toEqual(expect.arrayContaining(['h', 't', 'c', 'brand', [pid]]));
expect(cohortWhereSql(scope, { clinicId: 'c' }, new Date()).sql).not.toContain('fp.patient_id = ANY');
});
});
import { loadConfig } from '../src/config/configuration';
import { AiProviderService } from '../src/modules/ai/core/ai-provider.service';
import { estimateCostYuan } from '../src/modules/ai/core/cost';
import { ConfigService } from '@nestjs/config';
import type { AppConfig } from '../src/config/configuration';
jest.mock('@ai-sdk/deepseek', () => ({ createDeepSeek: () => (id: string) => ({ modelId: id }) }));
jest.mock('@ai-sdk/google', () => ({ createGoogleGenerativeAI: () => (id: string) => ({ modelId: id }) }));
jest.mock('@ai-sdk/openai-compatible', () => ({ createOpenAICompatible: () => (id: string) => ({ modelId: id }) }));
describe('DeepSeek Flash 型号与计价迁移', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
DATABASE_URL: 'postgresql://unused', REDIS_URL: 'redis://unused',
JWT_SECRET: 'test-only', JWT_REFRESH_SECRET: 'test-only',
DEEPSEEK_API_KEY: 'test-only', GEMINI_API_KEY: 'test-only', QWEN_API_KEY: 'test-only',
};
});
afterEach(() => { process.env = originalEnv; });
function provider() {
const config = loadConfig();
return new AiProviderService(new ConfigService<AppConfig, true>(config));
}
it('默认配置、SDK 请求和落账型号一致', () => {
expect(loadConfig().ai.defaultModel).toBe('deepseek-flash');
const resolved = provider().resolve('deepseek');
expect(resolved.modelId).toBe('deepseek-flash');
expect(resolved.model).toMatchObject({ modelId: 'deepseek-flash' });
expect(resolved.provider).toBe('deepseek');
});
it.each(['deepseek-v4-flash', 'deepseek-v4-flash-vision-exp'])('兼容环境变量和显式旧别名 %s', alias => {
process.env.AI_DEFAULT_MODEL = alias;
expect(loadConfig().ai.defaultModel).toBe('deepseek-flash');
expect(provider().getDefaultModelId()).toBe('deepseek-flash');
expect(provider().resolve(alias).model).toMatchObject({ modelId: 'deepseek-flash' });
});
it('保留显式 Pro 配置与其他供应商路由', () => {
process.env.AI_DEFAULT_MODEL = 'deepseek-v4-pro';
expect(provider().resolve('deepseek').modelId).toBe('deepseek-v4-pro');
expect(provider().resolve('qwen').provider).toBe('qwen');
expect(provider().resolve('gemini').provider).toBe('gemini');
});
it.each(['deepseek-flash', 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp'])('%s 按 Flash 价估算,不误落 Pro 兜底', model => {
const table = loadConfig().ai.priceTable;
expect(estimateCostYuan(table, model, 1_000_000, 1_000_000, 500_000))
.toEqual({ yuan: 9.02, priceMissing: false });
});
it('沿用旧名称的自定义价格,不悄悄替换用户配置', () => {
process.env.AI_PRICE_TABLE_JSON = JSON.stringify({ 'deepseek-v4-flash': { inHit: 1, inMiss: 2, out: 3 } });
expect(estimateCostYuan(loadConfig().ai.priceTable, 'deepseek-flash', 1_000_000, 1_000_000))
.toEqual({ yuan: 5, priceMissing: false });
});
it('自定义价格同时有新旧名称时,以新名称为准', () => {
const table = {
'deepseek-flash': { inHit: 1, inMiss: 2, out: 3 },
'deepseek-v4-flash': { inHit: 7, inMiss: 8, out: 9 },
};
expect(estimateCostYuan(table, 'deepseek-v4-flash', 1_000_000, 1_000_000))
.toEqual({ yuan: 5, priceMissing: false });
});
});
import { SheetBridgeService } from '../src/modules/assistant/sheet-bridge.service';
import { sheetSnapshotFacts } from '../src/modules/plan/assignment-facts';
import type { SheetSnapshot } from '@pac/types';
const scope = { hostId: 'h', tenantId: 't', userId: 'u', clinicIds: ['c'], sourceUnits: ['brand'] };
const snapshot: SheetSnapshot = { requestId: 's1', confirmed: false, placed: 1, agents: 1, pending: 0, dropped: 0, moved: 1, expiresInDays: 3, rowExpiryOverrides: 0, benefit: null,
byAgent: [{ name: '管理员', count: 1 }], criteria: { potentialTreatment: null, temperature: null, narrowedBy: null, candidateTotal: 1, batchSize: 1 },
patients: [{ patientId: 'p1', planId: 'plan1', name: '于常瑞', medicalRecordNumber: 'demo', assigneeUserId: 'u', assigneeName: '管理员', status: 'placed' }] };
describe('确认单真实回执', () => {
it('发出指令不等于成功,收到界面状态后才返回', async () => {
const bridge = new SheetBridgeService(); const send = jest.fn(); let resolved = false;
const waiting = bridge.request(scope, { kind: 'edit', requestId: 's1', ops: [] }, send).then(r => { resolved = true; return r; });
await Promise.resolve(); expect(resolved).toBe(false);
bridge.receive(scope, send.mock.calls[0][0].commandId, { snapshot, message: '于常瑞已改派给管理员' });
expect((await waiting).snapshot?.patients?.[0]?.assigneeName).toBe('管理员');
expect(sheetSnapshotFacts(snapshot).患者明细).toEqual([{ 姓名: '于常瑞', 病历号: 'demo', 客服: '管理员', 状态: 'placed' }]);
});
it('大名单有明确分页,点名可直接查到末尾患者', () => {
const large = { ...snapshot, patients: Array.from({ length: 120 }, (_, i) => ({ ...snapshot.patients![0]!, name: `患者${i}` })) };
expect(sheetSnapshotFacts(large)).toMatchObject({ 患者明细总数: 120, 下一页起点: 50 });
expect(sheetSnapshotFacts(large).患者明细).toHaveLength(50);
expect(sheetSnapshotFacts(large, { patient: '患者119' })).toMatchObject({ 患者明细总数: 1, 下一页起点: null, 患者明细: [expect.objectContaining({ 姓名: '患者119' })] });
expect(sheetSnapshotFacts(large, { offset: 100 }).患者明细).toHaveLength(20);
});
it('不接受其他用户、品牌、诊所范围的回执', async () => {
const bridge = new SheetBridgeService(); const send = jest.fn();
const waiting = bridge.request(scope, { kind: 'read' }, send);
const id = send.mock.calls[0][0].commandId;
for (const other of [{ ...scope, userId: 'other' }, { ...scope, sourceUnits: ['other'] }, { ...scope, clinicIds: ['other'] }]) {
expect(() => bridge.receive(other, id, { snapshot })).toThrow();
}
bridge.receive(scope, id, { snapshot, message: '最新' }); await waiting;
expect(() => bridge.receive(scope, id, { snapshot })).toThrow();
});
it('旧版本回执不能冒充当前确认单', async () => {
const bridge = new SheetBridgeService(); const send = jest.fn();
const waiting = bridge.request(scope, { kind: 'read', requestId: 'new' }, send);
bridge.receive(scope, send.mock.calls[0][0].commandId, { snapshot });
expect(await waiting).toMatchObject({ snapshot: null, message: expect.stringContaining('版本不一致') });
});
it('超时及断开均返回未确认,不报成功;晚回执拒绝', async () => {
const bridge = new SheetBridgeService(); const send = jest.fn();
const result = await bridge.request(scope, { kind: 'edit' }, send, undefined, 5);
expect(result).toMatchObject({ snapshot: null, message: expect.stringContaining('未知') });
expect(() => bridge.receive(scope, send.mock.calls[0][0].commandId, { snapshot })).toThrow();
const abort = new AbortController(); const waiting = bridge.request(scope, { kind: 'read' }, send, abort.signal);
abort.abort(); expect((await waiting).snapshot).toBeNull();
});
});
'use client';
import { finishSheetCommand } from './sheet-command';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import {
......@@ -158,7 +159,7 @@ export function AssignmentConfirmSheet({
* 助手下发的语义编辑指令队列(edit_assignment_sheet)。
* ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。
*/
edits?: { seq: number; ops: SheetEditOp[] }[];
edits?: { seq: number; ops: SheetEditOp[]; commandId?: string }[];
/** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */
/**
* 改单结果进对话。
......@@ -464,6 +465,14 @@ export function AssignmentConfirmSheet({
const snapshot = useMemo<SheetSnapshot>(
() => ({
requestId,
patients: [...sheet.items, ...(sheet.pending ?? [])].map(p => {
const assignee = moveByPlan[p.planId] ?? ('assigneeUserId' in p ? p.assigneeUserId : null);
const agent = [...sheet.byAgent, ...sheet.skippedAgents].find(a => a.userId === assignee);
return { patientId: p.patientId, planId: p.planId, name: p.patientName,
medicalRecordNumber: p.medicalRecordNumber, assigneeUserId: dropped.has(p.planId) ? null : assignee,
assigneeName: dropped.has(p.planId) ? null : agent?.name ?? null,
status: dropped.has(p.planId) ? 'removed' as const : assignee ? 'placed' as const : 'pending' as const };
}),
confirmed: assignmentId != null,
placed: items.length,
agents: groups.length,
......@@ -492,6 +501,8 @@ export function AssignmentConfirmSheet({
items,
groups,
pendingList.length,
moveByPlan,
dropped,
dropped.size,
expiresInDays,
dayByPlan,
......@@ -507,12 +518,13 @@ export function AssignmentConfirmSheet({
* ⛔ 不会报错。
*/
useEffect(() => {
if (state !== 'active' && state !== 'confirmed') return;
useAssistantStore.getState().setSheetSnapshot(snapshot);
return () => {
const s = useAssistantStore.getState();
if (s.sheetSnapshot?.requestId === snapshot.requestId) s.setSheetSnapshot(null);
};
}, [snapshot]);
}, [snapshot, state]);
/**
* 回声 —— **主管看增量,模型另外拿当前值**。
......@@ -537,6 +549,17 @@ export function AssignmentConfirmSheet({
const [echoSeq, setEchoSeq] = useState(0);
const snapRef = useRef(snapshot);
snapRef.current = snapshot;
const commandReceipts = useRef<{ id: string; message: string }[]>([]);
const [receiptSeq, setReceiptSeq] = useState(0);
const acknowledge = (id: string | undefined, message: string) => {
if (!id) return;
commandReceipts.current.push({ id, message });
setReceiptSeq(n => n + 1);
};
useEffect(() => {
const receipts = commandReceipts.current.splice(0);
for (const r of receipts) finishSheetCommand(r.id, snapRef.current, r.message);
}, [receiptSeq]);
const echo = useCallback((summary: string) => {
echoQueue.current.push(summary);
setEchoSeq((n) => n + 1);
......@@ -728,33 +751,20 @@ export function AssignmentConfirmSheet({
// ⚠️ 2026-08-20 起它们**不是没辙了**:走「调整各客服在手的单」那条路(独立的一张单)。
// ⛔ 别再把话说成「只能撤销重分」,那句从这天起就是错的。
if (assignmentId) {
for (const { ops } of fresh) {
for (const op of ops) {
if (op.action === 'set_benefit') {
void assignmentsApi
// ⚠️ schema 的 superRefine 保证 set_benefit 必有 text;`?? ''` 只是类型兜底
.setBenefit(assignmentId, op.text ?? '')
.then((r) => {
setBenefit(r.benefitText ?? '');
// ⭐ 回报**服务端那句**,不是前端自己编的 —— 里面有"作废了几条话术缓存"
// 和"几条客服已经打开过",这两个数只有服务端知道(T14)。
echo(`确认单已更新:${r.note}`);
})
.catch((e) =>
echo(
`福利没改成:${e instanceof Error ? e.message : '接口报错'} —— 请如实告诉主管,⛔ 不要说已经改好了。`,
),
);
continue;
void (async () => {
for (const { ops, commandId } of fresh) {
const results: string[] = [];
for (const op of ops) {
if (op.action !== 'set_benefit') { results.push('已确认批次不能在草稿上修改人员或时限,请使用在手调整'); continue; }
try {
const r = await assignmentsApi.setBenefit(assignmentId, op.text ?? '');
setBenefit(r.benefitText ?? ''); results.push(r.note);
} catch (e) { results.push(`福利未修改:${e instanceof Error ? e.message : '请求失败'}`); }
}
notes.push(
`这批**已经确认分配**了,「${OP_ZH[op.action] ?? op.action}」在这张单上改不了 —— ` +
`单子已经在客服手上 —— 让我把在手的摆出来调(换人、改时限、移出),` +
`或者撤销整批重新分`,
);
const message = results.join(';');
echo(message); acknowledge(commandId, message);
}
}
if (notes.length) echo(`确认单没有改动:${notes.join(';')}。`);
})();
return;
}
/**
......@@ -807,7 +817,7 @@ export function AssignmentConfirmSheet({
const k = q.trim();
const inSheet = groups.find((g) => g.name === k || g.userId === k);
if (inSheet) return inSheet;
const idle = sheet.skippedAgents.find((a) => a.name === k || a.userId === k);
const idle = [...sheet.byAgent.map(a => ({ userId: a.userId, name: a.name, inHand: a.inHandBefore })), ...sheet.skippedAgents].find((a) => a.name === k || a.userId === k);
return idle
? { userId: idle.userId, name: idle.name, inHandBefore: idle.inHand, list: [] }
: null;
......@@ -1132,6 +1142,7 @@ export function AssignmentConfirmSheet({
setMoveByPlan((m) => ({ ...m, ...moveDraft }));
setDropped((sset) => new Set([...sset, ...dropDraft]));
if (notes.length) echo(`确认单已更新:${notes.join(';')}。`);
for (const e of fresh) acknowledge(e.commandId, notes.join(';') || '没有匹配到可修改的内容');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edits]);
......
......@@ -263,6 +263,7 @@ function Markdown({ text: raw }: { text: string }) {
}
function BlockView({
hideSheet,
block,
onSheetConfirmed,
onSheetEdited,
......@@ -279,6 +280,7 @@ function BlockView({
orphanKeys,
}: {
block: Block;
hideSheet?: boolean;
onSheetConfirmed?: (
requestId: string,
assignmentId: string,
......@@ -357,7 +359,7 @@ function BlockView({
}
if (block.kind === 'assignment_sheet') {
return (
<AssignmentConfirmSheet
<div hidden={hideSheet}><AssignmentConfirmSheet
requestId={block.requestId}
sheet={block.sheet}
state={block.state}
......@@ -375,7 +377,7 @@ function BlockView({
onConfirmed={(id, summary, modelFacts) =>
onSheetConfirmed?.(block.requestId, id, summary, modelFacts)
}
/>
/></div>
);
}
/**
......@@ -837,7 +839,7 @@ function MessageView({
const ordered = [
...body,
...(streaming ? [] : orphan),
...(anchorAt !== -1 || streaming ? [] : sheets),
...(anchorAt !== -1 ? [] : sheets),
// 与确认单同一条规矩:漏调锚点 / 无根 → 等说完、钉末尾。⛔ 卡片永远不会不出现
...(rearrangeAnchorAt !== -1 || streaming ? [] : rearranges),
];
......@@ -866,6 +868,7 @@ function MessageView({
: `b:${i}`
}
block={b}
hideSheet={b.kind === 'assignment_sheet' && !!streaming && anchorAt === -1}
placedKeys={placedKeys}
signalOf={signalOf}
streaming={streaming}
......
......@@ -128,7 +128,7 @@ export type Block =
* 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。
* ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
*/
edits?: { seq: number; ops: SheetEditOp[] }[];
edits?: { seq: number; ops: SheetEditOp[]; commandId?: string }[];
}
/**
* 调整单卡片**摆在这** —— 位置由模型决定(`show_rearrange` 工具)。
......
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { SheetSnapshot } from '@pac/types';
import { useAssistantStore } from '@/stores/assistant-store';
import { readLiveSheet, waitSheetEdit, finishSheetCommand, postSheetReceipt } from './sheet-command';
vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ accessToken: 'new-session' }) } }));
const snapshot = (requestId: string) => ({ requestId, patients: [{ name: '患者甲', assigneeName: '管理员' }] } as SheetSnapshot);
afterEach(() => { useAssistantStore.setState({ sheetSnapshot: null }); vi.useRealTimers(); vi.unstubAllGlobals(); });
describe('确认单实时回执', () => {
it('等新单挂载,不把旧单当成新单', async () => {
useAssistantStore.setState({ sheetSnapshot: snapshot('old') });
const read = readLiveSheet('new');
let done = false; void read.then(() => { done = true; });
await Promise.resolve(); expect(done).toBe(false);
useAssistantStore.setState({ sheetSnapshot: snapshot('new') });
expect((await read).snapshot?.requestId).toBe('new');
});
it('每次读取当前版本的患者及归属', async () => {
useAssistantStore.setState({ sheetSnapshot: snapshot('a') });
expect((await readLiveSheet()).snapshot?.patients?.[0]?.assigneeName).toBe('管理员');
const changed = snapshot('a'); changed.patients![0]!.assigneeName = '客服乙';
useAssistantStore.setState({ sheetSnapshot: changed });
expect((await readLiveSheet()).snapshot?.patients?.[0]?.assigneeName).toBe('客服乙');
});
it('收到执行完成回执才返回,未知命令不影响待处理命令', async () => {
const edit = waitSheetEdit('one'); let done = false; void edit.then(() => { done = true; });
finishSheetCommand('other', snapshot('a'), '无关'); await Promise.resolve(); expect(done).toBe(false);
finishSheetCommand('one', snapshot('a'), '已改派给管理员');
expect(await edit).toMatchObject({ message: '已改派给管理员', snapshot: { requestId: 'a' } });
});
it('丢回执和找不到指定版本明确返回未知,不宣告成功', async () => {
vi.useFakeTimers();
const edit = waitSheetEdit('timeout'), read = readLiveSheet('missing');
await vi.advanceTimersByTimeAsync(9000);
expect(await edit).toMatchObject({ snapshot: null, message: expect.stringContaining('未确认') });
expect(await read).toMatchObject({ snapshot: null, message: expect.stringContaining('未就绪') });
});
it('登录切换后不借新令牌发送旧回执', async () => {
const fetcher = vi.fn(); vi.stubGlobal('fetch', fetcher);
await postSheetReceipt('one', { snapshot: snapshot('a'), message: '完成' }, 'old-session');
expect(fetcher).not.toHaveBeenCalled();
});
});
import type { SheetSnapshot } from '@pac/types';
import { useAssistantStore } from '@/stores/assistant-store';
import { useAuthStore } from '@/stores/auth-store';
import { env } from '@/lib/env';
type Receipt = { snapshot: SheetSnapshot | null; message: string };
const pending = new Map<string, (r: Receipt) => void>();
/** 回执必须在卡片提交 React 状态之后调用。 */
export function finishSheetCommand(id: string, snapshot: SheetSnapshot | null, message: string) {
pending.get(id)?.({ snapshot, message });
}
export function waitSheetEdit(id: string): Promise<Receipt> {
return new Promise(resolve => {
const timer = setTimeout(() => finishSheetCommand(id, null, '界面执行未确认,请重新读取确认单'), 9000);
pending.set(id, r => { clearTimeout(timer); pending.delete(id); resolve(r); });
});
}
export function readLiveSheet(requestId?: string): Promise<Receipt> {
const current = useAssistantStore.getState().sheetSnapshot;
if (!requestId || current?.requestId === requestId) return Promise.resolve({ snapshot: current, message: current ? '已读取最新确认单' : '当前没有确认单' });
return new Promise(resolve => {
const timer = setTimeout(() => { unsubscribe(); resolve({ snapshot: null, message: '确认单版本已变化或未就绪,本次未执行' }); }, 5000);
const unsubscribe = useAssistantStore.subscribe(s => {
if (s.sheetSnapshot?.requestId !== requestId) return;
clearTimeout(timer); unsubscribe(); resolve({ snapshot: s.sheetSnapshot, message: '已读取最新确认单' });
});
});
}
export async function postSheetReceipt(commandId: string, receipt: Receipt, token: string | null) {
// 登录切换后,旧流的回执不能借用新会话发送。
if (!token || useAuthStore.getState().accessToken !== token) return;
await fetch(new URL('/pac/v1/assistant/sheet-receipt', env.apiBaseUrl), {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ commandId, ...receipt }),
});
}
'use client';
import { readLiveSheet, waitSheetEdit, postSheetReceipt, finishSheetCommand } from './sheet-command';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
AssignmentProposal,
......@@ -248,6 +249,34 @@ export function useAssistantChat() {
}),
);
break;
case 'assignment_sheet_command': {
const commandId = String(evt.commandId ?? '');
const expectedId = typeof evt.requestId === 'string' ? evt.requestId : undefined;
const receiptToken = useAuthStore.getState().accessToken;
void (async () => {
const current = await readLiveSheet(expectedId);
if (evt.kind === 'read' || !current.snapshot) {
await postSheetReceipt(commandId, current, receiptToken);
return;
}
const receipt = waitSheetEdit(commandId);
setMessages(prev => {
const at = findActiveSheet(prev);
if (!at) { queueMicrotask(() => finishSheetCommand(commandId, null, '当前没有有效确认单,未执行修改')); return prev; }
const { messageIndex: mi, blockIndex: bi } = at;
const b = prev[mi]!.blocks[bi] as Extract<Block, { kind: 'assignment_sheet' }>;
if (b.requestId !== current.snapshot!.requestId) {
queueMicrotask(() => finishSheetCommand(commandId, null, '确认单版本已变化,未执行修改')); return prev;
}
const next: Block = { ...b, edits: [...(b.edits ?? []), {
seq: (b.edits?.length ?? 0) + 1, ops: (evt.ops ?? []) as SheetEditOp[], commandId,
}] };
return prev.map((m, k) => k === mi ? { ...m, blocks: m.blocks.map((x, j) => j === bi ? next : x) } : m);
});
await postSheetReceipt(commandId, await receipt, receiptToken);
})().catch(() => { /* 服务端超时会报告未确认,⛔ 不伪造成功 */ });
break;
}
case 'assignment_sheet_edit':
// ⭐ 助手要改**已经呈现**的那张卡:指令挂到最后一张确认单上,由它自己去匹配执行。
//
......@@ -519,6 +548,7 @@ export function useAssistantChat() {
messages: apiMessages,
model,
conversationId: conversationIdRef.current,
sheetBridge: true,
...(useAssistantStore.getState().activeClinicId
? { activeClinicId: useAssistantStore.getState().activeClinicId }
: {}),
......
......@@ -946,8 +946,8 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
* ═══ 为什么它必须由浏览器产 ═══════════════════════════════════════
* 确认之前,改动**全部只存在于卡片的组件状态里**(改派 / 移出 / 时限 / 福利)——
* 服务端手上只有最初生成的那一版提案,还是个请求内的闭包,没落库也没缓存。
* ⇒ ⛔ 别指望服务端"查"得到它:`get_current_sheet` 查的就是这份随请求捎来的快照,
* ⛔ 也别为它去开一条"草稿回传"的写路径(多一份要维护的状态,只为省模型一次加法)。
* ⇒ ⛔ 不从服务端缓存推测草稿状态:`get_current_sheet` 向浏览器实时读取。
* 读单和改单通过本轮回执返回最新快照;快照不写业务表,也不作为分配授权。
*
* ⚠️ 它是**这一刻**的照片,不是账本:主管下一秒拖一下它就过期了。
* 所以它随**每次** chat 请求重新捎一份(与 `activeClinicId` 同一条旁路),
......@@ -956,6 +956,12 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
* (`sheetSnapshotFacts`)—— 与 `modelFacts` 同一条纪律:事实投影只在服务端一处。
*/
export const SheetSnapshotSchema = z.object({
/** 最新界面状态中的患者与归属;旧客户端缺失时明确不可核验,不能当成空名单。 */
patients: z.array(z.object({
patientId: z.string(), planId: z.string(), name: z.string().nullable(),
medicalRecordNumber: z.string().nullable(), assigneeUserId: z.string().nullable(),
assigneeName: z.string().nullable(), status: z.enum(['placed', 'pending', 'removed']),
})).max(20000).optional(),
/// 哪一张单(服务端铸造的幂等键)——多轮之后能认出"说的是不是同一张"
requestId: z.string(),
/// 已经点过确认了没有。⚠️ true 时人员/时限已锁死,只有福利还能改
......
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