Commit fe79f0f3 by luoqi

merge: 分配助手文档重排 + 助手调用留痕(agent_invocations)

文档 25 个提交:《分配助手》按产品口径全面重排(决策树前置、意图分析、
去内部黑话、编号撞车修正、代价三档)。
功能 1 个提交:助手每轮往 agent_invocations 落一行(只存当轮, 不存上下文),
外加保留期清理任务与成本计算共享化。
parents 217ca734 ad264616
Pipeline #3573 failed in 0 seconds
...@@ -43,6 +43,12 @@ export interface AppConfig { ...@@ -43,6 +43,12 @@ export interface AppConfig {
assistantVoice: string; assistantVoice: string;
/// 价格表(¥/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 }>;
/**
* `agent_invocations` 里**肥字段**(inputSnapshot / prompt / outputText)的保留天数。
* 到期后清成元数据行,⛔ 不删行(成本与通过率曲线要长期可比)。失败行留 3 倍时长。
* 0 = 不清理 —— ⚠️ 只在排查期临时这么配,助手是按对话轮数写库的,不清会涨得很快。
*/
invocationRetentionDays: number;
}; };
alert: { webhookUrl: string }; alert: { webhookUrl: string };
cors: { origins: string[] }; cors: { origins: string[] };
...@@ -84,6 +90,7 @@ export function loadConfig(): AppConfig { ...@@ -84,6 +90,7 @@ export function loadConfig(): AppConfig {
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180), requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180),
assistantVoice: process.env.PAC_ASSISTANT_VOICE ?? '', assistantVoice: process.env.PAC_ASSISTANT_VOICE ?? '',
priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON), priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON),
invocationRetentionDays: Number(process.env.AI_INVOCATION_RETENTION_DAYS ?? 30),
}, },
alert: { alert: {
webhookUrl: process.env.ALERT_WEBHOOK_URL ?? '', webhookUrl: process.env.ALERT_WEBHOOK_URL ?? '',
......
...@@ -10,6 +10,7 @@ import { PromptCacheService } from './core/prompt-cache.service'; ...@@ -10,6 +10,7 @@ import { PromptCacheService } from './core/prompt-cache.service';
import { SafetyGateRejectError, SafetyGateService } from './core/safety-gate.service'; import { SafetyGateRejectError, SafetyGateService } from './core/safety-gate.service';
import { computeInputHash } from './core/hash.util'; import { computeInputHash } from './core/hash.util';
import type { AiCall, AiCallContext, AiCallResult } from './ai-call.interface'; import type { AiCall, AiCallContext, AiCallResult } from './ai-call.interface';
import { estimateCostYuan } from './core/cost';
/** /**
* 流式事件 — orchestrator / controller 转换成 SSE 后吐给客户端 * 流式事件 — orchestrator / controller 转换成 SSE 后吐给客户端
...@@ -460,8 +461,14 @@ export class AiCallRunnerService { ...@@ -460,8 +461,14 @@ export class AiCallRunnerService {
cachedInputTokens: number = 0, cachedInputTokens: number = 0,
): number { ): number {
const priceTable = this.config.get('ai', { infer: true }).priceTable; const priceTable = this.config.get('ai', { infer: true }).priceTable;
const p = priceTable[modelId]; const { yuan, priceMissing } = estimateCostYuan(
if (!p) { priceTable,
modelId,
promptTokens,
completionTokens,
cachedInputTokens,
);
if (priceMissing) {
/** /**
* 🔴 **价目表里没有这个模型 —— 必须吭声**(2026-08-13)。 * 🔴 **价目表里没有这个模型 —— 必须吭声**(2026-08-13)。
* 原来这里静默回落到 `deepseek-v4-pro` 的价:换成 qwen 旗舰之后, * 原来这里静默回落到 `deepseek-v4-pro` 的价:换成 qwen 旗舰之后,
...@@ -474,13 +481,7 @@ export class AiCallRunnerService { ...@@ -474,13 +481,7 @@ export class AiCallRunnerService {
`补价:AI_PRICE_TABLE_JSON`, `补价:AI_PRICE_TABLE_JSON`,
); );
} }
const price = p ?? priceTable['deepseek-v4-pro'] ?? { inHit: 0.5, inMiss: 3.6, out: 25 }; return yuan;
// 防御:cached > prompt 不该发生,clamp
const hit = Math.min(cachedInputTokens, promptTokens);
const miss = Math.max(0, promptTokens - hit);
const yuan =
(hit * price.inHit + miss * price.inMiss + completionTokens * price.out) / 1_000_000;
return Math.max(0, yuan);
} }
} }
......
...@@ -74,6 +74,8 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -74,6 +74,8 @@ import { PlanModule } from '../plan/plan.module';
], ],
exports: [ exports: [
// 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI) // 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI)
// 助手每轮往 agent_invocations 落一行,复用同一个 recorder(⛔ 别再抄一份写库逻辑)
InvocationRecorderService,
PlanScriptOrchestrator, PlanScriptOrchestrator,
WecomScriptOrchestrator, // 企微话术(PlansAggregateController 注入) WecomScriptOrchestrator, // 企微话术(PlansAggregateController 注入)
PlanSummaryOrchestrator, PlanSummaryOrchestrator,
......
/**
* 成本估算 —— 纯函数,`AiCallRunner` 与助手共用**同一份**口径。
*
* ⚠️ 抽出来是因为它是**计价**逻辑:抄第二份必然会漂,而漂了之后报表上看不出任何异常
* (同一类 bug 2026-08-13 栽过一次:换 qwen 旗舰后成本被低报约四倍)。
*/
export interface ModelPrice {
/** ¥/M tokens —— 输入里命中 vendor prompt cache 的部分 */
inHit: number;
/** ¥/M tokens —— 输入里未命中的部分 */
inMiss: number;
/** ¥/M tokens —— 输出 */
out: number;
}
/** 价目表里查不到时的兜底价(与历史行为一致:按 deepseek-v4-pro 估)。 */
export const FALLBACK_PRICE: ModelPrice = { inHit: 0.5, inMiss: 3.6, out: 25 };
export interface CostResult {
yuan: number;
/** 价目表里没有这个模型 —— 调用方要吭声,⛔ 别静默 */
priceMissing: boolean;
}
export function estimateCostYuan(
priceTable: Record<string, ModelPrice>,
modelId: string,
promptTokens: number,
completionTokens: number,
cachedInputTokens = 0,
): CostResult {
const p = priceTable[modelId];
const price = p ?? priceTable['deepseek-v4-pro'] ?? FALLBACK_PRICE;
// 防御:cached > prompt 不该发生,clamp
const hit = Math.min(Math.max(0, cachedInputTokens), Math.max(0, promptTokens));
const miss = Math.max(0, promptTokens - hit);
const yuan =
(hit * price.inHit + miss * price.inMiss + Math.max(0, completionTokens) * price.out) /
1_000_000;
return { yuan: Math.max(0, yuan), priceMissing: !p };
}
...@@ -107,6 +107,16 @@ import { Permission } from '@pac/types'; ...@@ -107,6 +107,16 @@ import { Permission } from '@pac/types';
*/ */
/** /**
* 提示词版本 —— 落进 `agent_invocations.prompt_version`,是回放「当时那份提示词长什么样」的**唯一锚**。
*
* 🔴 **改动 ①〜⑤ 任意一层的正文,必须 bump 这个值**(连同当天日期)。
* ⚠️ 系统提示词**不逐行落库**:主管那份 6.9 KB 每次一模一样,逐行存等于把同一份东西
* 抄一万遍。审计靠「这一行的 promptVersion + 代码里那个版本的正文」两边对 ——
* ⇒ 版本不 bump,这条链就断了,而断了不会有任何报错。
*/
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-16-a';
/**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。 * ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
* *
* ⚠️ 这里**刻意不写角色**:同一个助手同时服务门诊经理(主管)和客服, * ⚠️ 这里**刻意不写角色**:同一个助手同时服务门诊经理(主管)和客服,
...@@ -280,7 +290,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服 ...@@ -280,7 +290,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
### 分人 ### 分人
两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。 两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无专属或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。
不是每人加一样多,是每条都给当前手上最少的那个。没有「容量上限」这回事,负载就是在手量本身。 不是每人加一样多,是每条都给当前手上最少的那个。没有「容量上限」这回事,负载就是在手量本身。
......
...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai'; import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import { import {
Permission, Permission,
TEMPERATURE_TOOL_DESC, TEMPERATURE_TOOL_DESC,
...@@ -10,7 +11,17 @@ import { ...@@ -10,7 +11,17 @@ import {
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service'; import { AiProviderService } from '../ai/core/ai-provider.service';
import { buildSystemPrompt } from './assistant-prompts'; import { InvocationRecorderService } from '../ai/core/invocation-recorder.service';
import { estimateCostYuan } from '../ai/core/cost';
import { buildSystemPrompt, ASSISTANT_PROMPT_VERSION } from './assistant-prompts';
import {
slimInputSnapshot,
briefToolArgs,
truncateOutputText,
assistantCallKey,
assistantInputHash,
type ToolTraceEntry,
} from './assistant-invocation';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service'; import { AssignmentProposalService } from '../plan/assignment-proposal.service';
// ⭐ 给模型的事实投影(⛔ 不再给成品句子,见该文件顶部注释) // ⭐ 给模型的事实投影(⛔ 不再给成品句子,见该文件顶部注释)
...@@ -177,6 +188,7 @@ export class AssistantService { ...@@ -177,6 +188,7 @@ export class AssistantService {
private readonly mcp: McpClientService, private readonly mcp: McpClientService,
private readonly proposals: AssignmentProposalService, private readonly proposals: AssignmentProposalService,
private readonly config: ConfigService<AppConfig, true>, private readonly config: ConfigService<AppConfig, true>,
private readonly recorder: InvocationRecorderService,
) {} ) {}
// 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部 // 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部
...@@ -913,6 +925,70 @@ export class AssistantService { ...@@ -913,6 +925,70 @@ export class AssistantService {
this.logger.log( this.logger.log(
`assistant chat: model=${resolved.modelId} tools=${Object.keys(tools).join(',')}`, `assistant chat: model=${resolved.modelId} tools=${Object.keys(tools).join(',')}`,
); );
/**
* ⭐ **每一轮往 `agent_invocations` 落一行** —— 落的是「该调的工具调没调」,
* ⛔ 不是聊天记录。在此之前这件事线上一次都没留过痕,只能靠临时跑批。
*
* ⚠️ 落库**绝不能影响这一轮对话** —— 全程 try/catch 吞掉,失败只打日志:
* 主管正等着一版方案,⛔ 不该因为审计写不进去而看不到结果。
* ⚠️ `scope` 缺失(比如某些内部入口)就整条跳过 —— hostId/tenantId 是必填列。
*/
const toolTrace: ToolTraceEntry[] = [];
// 统一给**所有**工具(MCP 拉来的 + 本地那几个)套一层计时留痕。
// ⚠️ 装在这里而不是各自 execute 里:漏一个就等于那个工具永远不出现在验收数据里。
for (const name of Object.keys(tools)) {
// AI SDK 的 ToolSet 是判别联合,execute 的入参类型在这一层无法窄化 ——
// 计时包装对参数**不做任何解释**,只透传,所以用 any 收口,边界只在这两行。
/* eslint-disable @typescript-eslint/no-explicit-any */
const t = tools[name] as { execute?: (...a: any[]) => any } | undefined;
const orig = t?.execute?.bind(t);
if (!t || !orig) continue;
t.execute = async (args: any, opts: any) => {
const t0 = Date.now();
try {
const r = await orig(args, opts);
toolTrace.push({ name, ok: true, ms: Date.now() - t0, args: briefToolArgs(args) });
return r;
} catch (e) {
toolTrace.push({ name, ok: false, ms: Date.now() - t0, args: briefToolArgs(args) });
throw e;
}
};
/* eslint-enable @typescript-eslint/no-explicit-any */
}
const scope = input.scope;
const callKey = assistantCallKey(!!input.permissions?.includes(Permission.PLAN_DISPATCH));
const snapshot = slimInputSnapshot(input.messages, {
activeClinicId: input.activeClinicId,
activePatientId: input.activePatientId,
});
const startedAt = Date.now();
let invocationId: string | undefined;
if (scope) {
try {
invocationId = await this.recorder.start({
hostId: scope.hostId,
tenantId: scope.tenantId,
kind: 'assistant',
callKey,
promptVersion: ASSISTANT_PROMPT_VERSION,
modelProvider: resolved.provider,
modelName: resolved.modelId,
// 助手一轮 = 一个 run。⛔ 不跨轮串:每轮的上下文都不同,串起来没有可比性。
workflowRunId: randomUUID(),
inputHash: assistantInputHash(callKey, ASSISTANT_PROMPT_VERSION, snapshot),
// ⛔ 只放本轮 —— 理由见 assistant-invocation.ts 头注(全量存会按平方涨)
inputSnapshot: snapshot as unknown as Prisma.InputJsonValue,
// ⛔ systemPrompt / promptTemplate 一律不落:前者每次一模一样(靠 promptVersion 回查),
// 后者的内容已经在 inputSnapshot.userText 里了。
});
} catch (e) {
this.logger.warn(`invocation start 失败(不影响对话):${e instanceof Error ? e.message : e}`);
}
}
const { model } = resolved; const { model } = resolved;
return streamText({ return streamText({
model, model,
...@@ -953,7 +1029,76 @@ export class AssistantService { ...@@ -953,7 +1029,76 @@ export class AssistantService {
// 静默截断会让模型停在「我先查一下」之后,而主管以为它查完了。 // 静默截断会让模型停在「我先查一下」之后,而主管以为它查完了。
stopWhen: stepCountIs(MAX_TOOL_STEPS), stopWhen: stepCountIs(MAX_TOOL_STEPS),
abortSignal: input.abortSignal, abortSignal: input.abortSignal,
onFinish: (ev) => {
void this.finishInvocation(invocationId, resolved.modelId, startedAt, toolTrace, ev);
},
onError: ({ error }) => {
void this.finishInvocation(invocationId, resolved.modelId, startedAt, toolTrace, {
errorMessage: error instanceof Error ? error.message : String(error),
});
},
});
}
/**
* 一轮结束时回填 `agent_invocations` —— **失败只打日志,⛔ 绝不外抛**。
* 它挂在 streamText 的 onFinish/onError 上,抛出去会污染正在给主管吐字的那条流。
*/
private async finishInvocation(
invocationId: string | undefined,
modelId: string,
startedAt: number,
toolTrace: readonly ToolTraceEntry[],
ev: {
text?: string;
finishReason?: string;
// AI SDK 的 usage 里混着嵌套的 tokenDetails,这里只取几个标量 —— 用宽类型收口
totalUsage?: Record<string, unknown>;
usage?: Record<string, unknown>;
errorMessage?: string;
},
): Promise<void> {
if (!invocationId) return;
try {
const u = ev.totalUsage ?? ev.usage ?? {};
const num = (k: string): number => (typeof u[k] === 'number' ? (u[k] as number) : 0);
const promptTokens = num('inputTokens');
const completionTokens = num('outputTokens');
const cachedInputTokens = num('cachedInputTokens');
const { yuan, priceMissing } = estimateCostYuan(
this.config.get('ai', { infer: true }).priceTable,
modelId,
promptTokens,
completionTokens,
cachedInputTokens,
);
if (priceMissing) {
this.logger.warn(`价目表里没有 ${modelId} —— 本轮助手成本按兜底价估算,数字**不准**。`);
}
await this.recorder.end(invocationId, {
/**
* 🔴 **这一列才是落这行的理由** —— 「该调的工具调没调」是助手这层的验收判据。
* ⛔ 不放工具**返回值**:确认单那类肥载荷正是量级失控的源头。
*/
output: {
toolCalls: toolTrace as unknown as Prisma.InputJsonValue,
toolCallCount: toolTrace.length,
finishReason: ev.finishReason ?? null,
} as Prisma.InputJsonValue,
outputText: truncateOutputText(ev.text),
promptTokens,
completionTokens,
totalTokens: num('totalTokens') || promptTokens + completionTokens,
cachedInputTokens,
reasoningTokens: num('reasoningTokens'),
costYuan: yuan,
latencyMs: Date.now() - startedAt,
status: ev.errorMessage ? 'failed' : 'succeeded',
errorMessage: ev.errorMessage,
}); });
} catch (e) {
this.logger.warn(`invocation end 失败(不影响对话):${e instanceof Error ? e.message : e}`);
}
} }
/** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */ /** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */
......
...@@ -150,7 +150,7 @@ const SYSTEM = `你是 PAC(疗效保障 / 患者分析中心)工作台里的 ...@@ -150,7 +150,7 @@ const SYSTEM = `你是 PAC(疗效保障 / 患者分析中心)工作台里的
### 拟分怎么落人 ### 拟分怎么落人
两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。 两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无专属或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。
因此这批可能不满、团队也不齐平 —— 那是刻意的:宁可少分几个,也不动别人的客户。他问「怎么没分够」就说有几个人卡在待分配等他定,不是系统故障。 因此这批可能不满、团队也不齐平 —— 那是刻意的:宁可少分几个,也不动别人的客户。他问「怎么没分够」就说有几个人卡在待分配等他定,不是系统故障。
......
...@@ -184,7 +184,9 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> { ...@@ -184,7 +184,9 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
'分完之后每人手上': range(loads), '分完之后每人手上': range(loads),
// ⚠️ 读**这一版真用的**那个数,⛔ 不引常量:主管说过「按 20 通算」就该是 20 // ⚠️ 读**这一版真用的**那个数,⛔ 不引常量:主管说过「按 20 通算」就该是 20
'按每天几通算': p.dailyCalls, '按每天几通算': p.dailyCalls,
'落人规则': '有专属的先回自己人手上;无主的给当前手上最少的那个', // ⚠️ 措辞用「无专属」,⛔ 别写「无主」—— 界面上的按钮文案就是「无专属客服的患者」,
// 模型会照着这句念给主管听,两处不一致他会以为是两拨人。
'落人规则': '有专属的先回自己人手上;无专属的给当前手上最少的那个',
// ⚠️ 他自己设过的那几位要点名 —— 「为什么某某只有 5 条」的答案只在这里 // ⚠️ 他自己设过的那几位要点名 —— 「为什么某某只有 5 条」的答案只在这里
...(tuned.length > 0 ? { '他单独设过的': tuned } : {}), ...(tuned.length > 0 ? { '他单独设过的': tuned } : {}),
...(p.pending.length > 0 ? { '专属客服排满、这批没发的': p.pending.length } : {}), ...(p.pending.length > 0 ? { '专属客服排满、这批没发的': p.pending.length } : {}),
......
...@@ -378,6 +378,18 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -378,6 +378,18 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// 主管把它改成 20 之后,常量算出来的天数和卡片上每一行都对不上。 // 主管把它改成 20 之后,常量算出来的天数和卡片上每一行都对不上。
if (heaviest && heaviest.loadAfter > p.dailyCalls * d) { if (heaviest && heaviest.loadAfter > p.dailyCalls * d) {
const needDays = Math.ceil(heaviest.loadAfter / p.dailyCalls); const needDays = Math.ceil(heaviest.loadAfter / p.dailyCalls);
/**
* 🔴 **超时效的一共几位**(2026-08-16 产品定)。只报最忙那一位分不出两种相反的局面:
* · 只有他一个人超 → 该做的是**给他少分点 / 改派几个**;
* · 全队都超 → 该做的是**减少这批 / 延长时效**。
* 同一句话、相反的处置 —— 而这条唯一给的选项是「整批时效改成 N 天」,
* 碰上第一种局面它本身就是错的(为一个人的负载去延长整批时效)。
* ⚠️ 只加一个数,⛔ 不在这里铺开每个人:确认单上每位客服那一行已经有「约 N 天」,
* 分布本来就在眼皮底下 ⇒ 这里一句话把他引过去看就够(引导节点的职责是
* **点出要他定的事**,不是展示数据)。
* ⚠️ 只有他一个人超时这半句**不出现** —— ⛔ 不制造无谓的噪音。
*/
const overCount = p.byAgent.filter((a) => a.loadAfter > p.dailyCalls * d).length;
out.push({ out.push({
key: 'daily_overload', key: 'daily_overload',
severity: SEV.WRONG_EXPECTATION, severity: SEV.WRONG_EXPECTATION,
...@@ -389,7 +401,10 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -389,7 +401,10 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// ⚠️ 「按每天 15 通算」这个前提要写出来:它是评审当天的口头经验值,不是实测。 // ⚠️ 「按每天 15 通算」这个前提要写出来:它是评审当天的口头经验值,不是实测。
why: why:
`其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条;` + `其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条;` +
`按每人每天 ${p.dailyCalls} 通算需要 ${needDays} 天,本批时效定的是 ${d} 天。`, `按每人每天 ${p.dailyCalls} 通算需要 ${needDays} 天,本批时效定的是 ${d} 天。` +
(overCount > 1
? `另有 ${overCount - 1} 位也超过 ${d} 天;每位分完之后要打几天,确认单上逐位都写着。`
: ''),
defaultLabel: `不处理 = 就按 ${d} 天发,到期没打完的自动落回池子,下批还能再分`, defaultLabel: `不处理 = 就按 ${d} 天发,到期没打完的自动落回池子,下批还能再分`,
/** /**
* 🔴 **只留带数的那一个**(2026-08-15 产品定)。 * 🔴 **只留带数的那一个**(2026-08-15 产品定)。
...@@ -442,12 +457,16 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -442,12 +457,16 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// 列 3/5/7 天必然漏掉他要的那个,而漏掉那次他只能去打字 → 走模型理解 → 慢且可能错。 // 列 3/5/7 天必然漏掉他要的那个,而漏掉那次他只能去打字 → 走模型理解 → 慢且可能错。
// ⚠️ 15(DAILY_CALLS_PER_AGENT)是**换算尺**,⛔ 不是容量上限、⛔ 不参与落人。 // ⚠️ 15(DAILY_CALLS_PER_AGENT)是**换算尺**,⛔ 不是容量上限、⛔ 不参与落人。
// //
// 🔴 **候选够不着 N 时不出**(2026-08-13):`target = min(batchSize, candidateTotal)`, // 🔴 **2026-08-16 撤掉「候选够不着 N 就不出」那道闸**(产品指出)。
// 候选 312 / N 495 时**瓶颈是候选不是 N** —— 而这两个按钮预填当前值、往上调: // 原判据是 `candidateTotal > batchSize`,理由写的是:候选 312 / N 495 时瓶颈是候选,
// 改时效 1→3 天 ⇒ N=1485,改每天几通 15→30 ⇒ N=990,target 都还是 312。 // 往上调(时效 1→3 天 ⇒ N=1485)`target` 还是 312,点了没反应,而 why 说"人数会跟着变"⇒ 说谎。
// 点了没有任何反应,而这条的 why 写着「这批人数都会跟着变」⇒ **它在说谎**。 // ⚠️ **那个理由只考虑了往上调。** 主管同样可以**往下调**:候选 312 / N 495 时把每天几通
// ⚠️ 判据要用严格 `>`:相等时候选同样是瓶颈(往上调仍然无效)。 // 从 15 改成 5,N=165 < 312 ⇒ target 真的变成 165。他想少发一些,而这道闸把入口藏了。
if (p.basis === 'default' && p.candidateTotal > p.batchSize) { // ⇒ 闸撤掉,改成**据实说明**:候选够不着 N 时,why 里直接讲清楚"往上不会更多、往下可以少发"。
// ⛔ 别再用"点了没反应"当不出的理由 —— 该修的是那句话,不是把节点藏起来。
if (p.basis === 'default') {
/// 候选就这么多人,N 已经够不着 —— 往上调无效、往下调有效,措辞必须分开
const capped = p.candidateTotal <= p.batchSize;
out.push({ out.push({
key: 'batch_size_basis', key: 'batch_size_basis',
severity: SEV.INFO, severity: SEV.INFO,
...@@ -458,7 +477,10 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -458,7 +477,10 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
/// 2026-08-15 实测这里和 `modelFacts` 两处都印错过,沿革见 `rosterCount` 的字段注释。 /// 2026-08-15 实测这里和 `modelFacts` 两处都印错过,沿革见 `rosterCount` 的字段注释。
title: `本批 ${p.batchSize} = 在岗 ${p.rosterCount} × 每天 ${p.dailyCalls} × ${d} `, title: `本批 ${p.batchSize} = 在岗 ${p.rosterCount} × 每天 ${p.dailyCalls} × ${d} `,
// ⚠️ 只陈述事实。⛔ 不写「建议改成 X 天」—— 一批推多大是主管的判断。 // ⚠️ 只陈述事实。⛔ 不写「建议改成 X 天」—— 一批推多大是主管的判断。
why: `这是系统按这个式子估的,不是根据历史算的。改时效或改每天几通,这批人数都会跟着变。`, why: capped
? `这是系统按这个式子估的,不是根据历史算的。符合条件的只有 ${p.candidateTotal} 人,已经全在本批里了 —— ` +
`往上调不会更多,往下调可以少发一些。`
: `这是系统按这个式子估的,不是根据历史算的。改时效或改每天几通,这批人数都会跟着变。`,
defaultLabel: '不处理 = 就按这个数发', defaultLabel: '不处理 = 就按这个数发',
options: [ options: [
{ {
......
...@@ -218,7 +218,7 @@ export class AssignmentController { ...@@ -218,7 +218,7 @@ export class AssignmentController {
@ApiOperation({ @ApiOperation({
summary: '重新排一版:遇到专属排满的就跳过,从池子里往后取,尽量凑满 N', summary: '重新排一版:遇到专属排满的就跳过,从池子里往后取,尽量凑满 N',
description: description:
'⚠️ **不改变"不动别人客户"这条底线** —— 只是换一批**专属没排满 / 无**的人来凑,' + '⚠️ **不改变"不动别人客户"这条底线** —— 只是换一批**专属没排满 / 无专属**的人来凑,' +
'一条专属关系都不动。代价是这批人整体排名往后走(产品判定:批内名次对主管没有意义,' + '一条专属关系都不动。代价是这批人整体排名往后走(产品判定:批内名次对主管没有意义,' +
'这批人本来就共享同一组特征)。', '这批人本来就共享同一组特征)。',
}) })
......
...@@ -834,10 +834,15 @@ export class PlanAssignmentService { ...@@ -834,10 +834,15 @@ export class PlanAssignmentService {
* 回答文档里那三个问题的第三个:「团队现在什么状态」。 * 回答文档里那三个问题的第三个:「团队现在什么状态」。
* *
* ── 两种时间性混在一张表里,必须说清 ──────────────────────────── * ── 两种时间性混在一张表里,必须说清 ────────────────────────────
* · **在手 / 超期** —— **此刻**的状态(手上还压着多少、其中多少过了时效),与窗口无关; * · **在手** —— **此刻**手上还压着多少(`status='assigned'`),与窗口无关;
* · **完成 / 退回 / 已处置 / 没动** —— **窗口内**发生的事(近 7 天 / 近 30 天)。 * · **超期 / 完成 / 退回 / 已处置 / 没动** —— **窗口内**的事(近 7 天 / 近 30 天)。
* ⚠️ 界面上必须写明这件事,否则主管会把「在手 62」当成"这 7 天分了 62 条"。 * ⚠️ 界面上必须写明这件事,否则主管会把「在手 62」当成"这 7 天分了 62 条"。
* *
* 🔴 **超期也是带窗口的**(2026-08-16 修注释:原文把它和「在手」并列写成"与窗口无关",
* 与下面那条 SQL 对不上 —— SQL 里有 `assignment_expires_at >= since`,是**对的**)。
* 理由:一年前过期的单报上来对主管没有意义,他此刻能处置的只有近期这批。
* ⛔ 别照注释把 SQL 的窗口去掉。
*
* ⚠️ 「超期」必须带上**且没约下次回访**这半句,与 detail 的 agentStats 同一条判据: * ⚠️ 「超期」必须带上**且没约下次回访**这半句,与 detail 的 agentStats 同一条判据:
* 到期回收器刻意跳过 snoozedUntil 在未来的单(客服约了 6/10 回访,那之前不能收走), * 到期回收器刻意跳过 snoozedUntil 在未来的单(客服约了 6/10 回访,那之前不能收走),
* 不加守卫会把"打了电话、约好下次"的人显示成"压着单没动" —— 干得最好的那个被指责。 * 不加守卫会把"打了电话、约好下次"的人显示成"压着单没动" —— 干得最好的那个被指责。
......
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import type { AppConfig } from '../config/configuration';
/**
* InvocationRetentionService —— 定期把 `agent_invocations` 里**过期的肥字段**清掉,只留元数据。
*
* ═══ 为什么必须有 ═══════════════════════════════════════════════
* schema 上早就写着「成功调用 N 天后清 inputSnapshot 仅保留元数据」,但这个任务一直不存在。
* 助手接进来之后写入频率上一个量级(每轮对话一行),⚠️ 而测试机盘常年 90%+、
* PG 卷已占 75 G —— **不清就是把一个已知的紧张问题变成事故**。
*
* ═══ 清什么、留什么 ════════════════════════════════════════════
* 清:inputSnapshot(置为 {"pruned":true})· promptTemplate · systemPrompt · outputText
* —— 这四样占了一行 95% 以上的字节。
* 留:token / 成本 / 延迟 / status / callKey / promptVersion / modelName / judge / userFeedback
* —— 仪表盘、成本聚合、通过率统计要的全在这些列上,⛔ 一个都不动。
*
* ⚠️ **失败的留更久**(默认 3 倍):失败行的输入正是排查时唯一能看的东西。
* ⚠️ `inputSnapshot` 是 NOT NULL 列 ⇒ 只能覆盖成占位对象,⛔ 不能置 null。
* ⚠️ ⛔ 不删行:元数据行 ~1 KB,留着才有长期的成本 / 通过率曲线;肥字段清掉后
* 增长量已经可控(按 1800 次/天算约 0.65 GB/年)。
*/
@Injectable()
export class InvocationRetentionService {
private readonly logger = new Logger(InvocationRetentionService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService<AppConfig, true>,
) {}
/** 每天 04:00(沪)—— 避开 DW 08:00 落库与随后的增量摄入。 */
@Cron(process.env.PAC_INVOCATION_RETENTION_CRON || '0 4 * * *', {
name: 'invocation-retention',
timeZone: 'Asia/Shanghai',
})
async prune(): Promise<void> {
const days = this.config.get('ai', { infer: true }).invocationRetentionDays;
if (days <= 0) {
this.logger.log('保留期配置为 0 —— 跳过清理(AI_INVOCATION_RETENTION_DAYS)');
return;
}
const okCount = await this.pruneOlderThan(days, ['succeeded', 'cached']);
// 失败的留 3 倍时长:排查时唯一能看的就是它的输入
const failCount = await this.pruneOlderThan(days * 3, ['failed']);
this.logger.log(
`invocation 清理完成:成功/缓存 ${okCount} 行(>${days} 天)· 失败 ${failCount} 行(>${days * 3} 天)`,
);
}
/**
* 清一批。⚠️ `updatedAt` 是 `@updatedAt` 列,这次 update 会把它刷新 ——
* 所以判据用 `startedAt`,⛔ 不能用 updatedAt(否则清过的行永远追不上闸门,每天重清一遍)。
*/
private async pruneOlderThan(days: number, statuses: string[]): Promise<number> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const res = await this.prisma.agentInvocation.updateMany({
where: {
startedAt: { lt: cutoff },
status: { in: statuses },
// ⭐ 幂等闸:已经清过的不再进来(否则每天全表扫一遍已清的行)
NOT: { inputSnapshot: { equals: { pruned: true } } },
},
data: {
inputSnapshot: { pruned: true },
promptTemplate: null,
systemPrompt: null,
outputText: null,
},
});
return res.count;
}
}
...@@ -11,6 +11,7 @@ import { QueueProducer } from './queue-producer.service'; ...@@ -11,6 +11,7 @@ import { QueueProducer } from './queue-producer.service';
import { StaleScanService } from './stale-scan.service'; import { StaleScanService } from './stale-scan.service';
import { SyncIncrementalSchedulerService } from './sync-incremental.scheduler'; import { SyncIncrementalSchedulerService } from './sync-incremental.scheduler';
import { DwLagMonitorService } from './dw-lag-monitor.service'; import { DwLagMonitorService } from './dw-lag-monitor.service';
import { InvocationRetentionService } from './invocation-retention.service';
import { DailyHealthReportService } from './daily-health-report.service'; import { DailyHealthReportService } from './daily-health-report.service';
import { DailyHealthReportController } from './daily-health-report.controller'; import { DailyHealthReportController } from './daily-health-report.controller';
import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor'; import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor';
...@@ -71,6 +72,7 @@ import { ColdImportProcessor } from './processors/cold-import.processor'; ...@@ -71,6 +72,7 @@ import { ColdImportProcessor } from './processors/cold-import.processor';
SyncIncrementalSchedulerService, SyncIncrementalSchedulerService,
DwLagMonitorService, DwLagMonitorService,
DailyHealthReportService, DailyHealthReportService,
InvocationRetentionService,
PersonaRecomputeProcessor, PersonaRecomputeProcessor,
PlanRecomputeProcessor, PlanRecomputeProcessor,
PlanAssetGenerateProcessor, PlanAssetGenerateProcessor,
......
...@@ -212,11 +212,9 @@ describe('引导节点 · 判定', () => { ...@@ -212,11 +212,9 @@ describe('引导节点 · 判定', () => {
}); });
/** /**
* 🔴 `short_supply` 与 `batch_size_basis` **互斥**(2026-08-13 加判据之后): * ⚠️ 2026-08-16:`batch_size_basis` 的「候选够不着 N 就不出」那道闸**撤了**(产品指出:
* 前者要 `候选 < N`,后者要 `候选 > N` —— 同一份提案不可能两个都成立。 * 那个理由只考虑往上调,而主管同样可以往下调、少发一些)。⇒ 两种局面它都出,
* ⚠️ 所以"四个同时成立"这件事从此不存在,这条改成分两种局面各断一次。 * 差别在 `why` 的措辞。下面这条改成断"两种局面都有它"。
* ⚠️ 2026-08-14 `short_supply` 整条删掉之后,局面 A 就只剩派活那两条了 ——
* 「这批怎么来的」那一组在候选不够时**本来就没有要主管定的事**。
*/ */
test('🔴 ⛔ 不截断 —— 成立几条出几条,防噪音靠 tier 分层', () => { test('🔴 ⛔ 不截断 —— 成立几条出几条,防噪音靠 tier 分层', () => {
// 局面 A:候选不够(N 是够的,人不够) // 局面 A:候选不够(N 是够的,人不够)
...@@ -229,7 +227,16 @@ describe('引导节点 · 判定', () => { ...@@ -229,7 +227,16 @@ describe('引导节点 · 判定', () => {
byAgent: [agent('张悦', 90)], byAgent: [agent('张悦', 90)],
}), }),
); );
expect(short.map((x) => x.key)).toEqual(['pending', 'daily_overload']); expect(short.map((x) => x.key)).toEqual(['pending', 'daily_overload', 'batch_size_basis']);
/**
* 🔴 候选够不着 N 时,那句话必须**据实**说清方向 —— 往上调确实不会更多。
* ⛔ 不许再说「改时效或改每天几通,这批人数都会跟着变」:他往上调一次没反应,
* 下次就不信这张卡片上的任何一句话了。
*/
const capped = short.find((x) => x.key === 'batch_size_basis')!;
expect(capped.why).toContain('往上调不会更多');
expect(capped.why).toContain('往下调可以少发');
expect(capped.why).not.toContain('都会跟着变');
// 局面 B:候选管够(瓶颈是 N)—— 这时才轮到「本批多大是怎么估的」 // 局面 B:候选管够(瓶颈是 N)—— 这时才轮到「本批多大是怎么估的」
const ample = computeSignals( const ample = computeSignals(
...@@ -273,18 +280,33 @@ describe('引导节点 · 判定', () => { ...@@ -273,18 +280,33 @@ describe('引导节点 · 判定', () => {
); );
}); });
test('🔴 候选够不着 N → ⛔ 不出「本批多大」(改时效往上是空操作,说会变就是说谎)', () => { /**
// target = min(batchSize, candidateTotal):候选 312 / N 495 时改 N 往上,target 还是 312 * 🔴 2026-08-16 **反过来了**:原来这条锁的是「候选够不着 N 就不出」,
expect( * 理由是"改 N 往上是空操作,说会变就是说谎"。产品指出:**那只考虑了往上调** ——
keys(proposal({ basis: 'default', candidateTotal: 312, target: 312, batchSize: 495 })), * 候选 312 / N 495 时把每天几通 15→5,N=165 < 312 ⇒ target 真的变成 165。
).not.toContain('batch_size_basis'); * 他想少发一些,而那道闸把入口藏了。
// 相等时候选同样是瓶颈 —— 判据必须是严格 > * ⇒ 现在**两种局面都出**,差别在 `why` 的措辞:够不着时据实说"往上不会更多、往下可以少发"。
expect( * ⛔ 别再用"点了没反应"当不出的理由 —— 该修的是那句话,不是把节点藏起来。
keys(proposal({ basis: 'default', candidateTotal: 495, target: 495, batchSize: 495 })), */
).not.toContain('batch_size_basis'); test('🔴 候选够不着 N 时照样出「本批多大」—— 他可以往下调、少发一些', () => {
expect( for (const c of [
keys(proposal({ basis: 'default', candidateTotal: 5000, target: 495, batchSize: 495 })), { candidateTotal: 312, target: 312, batchSize: 495 }, // 候选 < N
).toContain('batch_size_basis'); { candidateTotal: 495, target: 495, batchSize: 495 }, // 候选 = N
]) {
const s = computeSignals(proposal({ basis: 'default', ...c })).find(
(x) => x.key === 'batch_size_basis',
);
expect({ 局面: c.candidateTotal, 出了吗: s != null }).toEqual({
局面: c.candidateTotal,
出了吗: true,
});
expect(s!.why).toContain('往下调可以少发');
}
// 候选管够时仍是原来那句(往上往下都真的会变)
const ample = computeSignals(
proposal({ basis: 'default', candidateTotal: 5000, target: 495, batchSize: 495 }),
).find((x) => x.key === 'batch_size_basis')!;
expect(ample.why).toContain('都会跟着变');
}); });
test('⛔ 模型拿不到 options 的技术细节 —— why 里不许出现 intent id', () => { test('⛔ 模型拿不到 options 的技术细节 —— why 里不许出现 intent id', () => {
...@@ -743,3 +765,39 @@ describe('没分到的那几位 —— 「为什么没轮到」要答得上来', ...@@ -743,3 +765,39 @@ describe('没分到的那几位 —— 「为什么没轮到」要答得上来',
expect(facts([])).not.toHaveProperty('他们现在手上有'); expect(facts([])).not.toHaveProperty('他们现在手上有');
}); });
}); });
/**
* 🔴 「最忙的那位」要报**一共几位超时效**(2026-08-16 产品定)。
* 只报最忙一位,主管分不出"个别人忙"和"全队都忙" —— 而这两种局面的处置相反:
* 前者该给他少分点/改派,后者该减量/延时效。而本节点唯一的选项是「整批时效改成 N 天」,
* 碰上前者它本身就是错的。
*/
describe('引导节点 · 最忙的那位要说清是一个人还是一片', () => {
const over = (name: string, loadAfter: number) => ({
userId: name,
name,
count: 5,
inHandBefore: loadAfter - 5,
loadAfter,
});
test('🔴 多人超时效 → 报「另有 N 位」,并把他引到确认单看逐位明细', () => {
// 时效 1 天 × 每天 15 通 = 15 条封顶;三个人都超
const s = computeSignals(
proposal({
expiresInDays: 1,
byAgent: [over('王强', 45), over('李莉', 30), over('赵敏', 20), over('周涛', 9)],
}),
).find((x) => x.key === 'daily_overload')!;
expect(s.title).toContain('王强'); // 最忙的那位仍然点名
expect(s.why).toContain('另有 2 位也超过 1 天'); // 3 位超 → 除最忙外还有 2 位
expect(s.why).toContain('确认单上逐位都写着');
});
test('⛔ 只有一个人超 → 那半句不出现(不制造无谓噪音)', () => {
const s = computeSignals(
proposal({ expiresInDays: 1, byAgent: [over('王强', 45), over('周涛', 9)] }),
).find((x) => x.key === 'daily_overload')!;
expect(s.why).not.toContain('另有');
});
});
import type { ModelMessage } from 'ai';
import {
slimInputSnapshot,
briefToolArgs,
truncateOutputText,
assistantCallKey,
assistantInputHash,
} from '../src/modules/assistant/assistant-invocation';
import { estimateCostYuan, FALLBACK_PRICE } from '../src/modules/ai/core/cost';
/**
* 助手落 `agent_invocations` 的**纯函数层**。
*
* 这组测试守的是同一件事:**落库量不能随对话轮数涨**。
* 助手的 messages 里带着确认单那类肥载荷,一旦把上下文原样落进去,
* 第 N 轮就要写前 N-1 轮的全部内容 —— 按平方涨(实测口径 ~100 KB/行 vs ~2.5 KB/行)。
*/
/** 造一段带工具往返的多轮对话 —— 工具返回值刻意做得很肥。 */
function conversation(turns: number, fatBytes = 50_000): ModelMessage[] {
const out: ModelMessage[] = [];
for (let i = 1; i <= turns; i++) {
out.push({ role: 'user', content: `第 ${i} 轮:给我出一版` } as ModelMessage);
out.push({
role: 'assistant',
content: [{ type: 'text', text: 'x'.repeat(fatBytes) }],
} as unknown as ModelMessage);
}
return out;
}
describe('slimInputSnapshot —— 只存当轮', () => {
it('只取最后一条用户消息,⛔ 不带历史', () => {
const s = slimInputSnapshot(conversation(3));
expect(s.userText).toBe('第 3 轮:给我出一版');
expect(s.turnNo).toBe(3);
expect(s.priorTurns).toBe(2);
});
it('🔴 快照大小不随轮数增长 —— 这是这层存在的全部理由', () => {
const small = JSON.stringify(slimInputSnapshot(conversation(1))).length;
const large = JSON.stringify(slimInputSnapshot(conversation(20))).length;
// 只有 turnNo/priorTurns 的位数会变,差几个字符而已
expect(large - small).toBeLessThan(10);
// 而原始上下文本身是几十万字节
expect(JSON.stringify(conversation(20)).length).toBeGreaterThan(1_000_000);
});
it('⛔ 不含任何工具返回值 / 助手历史发言', () => {
const raw = JSON.stringify(slimInputSnapshot(conversation(5)));
expect(raw).not.toContain('xxxx');
});
it('用户原话截断到 2000 字', () => {
const s = slimInputSnapshot([
{ role: 'user', content: '甲'.repeat(5000) } as ModelMessage,
]);
expect(s.userText).toHaveLength(2000);
});
it('content 为 parts 数组时也能取到文本', () => {
const s = slimInputSnapshot([
{ role: 'user', content: [{ type: 'text', text: '换成正畸' }] } as unknown as ModelMessage,
]);
expect(s.userText).toBe('换成正畸');
});
it('没有用户消息时不炸', () => {
expect(slimInputSnapshot([])).toMatchObject({ turnNo: 0, userText: '', priorTurns: 0 });
});
it('现场上下文有才带,⛔ 不塞 undefined 键', () => {
const bare = slimInputSnapshot([{ role: 'user', content: 'a' } as ModelMessage]);
expect(Object.keys(bare)).not.toContain('activeClinicId');
const withCtx = slimInputSnapshot([{ role: 'user', content: 'a' } as ModelMessage], {
activeClinicId: 'c1',
activePatientId: 'p1',
});
expect(withCtx).toMatchObject({ activeClinicId: 'c1', activePatientId: 'p1' });
});
});
describe('briefToolArgs —— 参数截断', () => {
it('肥载荷截到 200 字', () => {
expect(briefToolArgs({ patients: 'p'.repeat(9999) })).toHaveLength(200);
});
it('不可序列化的对象不抛', () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
expect(briefToolArgs(cyclic)).toBe('[unserializable]');
});
it('undefined 归一成 {}', () => {
expect(briefToolArgs(undefined)).toBe('{}');
});
});
describe('truncateOutputText', () => {
it('截到 8000', () => {
expect(truncateOutputText('乙'.repeat(20_000))).toHaveLength(8000);
});
it('空值原样返回 undefined', () => {
expect(truncateOutputText(undefined)).toBeUndefined();
expect(truncateOutputText('')).toBeUndefined();
});
});
describe('callKey 按现场分', () => {
it('主管走分配线,客服走打单线', () => {
expect(assistantCallKey(true)).toBe('assistant_assignment');
expect(assistantCallKey(false)).toBe('assistant_execute');
});
});
describe('inputHash', () => {
it('同输入同 hash,改一个字就变', () => {
const a = slimInputSnapshot([{ role: 'user', content: '发 200 人' } as ModelMessage]);
const b = slimInputSnapshot([{ role: 'user', content: '发 300 人' } as ModelMessage]);
const k = 'assistant_assignment';
const v = 'assistant@test';
expect(assistantInputHash(k, v, a)).toBe(assistantInputHash(k, v, a));
expect(assistantInputHash(k, v, a)).not.toBe(assistantInputHash(k, v, b));
});
it('提示词版本变了,hash 必须跟着变', () => {
const s = slimInputSnapshot([{ role: 'user', content: 'x' } as ModelMessage]);
expect(assistantInputHash('k', 'v1', s)).not.toBe(assistantInputHash('k', 'v2', s));
});
});
describe('estimateCostYuan —— 与 AiCallRunner 共用同一份口径', () => {
const table = { m1: { inHit: 1, inMiss: 10, out: 100 } };
it('命中缓存的部分按 inHit 计价', () => {
const { yuan } = estimateCostYuan(table, 'm1', 1_000_000, 0, 1_000_000);
expect(yuan).toBeCloseTo(1, 6);
});
it('未命中的部分按 inMiss 计价', () => {
const { yuan } = estimateCostYuan(table, 'm1', 1_000_000, 0, 0);
expect(yuan).toBeCloseTo(10, 6);
});
it('输出按 out 计价', () => {
const { yuan } = estimateCostYuan(table, 'm1', 0, 1_000_000, 0);
expect(yuan).toBeCloseTo(100, 6);
});
it('🔴 价目表里没有就报 priceMissing —— ⛔ 不许静默低报', () => {
const { priceMissing } = estimateCostYuan(table, '未知模型', 100, 100);
expect(priceMissing).toBe(true);
});
it('cached > prompt 时 clamp,不出负数', () => {
const { yuan } = estimateCostYuan(table, 'm1', 100, 0, 999_999);
expect(yuan).toBeGreaterThanOrEqual(0);
expect(yuan).toBeCloseTo((100 * 1) / 1_000_000, 9);
});
it('表全空时走兜底价', () => {
const { yuan, priceMissing } = estimateCostYuan({}, 'x', 1_000_000, 0, 0);
expect(priceMissing).toBe(true);
expect(yuan).toBeCloseTo(FALLBACK_PRICE.inMiss, 6);
});
});
import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { InvocationRetentionService } from '../src/queues/invocation-retention.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* `agent_invocations` 的保留期清理。
*
* 🔴 这段直接关系到**磁盘**:助手接进来之后按对话轮数写库,
* 而测试机盘常年 90%+、PG 卷已占 75 G。不清就是把一个已知的紧张问题变成事故。
*
* 这里锁住四件事:清哪些状态 · 失败留更久 · 清哪几列 · 幂等闸在不在。
*/
type Where = Record<string, unknown>;
type Call = { where: Where; data: Record<string, unknown> };
function build(retentionDays: number) {
const calls: Call[] = [];
const prisma = {
agentInvocation: {
updateMany: jest.fn(async (args: Call) => {
calls.push(args);
return { count: 1 };
}),
},
};
return { calls, prisma };
}
async function make(retentionDays: number) {
const { calls, prisma } = build(retentionDays);
const mod = await Test.createTestingModule({
providers: [
InvocationRetentionService,
{ provide: PrismaService, useValue: prisma },
{
provide: ConfigService,
useValue: { get: () => ({ invocationRetentionDays: retentionDays }) },
},
],
}).compile();
return { svc: mod.get(InvocationRetentionService), calls, prisma };
}
describe('InvocationRetentionService', () => {
it('分两批清:成功/缓存一批,失败一批', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls).toHaveLength(2);
expect(calls[0].where.status).toEqual({ in: ['succeeded', 'cached'] });
expect(calls[1].where.status).toEqual({ in: ['failed'] });
});
it('🔴 失败的留 3 倍时长 —— 排查时唯一能看的就是它的输入', async () => {
const { svc, calls } = await make(30);
await svc.prune();
const okCutoff = (calls[0].where.startedAt as { lt: Date }).lt.getTime();
const failCutoff = (calls[1].where.startedAt as { lt: Date }).lt.getTime();
const days = (ms: number) => Math.round((Date.now() - ms) / 86_400_000);
expect(days(okCutoff)).toBe(30);
expect(days(failCutoff)).toBe(90);
});
it('只清肥字段,⛔ 不碰 token / 成本 / 状态这些元数据列', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls[0].data).toEqual({
inputSnapshot: { pruned: true },
promptTemplate: null,
systemPrompt: null,
outputText: null,
});
for (const k of ['costYuan', 'totalTokens', 'status', 'callKey', 'promptVersion', 'judgeScore', 'userFeedback']) {
expect(Object.keys(calls[0].data)).not.toContain(k);
}
});
it('🔴 带幂等闸 —— 已清过的不再进来,否则每天全表重扫', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls[0].where.NOT).toEqual({ inputSnapshot: { equals: { pruned: true } } });
});
it('⚠️ 判据用 startedAt,⛔ 不能用 updatedAt(清理本身会刷新它)', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls[0].where).toHaveProperty('startedAt');
expect(calls[0].where).not.toHaveProperty('updatedAt');
});
it('配 0 = 关掉清理,一次都不写库', async () => {
const { svc, prisma } = await make(0);
await svc.prune();
expect(prisma.agentInvocation.updateMany).not.toHaveBeenCalled();
});
});
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