fix(rectification): surface pricing failures and persist opening question
This commit is contained in:
@@ -6974,3 +6974,35 @@
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-455 | 定价配置异常被通用运行失败吞掉
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-31
|
||||
- 最近更新:2026-08-31
|
||||
- 影响面:POST `/api/rectification/agent`、POST `/api/consult`、POST `/api/reports`、`FeaturePricingError` 错误映射
|
||||
- 用户现象:环境没有发布对应 feature/model tier 定价时,生时校正显示通用“暂时不可用,请稍后再试”,咨询和报告也只落入通用 503;文案暗示重试,但实际需要运维发布定价配置。
|
||||
- 触发条件:`resolve_feature_pricing` 返回 `feature_pricing_missing`、`feature_pricing_model_unavailable` 或其他 fail-closed 定价错误。
|
||||
- 根因:三个付费入口没有保留 `FeaturePricingError.code`;生时校正把 reserve 异常统一改成 `billing_unavailable`,随后又未在外层显式映射而落到 `run_failed`,consult/reports 则由通用 catch 吞掉。
|
||||
- 修复:三个入口均只把 `FeaturePricingError.code` 写入脱敏服务端日志;生时校正保留内部 `feature_pricing_*` reason,并统一映射为公开 `billing_unavailable`;咨询和报告统一返回 `pricing_configuration_unavailable`。用户文案明确“计费配置不可用/尚未完成,请联系支持”,不返回内部错误原文。
|
||||
- 验证:新增三入口路由契约,断言内部 code 被保留、公开错误不落入 `run_failed`、响应不包含 `error.message`;TypeScript、ESLint、billing/consult/report 聚焦回归通过。rectification 聚焦套件 `705/705`;全量 `2368/2369`,唯一失败为默认 `python3` 缺少 PyYAML,指定已有 PyYAML 的解释器后原失败文件 `39/39` 通过。真实 staging 定价行与日志因本地无环境凭据未验证。
|
||||
- 防复发:付费入口必须把配置缺失与瞬时运行错误分开;内部日志记录稳定 code,公开响应只使用安全错误码和可执行文案。部署完成不等于定价可用,仍须通过 admin flow 发布当前环境实际 model tier 的定价。
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-456 | opening 成功后未持久化当前问题槽
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-31
|
||||
- 最近更新:2026-08-31
|
||||
- 影响面:POST `/api/rectification/agent` opening 成功路径、`persistNextInterviewIfIdle`、`ensureNonTerminalTurnExit`
|
||||
- 用户现象:开场回复正文看起来提出了问题,但服务端 `current_question` 仍为空,页面显示“当前没有可回答的问题,正在等待服务端更新”。
|
||||
- 触发条件:新 Case 执行 `action=opening` 且 Agent 没有自行调用可持久化 focus 的工具。
|
||||
- 根因:成功轮后的问题槽持久化与非终态出口检查都被限制在 `action === "message"`;opening 只保存散文回复,没有建立服务端拥有的可回答问题槽。
|
||||
- 修复:成功的 `opening` 与 `message` 一起执行 `persistNextInterviewIfIdle`;随后执行 `ensureNonTerminalTurnExit`,在仍无问题且没有已确认、用户停止或真实可采用承载时确定性补出问题。`read_only` 保持无副作用,不纳入该分支。
|
||||
- 验证:回归锁定 opening/message 共用问题槽持久化与非终态出口路径,并以真实 `persistNextInterviewIfIdle` fixture 断言投影后的 `current_question` 非空;TypeScript、ESLint 与 rectification 聚焦套件 `705/705` 通过。未进行真实 staging opening smoke。
|
||||
- 防复发:任何会启动或推进 Case 的成功非终态轮都必须以服务端状态结束:存在 `current_question` 或真实可采用承载;不得以模型正文是否包含问句代替结构化状态。
|
||||
- 相关记录:BUG-452、BUG-453
|
||||
- 复发自:BUG-452(非终态出口只覆盖 message,未覆盖 opening)
|
||||
- 修复版本:待发布(Skill 保持 `10.0.13`)
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "@/lib/consultation-entrypoint";
|
||||
import { CreditRpcError } from "@/lib/consultation-billing";
|
||||
import { cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage } from "@/lib/agent-generation-settings";
|
||||
import { resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
|
||||
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
@@ -393,6 +393,19 @@ export async function POST(request: Request) {
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof FeaturePricingError) {
|
||||
console.error(
|
||||
`[billing] reservation failed request=${requestId} reason=${error.code}`,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "计费配置不可用",
|
||||
message: "当前服务的计费配置尚未完成,请联系支持人员,本次不会扣点。",
|
||||
code: "pricing_configuration_unavailable",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (error instanceof ConsultationPlanValidationError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ import { safePublicEvent } from "@/lib/rectification-agentic/v9/stream-mapping";
|
||||
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "@/lib/rectification-agentic/v9/case-status";
|
||||
import { blocksPromptExtraction } from "@/lib/consult-safety";
|
||||
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
|
||||
import { resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||
import { loadRuntimeFeatureFlags } from "@/lib/feature-flags";
|
||||
import { isProductEnabled } from "@/lib/product-access";
|
||||
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
|
||||
@@ -579,8 +579,9 @@ export async function POST(request: Request) {
|
||||
status: result.reason === "insufficient_credits" ? 402 : 503,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[rectification-v9] reserve failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`);
|
||||
return { success: false, reason: "billing_unavailable", status: 503 };
|
||||
const reason = error instanceof FeaturePricingError ? error.code : "billing_unavailable";
|
||||
console.error(`[rectification-v9] reserve failed case=${caseId} code=${reason}`);
|
||||
return { success: false, reason, status: 503 };
|
||||
}
|
||||
},
|
||||
async complete(usage) {
|
||||
@@ -672,9 +673,9 @@ export async function POST(request: Request) {
|
||||
if (!result.ok) {
|
||||
send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" });
|
||||
} else {
|
||||
if (action === "message") {
|
||||
if (action === "message" || action === "opening") {
|
||||
try {
|
||||
const idle = await persistNextInterviewIfIdle({
|
||||
await persistNextInterviewIfIdle({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
@@ -685,7 +686,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
try {
|
||||
const exit = await ensureNonTerminalTurnExit({
|
||||
await ensureNonTerminalTurnExit({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
@@ -711,6 +712,8 @@ export async function POST(request: Request) {
|
||||
send({ type: "error", code, message: "当前账户触发安全熔断,请稍后再试或联系支持。" });
|
||||
} else if (code === "billing_denied") {
|
||||
send({ type: "error", code, message: "暂时无法确认校正点数,请稍后重试。" });
|
||||
} else if (code.startsWith("feature_pricing_") || code === "billing_unavailable") {
|
||||
send({ type: "error", code: "billing_unavailable", message: "当前服务的计费配置不可用,请联系支持人员。" });
|
||||
} else if (code.includes("legacy_skill_identity_unverifiable")) {
|
||||
send({ type: "error", code: "skill_identity_unverifiable", message: "该校正绑定的是无法核验的历史 Skill,请先采用当前注册版本。" });
|
||||
} else if (code.includes("skill_identity_missing")) {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
|
||||
import { resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
@@ -138,6 +138,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const catalog = await loadLanguageModelCatalog();
|
||||
const defaultModel = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
|
||||
let pricingConfigurationErrorCode: string | null = null;
|
||||
const deps: ReportCreateCoreDeps = {
|
||||
requestUrl: request.url,
|
||||
origin: request.headers.get("origin"),
|
||||
@@ -199,11 +200,19 @@ export async function POST(request: Request) {
|
||||
model: defaultModel,
|
||||
billing: {
|
||||
reserve: async ({ userId: billingUserId, requestId, modelId }) => {
|
||||
const pricing = await resolveFeaturePricing(admin, "report.full", modelId);
|
||||
return authorizeUsage(admin, {
|
||||
userId: billingUserId, requestId, featureKey: "report.full",
|
||||
requestedModelId: modelId, creditCost: pricing.credit_cost,
|
||||
});
|
||||
try {
|
||||
const pricing = await resolveFeaturePricing(admin, "report.full", modelId);
|
||||
return authorizeUsage(admin, {
|
||||
userId: billingUserId, requestId, featureKey: "report.full",
|
||||
requestedModelId: modelId, creditCost: pricing.credit_cost,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof FeaturePricingError) {
|
||||
pricingConfigurationErrorCode = error.code;
|
||||
console.error(`[reports] billing reservation failed request=${requestId} reason=${error.code}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
complete: async ({ userId: billingUserId, requestId, usage }) => {
|
||||
if (!defaultModel) return false;
|
||||
@@ -231,6 +240,16 @@ export async function POST(request: Request) {
|
||||
};
|
||||
|
||||
const response = await resolveReportCreate(deps);
|
||||
if (pricingConfigurationErrorCode) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "计费配置不可用",
|
||||
message: "当前服务的计费配置尚未完成,请联系支持人员,本次不会扣点。",
|
||||
code: "pricing_configuration_unavailable",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (response.status >= 500 && profileError) {
|
||||
console.error(`[reports] create failed request=${String(deps.rawBody && typeof deps.rawBody === "object"
|
||||
? (deps.rawBody as Record<string, unknown>).requestId ?? "unknown"
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type RectificationActivityChangedEvent,
|
||||
type RectificationActivityEvent,
|
||||
type RectificationChoiceAppliedEvent,
|
||||
type PublicRectificationActivity,
|
||||
type PublicRectificationMethod,
|
||||
type PublicRectificationPhase,
|
||||
type PublicRectificationTool,
|
||||
@@ -36,6 +35,7 @@ export type PublicPhaseStreamEvent = Readonly<{
|
||||
|
||||
export type PublicErrorCode =
|
||||
| "billing_denied"
|
||||
| "billing_unavailable"
|
||||
| "skill_identity_unverifiable"
|
||||
| "skill_identity_missing"
|
||||
| "skill_identity_mismatch"
|
||||
@@ -284,6 +284,7 @@ export function safePublicEvent(value: unknown): PublicStreamEvent | null {
|
||||
if (event.type === "error") {
|
||||
const codes = new Set<PublicErrorCode>([
|
||||
"billing_denied",
|
||||
"billing_unavailable",
|
||||
"skill_identity_unverifiable",
|
||||
"skill_identity_missing",
|
||||
"skill_identity_mismatch",
|
||||
|
||||
@@ -31,6 +31,20 @@ test("Agentic rectification reuses one case-level usage authorization and the se
|
||||
assert.match(rectificationRoute, /releaseUsage\(accounting, userId, billingRequestId,/);
|
||||
});
|
||||
|
||||
test("Agentic rectification preserves pricing failure codes for logs and maps them to one safe public error", () => {
|
||||
const reserveBilling = sourceBetween(rectificationRoute, "async reserve() {", "async complete(usage) {");
|
||||
const errorMapping = sourceBetween(rectificationRoute, "const code = error instanceof RectificationToolServiceError", " } finally {");
|
||||
|
||||
assert.match(rectificationRoute, /import \{ FeaturePricingError, resolveFeaturePricing \} from "@\/lib\/feature-pricing"/);
|
||||
assert.match(reserveBilling, /error instanceof FeaturePricingError \? error\.code : "billing_unavailable"/);
|
||||
assert.match(reserveBilling, /reserve failed case=\$\{caseId\} code=\$\{reason\}/);
|
||||
assert.match(reserveBilling, /return \{ success: false, reason, status: 503 \}/);
|
||||
assert.match(errorMapping, /code\.startsWith\("feature_pricing_"\) \|\| code === "billing_unavailable"/);
|
||||
assert.match(errorMapping, /code: "billing_unavailable", message: "当前服务的计费配置不可用,请联系支持人员。"/);
|
||||
assert.ok(errorMapping.indexOf('code: "billing_unavailable"') < errorMapping.lastIndexOf('code: "run_failed"'));
|
||||
assert.doesNotMatch(reserveBilling + errorMapping, /error\.message/);
|
||||
});
|
||||
|
||||
test("free Agentic rectification turns bypass reservation, completion, and cancellation settlement", () => {
|
||||
const reserveBilling = sourceBetween(rectificationRoute, "async reserve() {", "async complete(usage) {");
|
||||
const completeBilling = sourceBetween(rectificationRoute, "async complete(usage) {", "async release() {");
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const reportsRoute = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
|
||||
const safeResponse = /error: "计费配置不可用",\s*message: "当前服务的计费配置尚未完成,请联系支持人员,本次不会扣点。",\s*code: "pricing_configuration_unavailable"/;
|
||||
|
||||
test("consult pricing failures log FeaturePricingError.code and return an actionable safe response", () => {
|
||||
assert.match(consultRoute, /import \{ FeaturePricingError, resolveFeaturePricing \} from "@\/lib\/feature-pricing"/);
|
||||
assert.match(consultRoute, /error instanceof FeaturePricingError[\s\S]*reason=\$\{error\.code\}/);
|
||||
assert.match(consultRoute, safeResponse);
|
||||
assert.doesNotMatch(consultRoute, /pricing_configuration_unavailable[\s\S]{0,300}error\.message/);
|
||||
});
|
||||
|
||||
test("reports pricing failures preserve the internal code while returning the same safe response", () => {
|
||||
assert.match(reportsRoute, /import \{ FeaturePricingError, resolveFeaturePricing \} from "@\/lib\/feature-pricing"/);
|
||||
assert.match(reportsRoute, /error instanceof FeaturePricingError[\s\S]*pricingConfigurationErrorCode = error\.code[\s\S]*reason=\$\{error\.code\}/);
|
||||
assert.match(reportsRoute, /if \(pricingConfigurationErrorCode\)/);
|
||||
assert.match(reportsRoute, safeResponse);
|
||||
assert.doesNotMatch(reportsRoute, /pricing_configuration_unavailable[\s\S]{0,300}error\.message/);
|
||||
});
|
||||
@@ -636,10 +636,12 @@ test("occupation collect denial declines the focus and advances coverage to hora
|
||||
assert.equal(horary.next_followup?.domain, "horary");
|
||||
});
|
||||
|
||||
test("free-text turns persist the next followup so current_question is not null", async () => {
|
||||
test("message and opening turns persist the next followup so current_question is not null", async () => {
|
||||
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||||
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
|
||||
assert.match(afterRun, /if \(action === "message" \|\| action === "opening"\)/);
|
||||
assert.match(afterRun, /persistNextInterviewIfIdle/);
|
||||
assert.match(afterRun, /ensureNonTerminalTurnExit/);
|
||||
assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle"));
|
||||
|
||||
const occupationPlan = planFrom(revision5Dossier(revision5State(), {
|
||||
|
||||
@@ -293,6 +293,14 @@ test("safePublicEvent drops anything outside the allowlist", () => {
|
||||
safePublicEvent({ type: "error", code: "skill_identity_unverifiable", message: "请先采用当前 Skill" }),
|
||||
{ type: "error", code: "skill_identity_unverifiable", message: "请先采用当前 Skill" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
safePublicEvent({ type: "error", code: "billing_unavailable", message: "当前服务的计费配置不可用,请联系支持人员。" }),
|
||||
{
|
||||
type: "error",
|
||||
code: "billing_unavailable",
|
||||
message: "当前服务的计费配置不可用,请联系支持人员。",
|
||||
},
|
||||
);
|
||||
assert.equal(safePublicEvent({ type: "error", code: "private_error", message: "secret" }), null);
|
||||
});
|
||||
|
||||
|
||||
+10
@@ -1060,3 +1060,13 @@
|
||||
- holdout 遍历断言补上 `choice_frame` 非空。
|
||||
- 引擎 `varga.d9` / `varga.d10` 无 `style_options` 时,断言打分 `choice_kind` 等于渲染 effective kind。断言先红(打分 `varga_style`、渲染 `existence`),已让 `conflictProbesFromContrast` 改用 `effectiveContrastChoiceKind`。
|
||||
- `varga_style` 的 B 选项 `weak_yes` 计入 `strong_conflict_count`,连答 3 次可淘汰对面分组;存在题 `weak_yes` 仍不计入强冲突(BUG-419)。
|
||||
|
||||
## 2026-08-31 - TASK-rectification-billing opening 问题槽决策
|
||||
|
||||
- 选择方案 1:成功的 `opening` 与 `message` 一样,在 Agent turn 完成后执行服务端确定性的 `persistNextInterviewIfIdle`。这复用现有问题槽持久化边界,不修改 Skill 版本或 sha256 绑定,也不依赖模型主动调用工具。
|
||||
- `ensureNonTerminalTurnExit` 同样覆盖成功的 `opening`,但不覆盖 `read_only`:opening 是新 Case 的非终态交互入口,必须满足 BUG-452 已建立的“存在 `current_question` 或真实可采用承载”不变量;该函数已自行跳过已有问题、已确认/已采用、用户停止和可采用区间,因此用于 opening 不会覆盖已有状态,只在前一步仍未形成承载时执行确定性兜底。`read_only` 不应产生新问题或改变会话状态。
|
||||
- 任务 C 本轮只给交付建议,不实现 readiness 脚本、应用启动检查、seed migration 或定价数据写入。
|
||||
- 建议在数据库 migration 成功后运行独立的环境 readiness 脚本,不放在应用启动期,避免每个实例重复探测或因业务配置缺失阻止无关只读能力启动。
|
||||
- readiness 应枚举当前环境所有已发布模型的 `model_tier`,逐一检查 `rectification`、`chat.standard`、`report.full` 三个 feature 是否存在 `status='published' AND enabled=true` 的定价;任何组合缺失即以脱敏、可行动的运维错误 fail closed。
|
||||
- readiness 只负责验证,不写入或 seed 价格;部署后仍必须通过 admin flow 为实际 tier 发布定价,随后再重跑 readiness。
|
||||
- 本地验证:TypeScript、改动文件 ESLint、rectification `705/705`、consult/report 聚焦 `262/262` 通过;全量 `2368/2369`,唯一失败是默认 `python3` 缺少 PyYAML,改用已安装 PyYAML 的 `/opt/homebrew/bin/python3` 后对应文件 `39/39` 通过。
|
||||
|
||||
Reference in New Issue
Block a user