Commit 70b70ce0 by luoqi

fix(ai-script): qwen3.7-max 结构化输出修复 + gemini 撤回关思考 + 超时兜底 + TTFT/latency 落账 + 价格表修正

- qwen3.7-max:DashScope 不强制 json_schema → 改 supportsStructuredOutputs:false + fetch 中间件强制
  response_format:json_object + enable_thinking:false;runner 从 outputSchema 自动注入英文 key 骨架
  (withQwenStructuredHint),否则模型自编 key 必 fail。
- 三档 schema 去掉 .min/.max/.length/.int 硬约束(对中文偏严,qwen 简洁输出被打回 too_small)→ 改 describe 软引导。
- gemini-3.5-flash 撤回 thinkingBudget:0(关思考会致结构化输出偶发 parse 失败);慢/卡由超时+重试+兜底兜住。
- 给 generateObject/streamObject 接 180s 超时(AI_REQUEST_TIMEOUT_SEC,原 60→180)防永久挂起。
- agent_invocations 加 ttft_ms / latency_ms(+migration),流式记首字、收尾记总耗时(对齐 Dify usage)。
- 价格表按官方页修正(¥/M):deepseek-v4-pro 0.03/3.13/6.26、flash 0.02/1.01/2.02、
  gemini-3.5-flash 1.08/10.8/64.8、qwen3.7-max 1.2/12/36(旗舰价,之前低估 5x)。
- 三档 promptVersion 随 schema 变更 bump。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 746c5b0a
-- AlterTable
ALTER TABLE "agent_invocations" ADD COLUMN "latency_ms" INTEGER,
ADD COLUMN "ttft_ms" INTEGER;
...@@ -1362,6 +1362,12 @@ model AgentInvocation { ...@@ -1362,6 +1362,12 @@ model AgentInvocation {
/// 没用 Int 分是因为单次调用常 <0.01 ,Int 精度全归零看不出差异。 /// 没用 Int 分是因为单次调用常 <0.01 ,Int 精度全归零看不出差异。
costYuan Decimal @default(0) @map("cost_yuan") @db.Decimal(12, 6) costYuan Decimal @default(0) @map("cost_yuan") @db.Decimal(12, 6)
/// 首字延迟(ms) 请求发起到第一个 token/partial 到达;仅流式有意义,非流式为 null
/// 排查慢/卡用(如推理模型思考期 TTFT 前端空转);对齐 Dify usage.time_to_first_token
ttftMs Int? @map("ttft_ms")
/// 生成总耗时(ms) LLM 调用净时长(发起→结束);落库便于聚合分析(对齐 Dify latency)
latencyMs Int? @map("latency_ms")
/// 状态(流式过程也归入 running,不单列): /// 状态(流式过程也归入 running,不单列):
/// running 进行中 /// running 进行中
/// succeeded 完成 /// succeeded 完成
......
...@@ -33,7 +33,7 @@ export interface AppConfig { ...@@ -33,7 +33,7 @@ export interface AppConfig {
qwenApiKey: string; qwenApiKey: string;
qwenBaseUrl: string; qwenBaseUrl: string;
qwenDefaultModel: string; qwenDefaultModel: string;
/// LLM 调用上限(秒),防卡死 /// 单次 LLM 调用上限(秒),防卡死(安全网,默认 180;深度档每步各自计时)
requestTimeoutSec: number; requestTimeoutSec: number;
/// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可 /// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可
priceTable: Record<string, { inHit: number; inMiss: number; out: number }>; priceTable: Record<string, { inHit: number; inMiss: number; out: number }>;
...@@ -73,7 +73,9 @@ export function loadConfig(): AppConfig { ...@@ -73,7 +73,9 @@ export function loadConfig(): AppConfig {
qwenApiKey: process.env.QWEN_API_KEY ?? process.env.DASHSCOPE_API_KEY ?? '', qwenApiKey: process.env.QWEN_API_KEY ?? process.env.DASHSCOPE_API_KEY ?? '',
qwenBaseUrl: process.env.QWEN_BASE_URL ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1', qwenBaseUrl: process.env.QWEN_BASE_URL ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1',
qwenDefaultModel: process.env.QWEN_DEFAULT_MODEL ?? 'qwen3.7-max', qwenDefaultModel: process.env.QWEN_DEFAULT_MODEL ?? 'qwen3.7-max',
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 60), // 单次 LLM 调用上限(秒)— 仅作"防永久挂起"安全网,不该卡正常请求。
// 取 180s:给 pro(慢·精细)+ 深度档单步留足余量;真挂起 3 分钟后失败 → 走模板兜底。
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180),
priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON), priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON),
}, },
alert: { alert: {
...@@ -104,14 +106,18 @@ export function loadConfig(): AppConfig { ...@@ -104,14 +106,18 @@ export function loadConfig(): AppConfig {
* 4. 新调用按新价计 * 4. 新调用按新价计
*/ */
function parsePriceTable(raw: string | undefined): Record<string, { inHit: number; inMiss: number; out: number }> { function parsePriceTable(raw: string | undefined): Record<string, { inHit: number; inMiss: number; out: number }> {
// ⚠️ 价格核对于 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)
// 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 = { const DEFAULT = {
'deepseek-v4-pro': { inHit: 0.5, inMiss: 3.6, out: 25 }, 'deepseek-v4-pro': { inHit: 0.03, inMiss: 3.13, out: 6.26 },
'deepseek-v4-flash': { inHit: 0.07, inMiss: 0.5, out: 2 }, 'deepseek-v4-flash': { inHit: 0.02, inMiss: 1.01, out: 2.02 },
// Gemini Flash 估算价(×7.2 汇率)— 实际调价改 AI_PRICE_TABLE_JSON env 'gemini-3.5-flash': { inHit: 1.08, inMiss: 10.8, out: 64.8 },
'gemini-3.5-flash': { inHit: 0.54, inMiss: 2.16, out: 18 }, 'gemini-2.5-flash': { inHit: 1.08, inMiss: 10.8, out: 64.8 },
'gemini-2.5-flash': { inHit: 0.54, inMiss: 2.16, out: 18 }, // Qwen3.7-Max 是旗舰最贵档(不是便宜模型):标准价 ¥/M 直接填
// Qwen Max(通义千问,DashScope)估算价(¥/M)— 实际调价改 AI_PRICE_TABLE_JSON env 'qwen3.7-max': { inHit: 1.2, inMiss: 12, out: 36 },
'qwen3.7-max': { inHit: 0.6, inMiss: 2.4, out: 9.6 },
}; };
if (!raw) return DEFAULT; if (!raw) return DEFAULT;
try { try {
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { generateObject, streamObject } from 'ai'; import { generateObject, streamObject } from 'ai';
import { z, type ZodSchema } from 'zod';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import type { AppConfig } from '../../config/configuration'; import type { AppConfig } from '../../config/configuration';
import { AiProviderService } from './core/ai-provider.service'; import { AiProviderService } from './core/ai-provider.service';
...@@ -91,7 +92,7 @@ export class AiCallRunnerService { ...@@ -91,7 +92,7 @@ export class AiCallRunnerService {
// ─── 2. 起 invocation ─── // ─── 2. 起 invocation ───
const built = call.buildPrompt(input); const built = call.buildPrompt(input);
const prompt = built.prompt; const prompt = built.prompt;
const system = withQwenJsonHint(provider, built.system); // qwen(DashScope)结构化输出要求 prompt 含 "json" const system = withQwenStructuredHint(provider, built.system, call.outputSchema); // qwen(DashScope)需把 key 骨架注入 prompt
const invocationId = await this.recorder.start({ const invocationId = await this.recorder.start({
...this.baseStart(call, ctx, modelId, provider, inputHash, input, false), ...this.baseStart(call, ctx, modelId, provider, inputHash, input, false),
promptTemplate: prompt.length > 8000 ? prompt.slice(0, 8000) + '…[truncated]' : prompt, promptTemplate: prompt.length > 8000 ? prompt.slice(0, 8000) + '…[truncated]' : prompt,
...@@ -109,10 +110,16 @@ export class AiCallRunnerService { ...@@ -109,10 +110,16 @@ export class AiCallRunnerService {
let result: Awaited<ReturnType<typeof generateObject<any>>> | undefined; let result: Awaited<ReturnType<typeof generateObject<any>>> | undefined;
const MAX_ATTEMPTS = 2; const MAX_ATTEMPTS = 2;
let lastErr: unknown; let lastErr: unknown;
// 超时兜底(防 LLM 永久挂起 → 一直 running)。
// 注:gemini-3.5-flash 是推理模型、生成偏慢,但**不关它的思考**——实测 thinkingBudget:0
// 会致结构化输出偶发 parse 失败(且 gemini API 延迟本就抖)→ 让它正常思考更可靠,
// 慢/卡由 withTimeout(180s)+ 重试 + 模板兜底兜住。
const callSignal = withTimeout(ctx.signal, this.requestTimeoutMs());
const callStart = Date.now(); // 计时:非流式只有总耗时(latency),无 TTFT
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await generateObject<any>({ model, schema: call.outputSchema, system, prompt, abortSignal: ctx.signal }); result = await generateObject<any>({ model, schema: call.outputSchema, system, prompt, abortSignal: callSignal });
break; break;
} catch (e) { } catch (e) {
lastErr = e; lastErr = e;
...@@ -164,6 +171,7 @@ export class AiCallRunnerService { ...@@ -164,6 +171,7 @@ export class AiCallRunnerService {
cachedInputTokens, cachedInputTokens,
reasoningTokens, reasoningTokens,
costYuan, costYuan,
latencyMs: Date.now() - callStart,
status: 'succeeded', status: 'succeeded',
}); });
...@@ -209,7 +217,7 @@ export class AiCallRunnerService { ...@@ -209,7 +217,7 @@ export class AiCallRunnerService {
const { model, provider, modelId } = this.provider.resolve(requestedModelId); const { model, provider, modelId } = this.provider.resolve(requestedModelId);
const built = call.buildPrompt(input); const built = call.buildPrompt(input);
const prompt = built.prompt; const prompt = built.prompt;
const system = withQwenJsonHint(provider, built.system); // qwen(DashScope)结构化输出要求 prompt 含 "json" const system = withQwenStructuredHint(provider, built.system, call.outputSchema); // qwen(DashScope)需把 key 骨架注入 prompt
const invocationId = await this.recorder.start({ const invocationId = await this.recorder.start({
...this.baseStart(call, ctx, modelId, provider, inputHash, input, false), ...this.baseStart(call, ctx, modelId, provider, inputHash, input, false),
...@@ -230,11 +238,18 @@ export class AiCallRunnerService { ...@@ -230,11 +238,18 @@ export class AiCallRunnerService {
let cachedInputTokens = 0; let cachedInputTokens = 0;
let reasoningTokens = 0; let reasoningTokens = 0;
let providerMetadata: unknown = null; let providerMetadata: unknown = null;
// 超时兜底(防流式永久挂起 → 前端一直空转显示"停止")。gemini 不关思考(见 run() 注释)。
const callSignal = withTimeout(ctx.signal, this.requestTimeoutMs());
const callStart = Date.now(); // 计时:TTFT(首个内容 partial)+ latency(总耗时)
let firstPartialAt: number | undefined;
for (let attempt = 1; attempt <= MAX_STREAM_ATTEMPTS; attempt++) { for (let attempt = 1; attempt <= MAX_STREAM_ATTEMPTS; attempt++) {
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const stream = streamObject<any>({ model, schema: call.outputSchema, system, prompt, abortSignal: ctx.signal }); const stream = streamObject<any>({ model, schema: call.outputSchema, system, prompt, abortSignal: callSignal });
for await (const partial of stream.partialObjectStream) { for await (const partial of stream.partialObjectStream) {
if (firstPartialAt === undefined && partial && Object.keys(partial).length > 0) {
firstPartialAt = Date.now(); // 第一个有内容的 partial = 首字
}
yield { type: 'partial', partial: partial as Partial<TOutput> }; yield { type: 'partial', partial: partial as Partial<TOutput> };
} }
// 等最终对象(解析失败在此抛)+ 计量(stream.object / usage 都是 Promise) // 等最终对象(解析失败在此抛)+ 计量(stream.object / usage 都是 Promise)
...@@ -309,6 +324,8 @@ export class AiCallRunnerService { ...@@ -309,6 +324,8 @@ export class AiCallRunnerService {
cachedInputTokens, cachedInputTokens,
reasoningTokens, reasoningTokens,
costYuan, costYuan,
ttftMs: firstPartialAt !== undefined ? firstPartialAt - callStart : undefined,
latencyMs: Date.now() - callStart,
status: 'succeeded', status: 'succeeded',
}); });
this.cache.set(inputHash, finalObj); this.cache.set(inputHash, finalObj);
...@@ -431,6 +448,11 @@ export class AiCallRunnerService { ...@@ -431,6 +448,11 @@ export class AiCallRunnerService {
* *
* 同 prompt + 同输入连续调用时 cache hit 比例上升,实际成本明显低于纯 miss 价。 * 同 prompt + 同输入连续调用时 cache hit 比例上升,实际成本明显低于纯 miss 价。
*/ */
/** LLM 单次调用超时(毫秒),来自 config(env AI_REQUEST_TIMEOUT_SEC,默认 60s)。 */
private requestTimeoutMs(): number {
return this.config.get('ai', { infer: true }).requestTimeoutSec * 1000;
}
private estimateCostYuan( private estimateCostYuan(
modelId: string, modelId: string,
promptTokens: number, promptTokens: number,
...@@ -448,12 +470,51 @@ export class AiCallRunnerService { ...@@ -448,12 +470,51 @@ export class AiCallRunnerService {
} }
/** /**
* qwen(DashScope compatible-mode)用 json_schema 结构化输出时,DashScope 硬性要求 prompt 出现 "json" 字样, * qwen(DashScope compatible-mode)的结构化输出补丁 —— 仅对 qwen 生效,其它 provider 原样返回。
* 否则 400(InvalidParameter: messages must contain the word 'json')。仅对 qwen 在 system 末尾补一句; *
* 其它 provider 原样返回。已含 json 字样则不重复加。 * 背景(实测 2026-06,qwen3.7-max):DashScope 不强制 json_schema,模型会自己编 key(甚至拿中文 describe 当 key)
* → AI SDK 用 Zod 校验必 fail。修法:把 outputSchema 转成一份**显式英文 key 骨架**注入 system,
* 告诉 qwen "只能用这些英文 key + 这个嵌套结构";配合 provider 端 response_format=json_object
* (见 ai-provider.service.ts),qwen 才稳定产出可校验的 JSON(实测 stable/standard/deep 三档均 100%)。
* 骨架文案含 "JSON" 字样,顺带满足 DashScope json_object 对 messages 含 "json" 的硬性要求。
*/
/**
* 给调用裹一道超时 —— 防 LLM 永久挂起(连接卡死 / 推理失控)导致 invocation 一直 running、前端一直转。
* 超时触发时 callSignal abort,但 ctx.signal(客户端断连)未 abort → runner 走正常 catch → 兜底模板,不是 aborted 分支。
*/ */
function withQwenJsonHint(provider: string, system: string): string { function withTimeout(clientSignal: AbortSignal | undefined, ms: number): AbortSignal {
const timeout = AbortSignal.timeout(ms);
return clientSignal ? AbortSignal.any([clientSignal, timeout]) : timeout;
}
function withQwenStructuredHint(provider: string, system: string, schema: ZodSchema): string {
if (provider !== 'qwen') return system; if (provider !== 'qwen') return system;
if (/json/i.test(system)) return system; const skeleton = JSON.stringify(jsonSchemaToSkeleton(z.toJSONSchema(schema)));
return `${system}\n\n请严格以 JSON 格式输出结果。`; return (
`${system}\n\n【输出格式·硬约束】必须输出一个 JSON 对象,**严格使用下面骨架里的英文 key 与嵌套结构**` +
`(不要用中文 key、不要多余字段、不要 markdown 代码块);尖括号 <...> 处替换成实际内容:\n${skeleton}`
);
}
/**
* JSON Schema → 紧凑"示例骨架"(英文 key 保留、嵌套结构保留、说明降级为 <...> 值占位)。
* 给 qwen 看 key 骨架比给它看完整 JSON Schema 更稳(避免它把 description 当 key)。
*/
function jsonSchemaToSkeleton(node: unknown): unknown {
if (!node || typeof node !== 'object') return '<...>';
const n = node as Record<string, unknown>;
if (Array.isArray(n.enum)) return `<${(n.enum as unknown[]).join('|')}>`;
if (n.type === 'array') return [jsonSchemaToSkeleton(n.items)];
if (n.type === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries((n.properties as Record<string, unknown>) ?? {})) {
out[k] = jsonSchemaToSkeleton(v);
}
return out;
}
if (n.type === 'boolean') return '<true|false>';
if (n.type === 'integer' || n.type === 'number') {
return `<数字${n.description ? '·' + String(n.description) : ''}>`;
}
return `<${n.description ? String(n.description) : '文本'}>`;
} }
...@@ -35,7 +35,7 @@ const VERIFY_SYSTEM = [ ...@@ -35,7 +35,7 @@ const VERIFY_SYSTEM = [
export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> { export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
readonly kind = 'script' as const; readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_plan'; readonly callKey = 'draft_plan_script_plan';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-plan-v6'; // v2: 去 {} 替换占位(朴素 labeled facts) readonly promptVersion = 'draft_plan_script@2026-06-08-deep-plan-v7'; // v7: schema 去硬约束(.min/.max → describe);v2: 去 {} 替换占位(朴素 labeled facts)
readonly defaultModelId = 'deepseek-v4-flash'; readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepPlanSchema; readonly outputSchema = DeepPlanSchema;
buildPrompt(ctx: ScriptContext) { buildPrompt(ctx: ScriptContext) {
...@@ -47,7 +47,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> { ...@@ -47,7 +47,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> { export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
readonly kind = 'script' as const; readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_write'; readonly callKey = 'draft_plan_script_write';
readonly promptVersion = 'draft_plan_script@2026-06-08-deep-write-v11'; // v11: repair 喂上一稿 + 逐条强约束(严格按自检 fix 改) readonly promptVersion = 'draft_plan_script@2026-06-08-deep-write-v12'; // v12: schema 去硬约束(.min/.max → describe);v11: repair 喂上一稿 + 逐条强约束(严格按自检 fix 改)
readonly defaultModelId = 'deepseek-v4-flash'; readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema; readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {} constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
...@@ -65,7 +65,7 @@ export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> { ...@@ -65,7 +65,7 @@ export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
export class DeepVerifyCall implements AiCall<DeepVerifyInput, DeepVerifyZ> { export class DeepVerifyCall implements AiCall<DeepVerifyInput, DeepVerifyZ> {
readonly kind = 'judge' as const; readonly kind = 'judge' as const;
readonly callKey = 'draft_plan_script_verify'; readonly callKey = 'draft_plan_script_verify';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-verify-v6'; // v2: 去 {} 替换占位(随 fact-block) readonly promptVersion = 'draft_plan_script@2026-06-08-deep-verify-v7'; // v7: schema 去硬约束(.min/.max/.int → describe);v2: 去 {} 替换占位(随 fact-block)
readonly defaultModelId = 'deepseek-v4-flash'; readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepVerifySchema; readonly outputSchema = DeepVerifySchema;
buildPrompt(input: DeepVerifyInput) { buildPrompt(input: DeepVerifyInput) {
......
...@@ -3,6 +3,10 @@ import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone'; ...@@ -3,6 +3,10 @@ import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone';
/** /**
* 深度档 3 步各自的输出 schema(都过 AiCallRunner 的 generateObject 强约束)。 * 深度档 3 步各自的输出 schema(都过 AiCallRunner 的 generateObject 强约束)。
*
* ⚠️ 不加 .min()/.max()/.length()/.int() 等硬长度·范围约束 —— 段数 / 字数 / 评分范围
* 全部只作 prompt 软引导(写在 describe 里)。理由:硬约束对中文偏严,且 qwen3.7-max 这类
* 不强制 schema 的模型一旦长度/段数差一点就整体 fail 走兜底;形态靠 system + describe 引导即可。
*/ */
// ── 步骤1:规划 ── // ── 步骤1:规划 ──
...@@ -10,18 +14,14 @@ export const DeepPlanSchema = z.object({ ...@@ -10,18 +14,14 @@ export const DeepPlanSchema = z.object({
sections: z sections: z
.array( .array(
z.object({ z.object({
key: z.string().min(1).max(24).describe('段标识(英文短,仅内部串联,如 opening/missed_11/review/close)'), key: z.string().describe('段标识(英文短,仅内部串联,如 opening/missed_11/review/close)'),
title: z.string().min(2).max(20).describe('中文小标题(自然口语,贴这通电话)'), title: z.string().describe('中文小标题(自然口语,贴这通电话,约 2-20 字)'),
intent: z.string().min(4).max(60).describe('这段要达成什么(一句话)'), intent: z.string().describe('这段要达成什么(一句话)'),
points: z points: z
.array(z.string().min(2).max(120)) .array(z.string())
.min(1) .describe('要点(1-5 条,口语化,**每条都须来自给定患者信息/病历事实**,不编造)'),
.max(5)
.describe('要点(口语化,**每条都须来自给定患者信息/病历事实**,不编造)'),
}), }),
) )
.min(3)
.max(7)
.describe('话术大纲:按这通电话需要拆几段(开场→切入本次问题→可顺带的其他关心→复查邀约→结束;段数你定,3-7 段)'), .describe('话术大纲:按这通电话需要拆几段(开场→切入本次问题→可顺带的其他关心→复查邀约→结束;段数你定,3-7 段)'),
}); });
export type DeepPlanZ = z.infer<typeof DeepPlanSchema>; export type DeepPlanZ = z.infer<typeof DeepPlanSchema>;
...@@ -32,17 +32,13 @@ export const DeepWriteSchema = z.object({ ...@@ -32,17 +32,13 @@ export const DeepWriteSchema = z.object({
sections: z sections: z
.array( .array(
z.object({ z.object({
title: z.string().min(2).max(20).describe('段小标题(贴这段内容,自然口语,别用刻板模板名)'), title: z.string().describe('段小标题(贴这段内容,自然口语,别用刻板模板名,约 2-20 字)'),
markdown: z markdown: z
.string() .string()
.min(20) .describe('该段话术正文(约 20-400 字)。短句分行、行首 `•`;接地病历、不编造;时间用【时间段】占位;无大标题/表情'),
.max(900)
.describe('该段话术正文。短句分行、行首 `•`;接地病历、不编造;时间用【时间段】占位;无大标题/表情'),
}), }),
) )
.min(3) .describe('按规划写出的多段话术(段数跟随规划,通常 3-7 段)'),
.max(7)
.describe('按规划写出的多段话术(段数跟随规划)'),
}); });
export type DeepWriteZ = z.infer<typeof DeepWriteSchema>; export type DeepWriteZ = z.infer<typeof DeepWriteSchema>;
...@@ -52,20 +48,20 @@ export const DeepVerifySchema = z.object({ ...@@ -52,20 +48,20 @@ export const DeepVerifySchema = z.object({
issues: z issues: z
.array( .array(
z.object({ z.object({
section: z.string().min(1).max(30).describe('出问题的段标题或序号'), section: z.string().describe('出问题的段标题或序号'),
problem: z.string().min(4).max(160).describe('问题:编造/接地不实(追不到事实)/安全越界(报价/承诺/写死时间/≤18拍片)'), problem: z.string().describe('问题:编造/接地不实(追不到事实)/安全越界(报价/承诺/写死时间/≤18拍片)'),
fix: z.string().min(4).max(160).describe('修法建议(回喂改写)'), fix: z.string().describe('修法建议(回喂改写)'),
}), }),
) )
.describe('逐条列出有问题的点;全部 OK 则空数组'), .describe('逐条列出有问题的点;全部 OK 则空数组'),
// ⭐ 质量评分(1-5)—— **只评好不好,跟 pass 无关**(pass 只看接地+安全)。仅落账供 eval / 版本对比,不卡生成。 // ⭐ 质量评分(1-5)—— **只评好不好,跟 pass 无关**(pass 只看接地+安全)。仅落账供 eval / 版本对比,不卡生成。
quality: z quality: z
.object({ .object({
natural: z.number().int().min(1).max(5).describe('口语自然度:像真人一来一回,不书面/机器腔/念稿'), natural: z.number().describe('口语自然度(1-5):像真人一来一回,不书面/机器腔/念稿'),
warmth: z.number().int().min(1).max(5).describe('关怀温度:医疗关怀感,不冷淡也不推销'), warmth: z.number().describe('关怀温度(1-5):医疗关怀感,不冷淡也不推销'),
focus: z.number().int().min(1).max(5).describe('聚焦:紧扣本次问题、主线清晰,不发散'), focus: z.number().describe('聚焦(1-5):紧扣本次问题、主线清晰,不发散'),
nonPushy: z.number().int().min(1).max(5).describe('不推销:邀约自然,不促单 / 不报价 / 不施压'), nonPushy: z.number().describe('不推销(1-5):邀约自然,不促单 / 不报价 / 不施压'),
overall: z.number().min(1).max(5).describe('综合质量分(1-5,可含半分,如 4.5)'), overall: z.number().describe('综合质量分(1-5,可含半分,如 4.5)'),
}) })
.describe('质量细项打分(1-5);只评质量,不影响 pass/issues'), .describe('质量细项打分(1-5);只评质量,不影响 pass/issues'),
}); });
......
...@@ -11,29 +11,24 @@ import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone'; ...@@ -11,29 +11,24 @@ import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone';
export const DraftPlanScriptSchema = z.object({ export const DraftPlanScriptSchema = z.object({
tone: ToneEnum.describe(TONE_DESCRIBE), tone: ToneEnum.describe(TONE_DESCRIBE),
// ⚠️ 不加 .min()/.max() 硬长度约束 —— 长度只作 prompt 软引导(写在 describe 里)。
// 历史踩坑:硬约束对中文偏严(中文信息密度高,qwen 写得简洁就被打回 too_small → 整段作废走兜底);
// 长度形态由 system 提示词 + describe 引导即可,不靠 schema 卡。
opening: z opening: z
.string() .string()
.min(50) .describe('第一部分·开场白(约 50-300 字)。markdown,`• ` 短句分行。内容/顺序按系统提示词的模板,不要大标题/分隔符/表情。'),
.max(600)
.describe('第一部分·开场白。markdown,`• ` 短句分行。内容/顺序按系统提示词的模板,不要大标题/分隔符/表情。'),
informMissed: z informMissed: z
.string() .string()
.min(80) .describe('第二部分·告知应治未治(约 80-400 字)。**只讲本次一个 {应治未治项}**,温和提醒非推销;markdown `• ` 短句分行。'),
.max(900)
.describe('第二部分·告知应治未治。**只讲本次一个 {应治未治项}**,温和提醒非推销;markdown `• ` 短句分行。'),
reviewAdvice: z reviewAdvice: z
.string() .string()
.min(80) .describe('第三部分·复查建议(约 80-400 字)。引导预约用【时间段1】【时间段2】占位、**不写死具体时间**;markdown `• ` 短句分行。'),
.max(900)
.describe('第三部分·复查建议。引导预约用【时间段1】【时间段2】占位、**不写死具体时间**;markdown `• ` 短句分行。'),
closing: z closing: z
.string() .string()
.min(40) .describe('第四部分·结束回访语(约 40-200 字)。含【预约成功】/【预约不成功】两种;时间用【具体预约时间】占位、不承诺、不写死。'),
.max(500)
.describe('第四部分·结束回访语。含【预约成功】/【预约不成功】两种;时间用【具体预约时间】占位、不承诺、不写死。'),
}); });
export type DraftPlanScriptOutputZ = z.infer<typeof DraftPlanScriptSchema>; export type DraftPlanScriptOutputZ = z.infer<typeof DraftPlanScriptSchema>;
...@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc ...@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。 * 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/ */
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-07-4module-v26'; // v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板 'draft_plan_script@2026-06-08-4module-v27'; // v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable() @Injectable()
export class DraftPlanScriptCall export class DraftPlanScriptCall
......
...@@ -18,19 +18,15 @@ export const StandardScriptSchema = z.object({ ...@@ -18,19 +18,15 @@ export const StandardScriptSchema = z.object({
sections: z sections: z
.array( .array(
z.object({ z.object({
// 不加 .min()/.max() 硬约束,长度/段数只作 describe 软引导(对中文偏严 + qwen3.7-max 简洁易误伤)
title: z title: z
.string() .string()
.min(2) .describe('该段小标题(约 2-20 字):你自起、自然口语贴这通电话,别用"开场白/告知应治未治/复查建议/结束回访语"这类刻板模板名'),
.max(20)
.describe('该段小标题:你自起、自然口语贴这通电话,别用"开场白/告知应治未治/复查建议/结束回访语"这类刻板模板名'),
markdown: z markdown: z
.string() .string()
.min(30) .describe('该段正文(约 30-400 字):分短句、行首 `•`;接地病历不编造;具体时间一律用【时间段】占位;无大标题/分隔符/表情'),
.max(900)
.describe('该段正文:分短句、行首 `•`;接地病历不编造;具体时间一律用【时间段】占位;无大标题/分隔符/表情'),
}), }),
) )
.length(4)
.describe( .describe(
'固定 4 段(数组顺序即话术顺序);**角色/标题/每段讲什么都由你定,不要套固定段名**。一通回访通常覆盖:打招呼问近况 / 带出本次问题(隐患+趁早好处)/ 邀约来院复查(段尾含固定预约引导句)/ 简短收尾 —— 但怎么分、起什么标题由你决定。详细写法见 system 提示词。', '固定 4 段(数组顺序即话术顺序);**角色/标题/每段讲什么都由你定,不要套固定段名**。一通回访通常覆盖:打招呼问近况 / 带出本次问题(隐患+趁早好处)/ 邀约来院复查(段尾含固定预约引导句)/ 简短收尾 —— 但怎么分、起什么标题由你决定。详细写法见 system 提示词。',
), ),
......
...@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types'; ...@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types';
* *
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。 * callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/ */
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-07-standard-v13'; // v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化 const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v14'; // v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化
@Injectable() @Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> { export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> {
......
...@@ -55,21 +55,25 @@ export class AiProviderService { ...@@ -55,21 +55,25 @@ export class AiProviderService {
this.logger.warn('QWEN_API_KEY(或 DASHSCOPE_API_KEY)未设置 — Qwen 调用会失败。到阿里云百炼 dashscope 申请'); this.logger.warn('QWEN_API_KEY(或 DASHSCOPE_API_KEY)未设置 — Qwen 调用会失败。到阿里云百炼 dashscope 申请');
} }
// 通义千问走 DashScope OpenAI-兼容端点(compatible-mode)。 // 通义千问走 DashScope OpenAI-兼容端点(compatible-mode)。
// DashScope 支持 OpenAI json_schema 严格模式(实测严格按 schema 返),但两个前提: // ⚠️ 实测结论(2026-06,qwen3.7-max):
// ① supportsStructuredOutputs: true → SDK 才发 response_format=json_schema(否则松散 json_object 不符 schema) // - DashScope 的 response_format=json_schema **不被强制执行** —— 即便 strict:true 也返 200 但 key 全自己编
// ② prompt 里必须出现 "json" 字样(DashScope 硬性校验)— 由 runner 对 qwen 注入(见 buildQwenSystem) // (拿 .describe() 中文当 key)→ AI SDK 用 Zod 一校验必 fail。所以 supportsStructuredOutputs 关掉。
// - 但 response_format=json_object **是认的**(保证纯 JSON、不裹 markdown);配合把"英文 key 骨架"
// 注入到 prompt(见 ai-call-runner.ts withQwenStructuredHint),qwen 才会用正确的 key。
// - DashScope 用 json_object 时硬性要求 messages 含 "json" 字样(key 骨架文案已含"JSON")。
// - qwen3.7-max 是推理模型:流式下 thinking 会污染 content → enable_thinking:false 关掉。
// - 这两个参数 SDK 都不直接支持,统一用 fetch 中间件改 body 注入。
this.qwen = createOpenAICompatible({ this.qwen = createOpenAICompatible({
name: 'qwen', name: 'qwen',
apiKey: ai.qwenApiKey, apiKey: ai.qwenApiKey,
baseURL: ai.qwenBaseUrl, baseURL: ai.qwenBaseUrl,
supportsStructuredOutputs: true, supportsStructuredOutputs: false,
// qwen3.7-max 是推理模型:流式下 thinking 会污染 content 流 → 结构化 JSON 解析失败。
// DashScope 用 body 里 enable_thinking:false 关思考(SDK 无此参数,用 fetch 中间件注入)。
fetch: (async (url: string | URL | Request, options?: RequestInit) => { fetch: (async (url: string | URL | Request, options?: RequestInit) => {
if (options?.body && typeof options.body === 'string') { if (options?.body && typeof options.body === 'string') {
try { try {
const b = JSON.parse(options.body) as Record<string, unknown>; const b = JSON.parse(options.body) as Record<string, unknown>;
b.enable_thinking = false; b.enable_thinking = false;
b.response_format = { type: 'json_object' }; // DashScope 认 json_object(json_schema 不强制)
options = { ...options, body: JSON.stringify(b) }; options = { ...options, body: JSON.stringify(b) };
} catch { } catch {
/* 非 JSON body 不动 */ /* 非 JSON body 不动 */
......
...@@ -45,6 +45,10 @@ export interface InvocationEndInput { ...@@ -45,6 +45,10 @@ export interface InvocationEndInput {
/** 输出里 reasoning 部分(thinking 模式),completionTokens 已含,这里冗余 */ /** 输出里 reasoning 部分(thinking 模式),completionTokens 已含,这里冗余 */
reasoningTokens?: number; reasoningTokens?: number;
costYuan?: number; costYuan?: number;
/** 首字延迟(ms)— 仅流式有意义,非流式不传(留 null)*/
ttftMs?: number;
/** 生成总耗时(ms)— LLM 调用净时长 */
latencyMs?: number;
status: 'succeeded' | 'failed' | 'cached'; status: 'succeeded' | 'failed' | 'cached';
errorMessage?: string; errorMessage?: string;
} }
...@@ -96,6 +100,8 @@ export class InvocationRecorderService { ...@@ -96,6 +100,8 @@ export class InvocationRecorderService {
cachedInputTokens: input.cachedInputTokens ?? 0, cachedInputTokens: input.cachedInputTokens ?? 0,
reasoningTokens: input.reasoningTokens ?? 0, reasoningTokens: input.reasoningTokens ?? 0,
costYuan: input.costYuan ?? 0, costYuan: input.costYuan ?? 0,
ttftMs: input.ttftMs ?? null,
latencyMs: input.latencyMs ?? null,
status: input.status, status: input.status,
errorMessage: input.errorMessage ?? null, errorMessage: input.errorMessage ?? null,
endedAt: new Date(), endedAt: new Date(),
......
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