From 68670b28195b8758947cfff5d488caa0131d5a50 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 29 Oct 2026 11:54:49 +0800 Subject: [PATCH] Consult classification runs beside reserve and append (issue #162, BUG-1499) Classification only needs the question, history, name and session model, so it now starts in beforeReserve once moderation passes and is awaited after the question is written. Billing is unchanged: smalltalk is still reserved and refunded by complete_consultation_free, and an unused early call (reserve refused) is capped at the classifier's 3 s fail-open. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ docs/BUG_HISTORY.md | 16 ++++ docs/tasks/README.md | 1 + .../consult-classify-parallel-20261029.md | 7 ++ frontend/src/app/api/consult/route.ts | 92 +++++++++++-------- .../src/lib/consultation-route-service.ts | 3 + ...t-first-frame-and-pacing-20260928.test.tsx | 6 +- .../consult-packet-warm-20261009.test.ts | 5 +- .../tests/consult-prep-gate-20261013.test.ts | 5 +- frontend/tests/consultation-smalltalk.test.ts | 15 ++- .../consultation-workflow-contract.test.ts | 5 +- 11 files changed, 118 insertions(+), 43 deletions(-) create mode 100644 docs/testing/consult-classify-parallel-20261029.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c70d05d7..6f0c6e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-29 — 咨询首字前少等最多 3 秒 + +- 判断「是不是寒暄」改为和扣点、记下问题同时进行,不再排在它们后面;真问题首字前最多快约 3 秒。 +- 计费不变:寒暄照旧先扣后退、免费;扣点失败时照旧提示,不会多扣。 +- Skill 版本不变(BUG-1499,issue #162)。 + ## 2026-10-29 — 咨询回答快约三分之一 - 咨询里写回答那一步,模型的思考量默认改为「低」:对比测试里思考量少约一半,一轮回答从约 34 秒降到约 22 秒,回答质量逐份并排读过没有变差(issue #134,产品选 A)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5cd5b694..f288a168 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20477,3 +20477,19 @@ - 关联:BUG-1481(第一次记下这个失败)、BUG-1479(#130,负缓存与每测试目录)。 - 复发自:BUG-1481 记下的同一项失败。当时的处理(每测试单独目录)没拦住,因为根因是后台任务跨测试写入,不在目录共用上。 - 修复版本:分支 `codex/bug-1498-executor-isolation`。 + + +## BUG-1499 | 咨询首字前要白等分类:判断寒暄要等扣点、写入问题都做完才开始 + +- 状态:resolved(代码 + 回归测试;待部署) +- 首次发现 / 最近更新:2026-10-28 / 2026-10-29(issue #118 代码体检第 7 条 → #135 → #162) +- 影响面:`frontend/src/app/api/consult/route.ts::runTurn`、`frontend/src/lib/consultation-route-service.ts`(`beforeReserve` 上下文多带 `subject`)。 +- 现象:每个咨询问题首字前,「是不是寒暄」的分类(最多 3 秒)排在扣点、写入问题之后才开始,三步串行。 +- 触发条件:任何未带 entrypoint 的咨询轮次。 +- 根因:分类调用写在 `appendQuestion()` 之后;它只需要用户原话、历史、称呼和会话模型,不依赖扣点或写入的结果。 +- 修复:审核放行后,在 `beforeReserve` 里开始分类,与扣点、写入同时进行;写入完成后再等分类结果,按原来的寒暄 / 正常两条路走。用的模型是扣点选中的同一个 `sessionModel` 对象。计费顺序、寒暄先扣后退(`complete_consultation_free`)与金额都不变;扣点失败时分类结果丢弃,最多白调一次分类模型(有 3 秒上限,失败默认按正常问题)。新增计时 `prep.classify_wait`(写入后还要等分类多久)。 +- 验证:前端相关 71 条测试过;全量 5339 条里 25 条失败,与改动前同一批(Docker / 数据库 / rsync 环境),无新增;tsc 0 错、lint 0 错。 +- 防复发:`consultation-smalltalk.test.ts` 锁住分类在审核之后、扣点之前开始,且用扣点选中的同一个模型对象;寒暄分支仍走 `completeSmalltalk`。 +- 关联:BUG-976(寒暄先扣后退)、BUG-1074(开流前的串行链)、#134(同批提速)。 +- 复发自:无 +- 修复版本:分支 `codex/consult-classify-parallel-20261029`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 6cdbceaa..ea595fab 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -547,3 +547,4 @@ | `TASK-upstream-sync9-20261028.md` | `PROGRESS-upstream-sync9-20261028.md` | **第 1 次打回(快速门 1 败:Darakaraka 旧测试锁错值,见 `TASK-upstream-sync9-fix-20261028.md`)** · **#141 上游同步第九轮**:甲批九项已做完并推 `codex/upstream-sync9-20261028`;校正 20 条和 900 条与上一份回放逐条相同;乙批仍等 #137 | 待验收 | `codex/upstream-sync9-20261028` | | `TASK-upstream-sync9b-20261028.md` | `PROGRESS-upstream-sync9b-20261028.md` | **第 1 次打回(冻结契约排除清单删了第八轮两条,见 `TASK-upstream-sync9b-fix-20261028.md`)** · **#159 上游同步第九轮乙批(产品选 A)**:Sarpa Drekkana 补成五段、第 22 号 Drekkana 改为 D3 第 8 宫;改冻结文件 `varga.py`,校正 20 + 900 与上一份逐条相同后按 ERR-110 重新冻结。Narayana 小运强度口径(B2)不做,另问产品 | 待验收 | `codex/upstream-sync9b-20261028` | | — | — | **#134 咨询答题默认低思考量(产品选 A)**:`CONSULT_ANSWER_REASONING_EFFORT` 不设时默认 `low`,设 `off` 恢复原请求体(Claude 直接执行) | 已推 staging | `codex/answer-effort-default-20261029` | +| — | — | **#162 咨询分类与扣点同时进行(产品同意)**:审核放行后分类与扣点、写入并行;计费与寒暄退点不变(BUG-1499,Claude 直接执行) | 已推 staging | `codex/consult-classify-parallel-20261029` | diff --git a/docs/testing/consult-classify-parallel-20261029.md b/docs/testing/consult-classify-parallel-20261029.md new file mode 100644 index 00000000..6d7aa4ea --- /dev/null +++ b/docs/testing/consult-classify-parallel-20261029.md @@ -0,0 +1,7 @@ +# 真机走查 · 咨询分类与扣点同时进行(issue #162) + +| # | 操作 | 应该看到 | +| --- | --- | --- | +| 1 | 用一张星盘问一个具体问题 | 正常回答;首字出来比以前快一点 | +| 2 | 只发「你好」或「谢谢」 | 回一句寒暄;点数余额不变(先扣后退) | +| 3 | 点数不足时发一个问题 | 照旧提示「咨询点数不足」,不会多扣 | diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index b51327c7..60b9d985 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -537,6 +537,49 @@ export async function POST(request: Request) { }, }); let prepared: PreparedConsultationRoute; + // Issue #162: classification starts once moderation passes and runs beside + // reserve + append instead of after them. Billing order is unchanged: a + // smalltalk turn is still reserved, then refunded by completeSmalltalk. + // The classifier fails open and is capped at 3 s, so an unused early call + // (reserve refused, append failed) costs at most that one call. + const earlyClassification: { turn: ReturnType | null } = { turn: null }; + let classifyStarted = 0; + function startClassification(name: string) { + classifyStarted = Date.now(); + return classifyConsultationTurn({ + model: sessionModel, + question: visibleQuestion, + history: chatSession.messages, + name, + signal: request.signal, + onObservation(observation) { + if (!observation.late) { + classificationUsage = observation.usage ?? {}; + classificationOutcome = observation.outcome; + classificationDurationMs = observation.durationMs; + } + const inputTokens = observation.usage?.inputTokens ?? 0; + const outputTokens = observation.usage?.outputTokens ?? 0; + logAgentObservability({ + requestId, sessionId, + agentVersion: "consultation-smalltalk-v1", + modelVersion: String(sessionModel.configVersion), + policyVersion: "consultation-smalltalk-v1", + toolCalls: [], + classification: { durationMs: observation.durationMs }, + contractPhases: [{ + phase: observation.late ? "classification.late_usage" : `classification.${observation.outcome}`, + durationMs: observation.durationMs, + status: observation.outcome === "smalltalk" || observation.outcome === "consult" ? "completed" : "failed", + }], + ...(observation.usage ? { inputTokens, outputTokens, + costMicrousd: Math.round((inputTokens * (sessionModel.inputCostMicrousdPerMillion ?? 0) + + outputTokens * (sessionModel.outputCostMicrousdPerMillion ?? 0)) / 1_000_000) } : {}), + billingSettlementResult: "not_applicable", + }); + }, + }); + } const prepareStarted = Date.now(); try { prepared = await prepareConsultationRoute({ @@ -570,7 +613,7 @@ export async function POST(request: Request) { return data; }, loadCandidateRange: (profileUserId) => loadReportCandidateRange(accounting, { userId: profileUserId }), - beforeReserve: async ({ consultationMode, serverChart }) => { + beforeReserve: async ({ consultationMode, serverChart, declaredWindow, subject }) => { if (serverChart && consultationMode !== "declared_birth_window") prewarmConsultPacketForTurn(serverChart.toolInput); prepPhases.push({ phase: "prep.subject", @@ -579,6 +622,9 @@ export async function POST(request: Request) { }); const inputModeration = await settleInputModeration(); if (inputModeration?.verdict === "block") throw new ConsultationInputBlockedError(inputModeration); + if (parsed.data.entrypoint === undefined) { + earlyClassification.turn = startClassification(subject?.name || serverChart?.name || declaredWindow?.name || ""); + } return createConsultationPlan({ userIntent: parsed.data.question, theme: consultationTheme, @@ -718,42 +764,14 @@ export async function POST(request: Request) { .eq("id", sessionId) .eq("user_id", userId) .maybeSingle(); - const classifyStarted = Date.now(); - const turn = parsed.data.entrypoint === undefined - ? await classifyConsultationTurn({ - model: selectedModel, - question: visibleQuestion, - history: chatSession.messages, - name: subjectName, - signal: request.signal, - onObservation(observation) { - if (!observation.late) { - classificationUsage = observation.usage ?? {}; - classificationOutcome = observation.outcome; - classificationDurationMs = observation.durationMs; - } - const inputTokens = observation.usage?.inputTokens ?? 0; - const outputTokens = observation.usage?.outputTokens ?? 0; - logAgentObservability({ - requestId, sessionId, - agentVersion: "consultation-smalltalk-v1", - modelVersion: String(selectedModel.configVersion), - policyVersion: "consultation-smalltalk-v1", - toolCalls: [], - classification: { durationMs: observation.durationMs }, - contractPhases: [{ - phase: observation.late ? "classification.late_usage" : `classification.${observation.outcome}`, - durationMs: observation.durationMs, - status: observation.outcome === "smalltalk" || observation.outcome === "consult" ? "completed" : "failed", - }], - ...(observation.usage ? { inputTokens, outputTokens, - costMicrousd: Math.round((inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0) - + outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)) / 1_000_000) } : {}), - billingSettlementResult: "not_applicable", - }); - }, - }) - : { kind: "consult" as const }; + // Started in beforeReserve, beside reserve + append (issue #162). + const classifyWaitStarted = Date.now(); + const turn = earlyClassification.turn ? await earlyClassification.turn : { kind: "consult" as const }; + prepPhases.push({ + phase: "prep.classify_wait", + durationMs: Math.max(0, Date.now() - classifyWaitStarted), + status: "completed", + }); prepPhases.push({ phase: "prep.classify", durationMs: classificationDurationMs ?? Math.max(0, Date.now() - classifyStarted), diff --git a/frontend/src/lib/consultation-route-service.ts b/frontend/src/lib/consultation-route-service.ts index 712c38c1..3cddbb67 100644 --- a/frontend/src/lib/consultation-route-service.ts +++ b/frontend/src/lib/consultation-route-service.ts @@ -138,6 +138,8 @@ type ConsultationPreReserveContext = Readonly<{ serverChart: ServerChartConsultation | null; declaredWindow: DeclaredBirthWindowConsultation | null; generalDailyReference: GeneralDailyReference | null; + /** The bound subject, so pre-reserve work can address it by name (issue #162). */ + subject: PreparedConsultationSubject | null; }>; type PrepareConsultationRouteInput = Readonly<{ @@ -615,6 +617,7 @@ export async function prepareConsultationRoute( serverChart, declaredWindow, generalDailyReference, + subject, }) as Awaited : undefined; const reservation = await input.reserve(); diff --git a/frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx b/frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx index 619760f3..c4874d97 100644 --- a/frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx +++ b/frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx @@ -279,7 +279,11 @@ test("the consult route opens the stream before subject reads, reservation, ques "prepareConsultationRoute({", "consultSettlement.reserveCredit", "consultSettlement.appendQuestion", - "await classifyConsultationTurn({", + // 原值: "await classifyConsultationTurn({" + // 新值: 分类调用与等待结果两处都在流内 + // 原因: issue #162 分类提前到 beforeReserve 开始,结果在写入问题之后等待 + "return classifyConsultationTurn({", + "await earlyClassification.turn", "insufficient ? 402 : 503", 'error: "暂时无法生成解读"', ]) { diff --git a/frontend/tests/consult-packet-warm-20261009.test.ts b/frontend/tests/consult-packet-warm-20261009.test.ts index 3fd580c9..f552f944 100644 --- a/frontend/tests/consult-packet-warm-20261009.test.ts +++ b/frontend/tests/consult-packet-warm-20261009.test.ts @@ -77,5 +77,8 @@ test("a consult turn prewarms the card packet with the same identity the card re { apiBase: "http://engine.test", fetch: (async () => { throw new Error("down"); }) as unknown as typeof fetch }, ); const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); - assert.match(route, /beforeReserve: async \(\{ consultationMode, serverChart \}\) => \{\n\s+if \(serverChart && consultationMode !== "declared_birth_window"\) prewarmConsultPacketForTurn\(serverChart\.toolInput\);/); + // 原值: beforeReserve 只解构 consultationMode、serverChart + // 新值: 同时解构 declaredWindow、subject;预热仍是第一句 + // 原因: issue #162 beforeReserve 多取 declaredWindow、subject,用来在审核后提前开始分类 + assert.match(route, /beforeReserve: async \(\{ consultationMode, serverChart, declaredWindow, subject \}\) => \{\n\s+if \(serverChart && consultationMode !== "declared_birth_window"\) prewarmConsultPacketForTurn\(serverChart\.toolInput\);/); }); diff --git a/frontend/tests/consult-prep-gate-20261013.test.ts b/frontend/tests/consult-prep-gate-20261013.test.ts index 60853abf..c32bacfe 100644 --- a/frontend/tests/consult-prep-gate-20261013.test.ts +++ b/frontend/tests/consult-prep-gate-20261013.test.ts @@ -163,7 +163,10 @@ test("the title read starts after the question is saved and is awaited at the ol const append = route.indexOf("consultSettlement.appendQuestion()"); assert.match(settlement, /accounting\.rpc\("append_consultation_question"/); const title = route.indexOf('.select("title")'); - const classify = route.indexOf("await classifyConsultationTurn({"); + // 原值: 分类以 "await classifyConsultationTurn({" 在标题读取之后开始 + // 新值: 分类在 beforeReserve 里提前开始,这里在标题读取之后等它的结果 + // 原因: issue #162 分类与扣点、写入同时进行;标题读取仍在保存成功之后,等待次序不变 + const classify = route.indexOf("await earlyClassification.turn"); const awaited = route.indexOf("await titleRead"); assert.ok(append > 0 && title > append && classify > title && awaited > classify); }); diff --git a/frontend/tests/consultation-smalltalk.test.ts b/frontend/tests/consultation-smalltalk.test.ts index 7dd5e0fb..f7051211 100644 --- a/frontend/tests/consultation-smalltalk.test.ts +++ b/frontend/tests/consultation-smalltalk.test.ts @@ -222,8 +222,19 @@ test("smalltalk views never reconstruct an invented settled execution timeline", }); test("route gates all special entries before a single selected-model classifier and keeps tool contracts", () => { const route = source("../src/app/api/consult/route.ts"); - assert.match(route, /parsed.data.entrypoint === undefined\s*\? await classifyConsultationTurn/); - assert.match(route, /classifyConsultationTurn\(\{\s*model: selectedModel,\s*question: visibleQuestion/); + // 原值: 分类在扣点、写入之后以 `entrypoint === undefined ? await classifyConsultationTurn` 串行开始,用 selectedModel + // 新值: 审核放行后在 beforeReserve 里开始分类,与扣点、写入同时进行;用的 sessionModel 就是扣点选中的同一个对象 + // 原因: issue #162 去掉首字前最多 3 秒的串行等待;计费顺序与寒暄退点不变 + assert.match(route, /if \(parsed\.data\.entrypoint === undefined\) \{\s*earlyClassification\.turn = startClassification\(/); + assert.match(route, /classifyConsultationTurn\(\{\s*model: sessionModel,\s*question: visibleQuestion/); + assert.match(route, /\(modelId\) => sessionModel\?\.id === modelId \? sessionModel : null/); + const beforeReserve = route.slice(route.indexOf("beforeReserve: async"), route.indexOf("reserve: async () =>")); + assert.ok( + beforeReserve.indexOf('if (inputModeration?.verdict === "block") throw') < beforeReserve.indexOf("earlyClassification.turn = startClassification("), + "classification starts only after moderation lets the input through", + ); + assert.ok(route.indexOf("earlyClassification.turn = startClassification(") < route.indexOf("consultSettlement.reserveCredit")); + assert.match(route, /await earlyClassification\.turn/); assert.equal((route.match(/requireTool: true/g) ?? []).length, 2); const smalltalk = route.slice(route.indexOf('if (turn.kind === "smalltalk")'), route.indexOf("const expectedTitle")); const settlement = source("../src/lib/consult/settlement.ts"); diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index 956399ed..7b2590ee 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -167,7 +167,10 @@ test("consultation plans are server-owned and bounded", () => { prepareCall.slice(beforeReserveAt, reserveAt), // 原值:参数只解构 consultationMode;新值:还解构 serverChart,用来在扣点前不等待地预热数据卡 // 原因:issue #129 每天首问不在回答路径上现算整包;createConsultationPlan 仍在这个回调里、仍早于 reserve - /beforeReserve:\s*async\s*\(\{\s*consultationMode,\s*serverChart\s*\}\)[\s\S]*createConsultationPlan\(\{/, + // 原值: beforeReserve 只解构 consultationMode、serverChart + // 新值: 同时解构 declaredWindow、subject;计划仍在 beforeReserve 里生成 + // 原因: issue #162 beforeReserve 多取 declaredWindow、subject,用来在审核后提前开始分类 + /beforeReserve:\s*async\s*\(\{\s*consultationMode,\s*serverChart,\s*declaredWindow,\s*subject\s*\}\)[\s\S]*createConsultationPlan\(\{/, ); assert.match( prepareCall.slice(reserveAt),