Compare commits

..
Author SHA1 Message Date
Jesse_ChenandClaude Opus 5.5 68670b2819 Consult classification runs beside reserve and append (issue #162, BUG-1499)
Independent Staging Quality Gate / validate (push) Successful in 16m28s
Independent Staging Quality Gate / publish (push) Successful in 4m15s
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 <noreply@anthropic.com>
2026-10-29 11:54:49 +08:00
11 changed files with 118 additions and 43 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-29 — 咨询首字前少等最多 3 秒
- 判断「是不是寒暄」改为和扣点、记下问题同时进行,不再排在它们后面;真问题首字前最多快约 3 秒。
- 计费不变:寒暄照旧先扣后退、免费;扣点失败时照旧提示,不会多扣。
- Skill 版本不变(BUG-1499,issue #162)。
## 2026-10-29 — 咨询回答快约三分之一
- 咨询里写回答那一步,模型的思考量默认改为「低」:对比测试里思考量少约一半,一轮回答从约 34 秒降到约 22 秒,回答质量逐份并排读过没有变差(issue #134,产品选 A)。
+16
View File
@@ -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`。
+1
View File
@@ -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` |
@@ -0,0 +1,7 @@
# 真机走查 · 咨询分类与扣点同时进行(issue #162)
| # | 操作 | 应该看到 |
| --- | --- | --- |
| 1 | 用一张星盘问一个具体问题 | 正常回答;首字出来比以前快一点 |
| 2 | 只发「你好」或「谢谢」 | 回一句寒暄;点数余额不变(先扣后退) |
| 3 | 点数不足时发一个问题 | 照旧提示「咨询点数不足」,不会多扣 |
+55 -37
View File
@@ -537,6 +537,49 @@ export async function POST(request: Request) {
},
});
let prepared: PreparedConsultationRoute<ModelSelection, ConsultationPlan>;
// 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<typeof classifyConsultationTurn> | 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<ModelSelection, ConsultationPlan>({
@@ -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),
@@ -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<Reservation> = Readonly<{
@@ -615,6 +617,7 @@ export async function prepareConsultationRoute<Reservation, GuardResult>(
serverChart,
declaredWindow,
generalDailyReference,
subject,
}) as Awaited<GuardResult>
: undefined;
const reservation = await input.reserve();
@@ -279,7 +279,11 @@ test("the consult route opens the stream before subject reads, reservation, ques
"prepareConsultationRoute<ModelSelection, ConsultationPlan>({",
"consultSettlement.reserveCredit",
"consultSettlement.appendQuestion",
"await classifyConsultationTurn({",
// 原值: "await classifyConsultationTurn({"
// 新值: 分类调用与等待结果两处都在流内
// 原因: issue #162 分类提前到 beforeReserve 开始,结果在写入问题之后等待
"return classifyConsultationTurn({",
"await earlyClassification.turn",
"insufficient ? 402 : 503",
'error: "暂时无法生成解读"',
]) {
@@ -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\);/);
});
@@ -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);
});
+13 -2
View File
@@ -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");
@@ -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),