Files
Jyotisha/docs/tasks/TASK-cost-accounting-gaps-20260927.md
T

189 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务书 · 成本记账补漏与缓存分价(2026-09-27)
基线:`origin/staging` @ `0c95a7ec`("docs(bugs): BUG-1061 deployed to staging d9c9e714")。
**行号只是线索,一律按符号名定位。** 本单是对 `TASK-billing-pricing-20260830.md`、`TASK-round2-cost-and-delivery-20260831.md` 的续单:前者把账本、功能定价、聚合端点、定价测算页都做完了;后者的任务 0(采真实单位成本)**从未执行**,没有 PROGRESS 文件。本单不重做已完成部分,只补让「成本算出来是假的」的那几处漏洞。
## 为什么要做
产品负责人 2026-09-27 提问「项目怎么做成本计算 / 后台有没有 token 统计 / 缓存开了没有」。按代码逐条核对后的结论:
1. **骨架完整**:`usage_reservations → usage_events → usage_ledger` 三表;`authorize_usage / complete_usage / release_usage` 三个 SQL 函数;`feature_pricing` 按功能定价;后台 `/admin/usage`(每行有 Token、缓存读 tokens、成本列)、`/admin/usage/aggregate`(按功能 30 天 runs / avg / p50 / p95 / max)、`/admin/pricing-simulator`(售价 vs 成本 vs 毛利率 + 缓存命中表)、`/admin/models`(输入 / 输出单价)。
2. **算出来是 0**:`model_config_versions.input_cost_microusd_per_million / output_cost_microusd_per_million` 默认 0,staging 从未填过;账本里没有一条非零真实成本行。**而且后台没有地方填**:`components/admin/model-management.tsx` 的模型草稿弹窗只有「供应商 / 模型 / 单次点数 / 启用 / 默认模型」五个字段,`saveDraft` 请求体里的 `inputCostMicrousdPerMillion / outputCostMicrousdPerMillion / modelTier / contextWindow` 全部原样回填旧值(新建时为 0 / standard / null)。列表页「成本/百万 Token」一列永远显示 `$0.0000 / $0.0000`。API(`api/admin/models/route.ts` 的 zod schema)是收这几个字段的,缺的只是表单。见 E0。
3. **即使填了单价,账本也系统性低估**,原因就是下面「事故实证」的五条。这才是本单要修的。
## 事故实证(按符号定位,已逐条读代码确认)
### E0 · 模型单价在后台无处可填
`model-management.tsx` → 模型弹窗(`title` 为「新增模型草稿」/「编辑 <modelId>」)内 `Form.Item` 的 `name` 只有 `providerId / providerModel / creditCost / enabled / isDefault`。`modelTier`(影响 `feature_pricing` 匹配哪一档)、`contextWindow`、两个单价都不可编辑。产品负责人 2026-09-27 在 staging 后台实际打开过测算页,「单位成本」列全部是「无实测数据 / 模型估算 0」,根源之一就是这里。
### E1 · 七处模型调用不进账本
| # | 调用点 | 符号 | 现状 |
| --- | --- | --- | --- |
| 1 | 会话标题 | `frontend/src/lib/session-title-agent.ts` → `generateSessionTitleText` | `agent.generate` 结果只取文本,usage 丢弃 |
| 2 | 上下文摘要 | `frontend/src/lib/session-context-summary.ts` → `generateSessionContextSummaryText` | 同上,默认模型,输入是整段历史,**不便宜** |
| 3 | 校正轮意图分类 | `frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts` → `classifyTurnIntentWithRetry` | 只 `console.info` 诊断,每一轮都调 |
| 4 | 采用旁白 | `frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts` → `generateAdoptNarrationText` | usage 丢弃 |
| 5 | 校正非 `message` 动作(opening 等) | `frontend/src/lib/rectification-agentic/v9/agent-route-billing.ts` → `createRectificationRunBilling` 的 `reserve / complete` 开头 `if (action !== "message") return` | 免费是产品决定,**但 agent run 的 token 一并被丢**:开场轮要加载整段 Skill,是校正里最贵的单轮之一 |
| 6 | 生时引导 | `frontend/src/lib/birth-time-guide-service.ts` → `generator.generate(prompt)` 那行(`createBirthTimeGuideService` 上方) | 不计费、不记账 |
| 7 | 每日星语 | `frontend/src/app/api/daily-starlanguage/route.ts` → `getDailyStarlanguageAgent(model).generate` | 只限流,不记账 |
对照:聊天主链已经把小聊分类器的 usage 合进去了(`consult/route.ts` → `usagePayload` 里的 `classificationUsage`),说明「附属调用并入主账」这个模式仓库里已有,不是新发明。
### E2 · 缓存命中的输入按全价算
四个成本计算点是同一段公式复制四份,都只有 `inputTokens × 输入单价 + outputTokens × 输出单价`:
- `frontend/src/app/api/consult/route.ts` → `usagePayload`
- `frontend/src/app/api/reports/route.ts` → 报告同步路径的 `costMicrousd` 计算
- `frontend/src/lib/personal-report-worker.ts` → worker 路径的 `costMicrousd` 计算
- `frontend/src/lib/rectification-agentic/v9/agent-route-billing.ts` → `complete(usage)`
`metadata.cache.readTokens` 已经被解析并写入账本(`promptCacheUsage` 覆盖 AI SDK、Mastra、OpenAI 原始三种形状;Mastra 1.50.1 会把 OpenAI 兼容返回的 `prompt_tokens_details.cached_tokens` 映射进 usage),**但它只用于展示命中率,不参与算钱**。`model_config_versions` 表也没有缓存读单价列。
供应商事实(2026-09-27 查官方文档,执行时须再核一次):DeepSeek 上下文缓存**默认对所有用户开启、不需要任何请求参数**,命中与否只看请求前缀是否与已缓存前缀完全一致;`usage` 返回 `prompt_cache_hit_tokens / prompt_cache_miss_tokens`,且 `prompt_tokens_details.cached_tokens` 与 hit 数相同;命中价约为未命中价的 1/30(V4-Pro 峰时 0.044 vs 1.32 美元 / 百万 token;非峰时半价)。所以「咱们是否开缓存了」的答案是:**开着,但账本把命中的那部分按 30 倍价格记**。本仓 `cachedSystemMessage / cachedHistoryMessage` 的 Anthropic 标记对 DeepSeek 无作用也无害。
### E3 · 多事件账本的 `metadata.cache` 被覆盖而不是累加
`complete_usage` 的 upsert 分支 `metadata = ledger.metadata || excluded.metadata`(`frontend/supabase/migrations/20260806030000_settle_order_usage_authorization.sql`)。校正一个 case 多轮、报告多章节都是多事件合一行;jsonb `||` 是浅合并,`cache` 键被最后一个事件整个替换,聚合端点与测算页的命中率因此失真。token 与成本列是累加的,只有 `metadata` 不是。
### E4 · 失败 / 释放的运行不留任何成本痕迹
`release_usage(p_user_id, p_request_id, p_reason)` 没有 usage 参数,只退积分、改状态。模型流到一半失败、用户中止、`empty_answer` 整轮失败(BUG-633 / 634 / 1049 那类)都已经花了供应商的钱,账本看不见。以校正为例,`runV9AgentTurn` 失败时走 `billing.release()`,此时 `result.totalUsage` 是有的,只是没人接。
### E5 · 后台聚合只展示成本,不展示 token 分位数
`/api/admin/usage/aggregate` 已返回 `inputTokens / outputTokens` 的 p50 / p95,但 `components/admin/pricing-simulator.tsx` 的「单功能表」只渲染售价、单位成本、毛利率。产品要看「一次校正到底吃多少 token」目前只能翻每行明细。
### 非缺陷、本单不做
- 推理 token:DeepSeek 把推理 token 计入 `completion_tokens` 一起按输出价收费,`outputTokens` 已经包含,**不需要单独定价**;只需把 `completion_tokens_details.reasoning_tokens` 存进 `metadata` 供观察(并入 T4)。
- 非模型算力(VedAstro 外部调用、Swiss Ephemeris 重计算):只有限流没有计量。另立单,本单不碰。
- 峰 / 非峰半价:不按时段分价,单价表填**峰时价**做保守估计。决策记录 D3。
## 根因
计费闭环那一轮的目标是「钱不出错」,账本是围绕**收费动作**建的:有预留才有账,免费的就没账。成本核算需要的是围绕**模型调用**建账。两者的差集就是 E1、E4;E2、E3 是缓存观测在 8 月 31 日那轮只做到「可见」没做到「计价」的遗留(PROGRESS-billing-pricing 的「任务 4 的缺口」已经写明)。
## 决策记录
- **D1** 产品负责人 2026-09-27 授权开本单:补记账、缓存分价、后台可看 token。**不动任何售价、积分价、会员公平使用参数**(沿用 0830 单红线 1)。
- **D2** 免费调用**照旧免费**,只记账不扣积分。E1 七处不得因此开始收费。
- **D3** 单价表按峰时价填;不实现按时段分价。
- **D4** `usage_reservations.feature_key` 与 `usage_ledger.source` 的 check 约束需要**加宽枚举**以容纳辅助调用(新 feature_key)与系统来源(新 source `system`)。这是 schema 扩展,不是放宽计费校验;`authorize_usage / complete_usage / release_usage` 的公平使用、配额、`model_not_included` 判定一条不得放宽(0830 单红线 4 继续有效)。辅助调用**不得**通过 `authorize_usage` 走一遍预留再释放来「借道」记账,那会污染公平使用计数。
- **D5** 供应商契约(缓存字段名、命中价)以执行当日官方文档为准,任务书里的数字只是线索。
- **D6** 8 月 31 日第二轮任务 0(真实成本采集)的**执行主体是产品负责人**,在本单部署后做;执行方只负责把清单写进 `docs/testing/`,并把 `TASK-round2-cost-and-delivery-20260831.md` 状态板行改为「任务 0 并入本单,其余未执行」。
## 硬红线
1. **钱不出错**:每条现有计费路径的 `reserve → complete / release` 三态不得改语义;本单只加「记」,不加「扣」。任何改动后 `application-billing-contract`、`consultation-billing`、`consult-reports-billing`、`high-risk-billing-routes-contract`、`feature-pricing-contract` 五个测试文件必须全绿且断言不改。
2. **成本公式只保留一份**:新建 `frontend/src/lib/usage-cost.ts`(名字可调),导出一个纯函数 `computeUsageCostMicrousd(model, usage)`;四个调用点全部改成调它,`git grep 'inputCostMicrousdPerMillion ?? 0'` 在 `src/` 下只剩这一处。
3. **辅助调用记账失败不得影响主流程**:写账的 promise 用 `void`/`catch` 隔离,且失败要 `console.warn` 带 feature_key,不带用户内容。
4. **动表必须真跑 `npm run test:db`**(需要 Docker)。没有 Docker 就把 migration 与 SQL 测试写好、`BLOCKED.md` 记环境缺口,**不得推 staging**。
5. 不得修改既有测试断言;确需改的,断言上方写「原值 / 新值 / 原因」三栏并在 PROGRESS 单列。测试总数不低于开工时 `origin/staging` 实测。
6. 账本、日志、测试 fixture 不得出现用户内容、姓名、出生资料、模型原文;`metadata` 只放数字与枚举。
7. `scripts/jyotish_api_server.py` 与 `frontend/src/app/page.tsx` 零增长;不改 `.gitea/workflows/**`;不动 DNS;不自行提升 `main`。
8. 不得顺手升级 `@mastra/core` 或任何依赖。
让步顺序:**钱不出错 > 数据不损坏 > 功能与测试不回归 > 成本数字诚实(宁缺勿假)> 后台好看 > 代码整洁**。
## 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/cost-accounting-gaps-20260927 \
.worktrees/cost-accounting-gaps-20260927 origin/staging
```
读 `AGENTS.md` §1–§7、`frontend/AGENTS.md`、`docs/tasks/PROGRESS-billing-pricing-20260830.md` 全文(尤其「外部核对」一节)、`docs/tasks/TASK-billing-pricing-20260830.md` 的硬红线。在 `docs/BUG_HISTORY.md` 检索 `usage_ledger`、`cost_microusd`、`complete_usage`、`release_usage`、`cache`、`计费`。跑 `python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`(本单动了 SQL 与运行链,按 §9 要求跑)。
开工时记录基线:`tsc`、`lint` warning 数、`npm test` 总数与无 Docker 失败清单(存文件,收工逐条 diff)。
## 任务分解
### T0(P0,门控)· 核实供应商契约与运行事实
1. 在 staging 后台 `/admin/models` 或只读查询确认当前发布模型的 `provider_type`、`provider_model`、`model_tier`,写进 PROGRESS(不写 key、不写 base_url 的凭据部分)。
2. 按当日 DeepSeek 官方文档(`api-docs.deepseek.com` 的 kv_cache 与 pricing 页)核对:缓存是否默认开启、`usage` 字段名、命中 / 未命中 / 输出三档价格。若 provider 不是 DeepSeek,按对应供应商文档核。
3. 写一条单测:用 DeepSeek 形状的原始 `usage`(含 `prompt_tokens_details.cached_tokens` 与 `completion_tokens_details.reasoning_tokens`)喂 `promptCacheUsage`,断言 `readTokens` 正确。若 Mastra 已把它映射成 `cachedInputTokens`,两种形状都要覆盖。
**验收**:PROGRESS 有「供应商 / 模型 / 缓存契约 / 三档价格 / 文档 URL / 核对日期」一张表;新单测绿。
### T1(P0)· 缓存分价 + 成本公式收敛
0. **先把表单补上**(E0,独立可交付,建议第一个 commit):模型草稿弹窗增加「模型档位」(下拉,沿用 `MODEL_TIER_OPTIONS` 标签)、「上下文窗口」、「输入价 $/百万 token」、「输出价 $/百万 token」,以及本任务新增的「缓存读价」「缓存写价」。表单以**美元 / 百万 token** 输入(小数,精度 4 位),提交时乘 1,000,000 取整成微美元;编辑既有版本时回显换算后的值。保存后仍走既有「草稿 → 连接测试 → 发布」流程,不得绕过。列表页「成本/百万 Token」一列加上缓存读价。组件测试断言这六个字段存在且换算正确。
1. migration:`model_config_versions` 新增 `cache_read_cost_microusd_per_million bigint not null default 0 check (>= 0)` 与 `cache_write_cost_microusd_per_million`(同型;DeepSeek 为 0,Anthropic 有写入溢价,留位)。`admin_save_model_config_draft`(或现名)与 `model_catalog.ts` 的读取、`/admin/models` 表单、返回 JSON 一并加字段;旧行默认 0,行为不变。
2. 新建 `computeUsageCostMicrousd(model, usage)`:
- `billableInput = inputTokens - cache.readTokens`(下限 0)按输入价;
- `cache.readTokens` 按缓存读价;`cache.writeTokens` 按缓存写价(为 0 则等于不计);
- `outputTokens` 按输出价;四舍五入到整数微美元。
- 缓存读价为 0 且 `readTokens > 0` 时**不得**静默按 0 算:返回值附带 `pricingComplete: false`,调用方写进 `metadata.pricing = "cache_price_missing"`,聚合端点把这类行单独计数。这是「宁缺勿假」。
3. 四个调用点改为调用该函数;删掉四份复制的公式。
4. 单测:纯函数覆盖「无缓存 / 有命中 / 缓存价缺失 / 全命中」四例;四个调用点各一条合同测试证明走了新函数(现有 `consult-reports-billing.test.ts` 等按需扩展,不改旧断言)。
**验收**:`git grep -n 'inputCostMicrousdPerMillion ?? 0' frontend/src` 只剩 `usage-cost.ts`;`npm run test:db` 含新列的读写用例;`tsc` 0 错。
### T2(P0)· 辅助调用进账本(不扣费)
1. 加宽枚举(D4):`usage_reservations.feature_key`、`usage_ledger.feature_key`、`feature_pricing.feature_key` **不动**(定价表不需要新键);只在 `usage_reservations` / `usage_ledger` / 聚合端点的 `FEATURE_KEYS` 加:`chat.title`、`chat.summary`、`rectification.opening`、`rectification.intent`、`rectification.narration`、`birth_time_guide`、`daily_starlanguage`。`source` 加 `system`。
2. 新 SQL 函数 `record_system_usage(p_user_id, p_request_id, p_feature_key, p_actual_usage jsonb)`:自建一条 `status='completed'、source='system'、credit_amount=0` 的预留并直接调 `complete_usage` 落账;幂等键仍是 `(user_id, request_id)`;**不触碰** `profiles.credits`、不写 `credit_transactions`、不参与公平使用计数(`authorize_usage` 的窗口查询若按 `feature_key in (...)` 或 `source` 过滤,要确认 `system` 行不会被算进去,并写测试钉死)。
3. TS 封装 `recordSystemUsage(accounting, ...)`,七处调用点接入;request_id 规则写进代码注释与 PROGRESS:
- 标题 / 摘要:`<consultRequestId>:title`、`<consultRequestId>:summary`
- 校正:`<turnRequestId>:opening`、`:intent`、`:narration`(同一轮可能有多次意图分类,用 `:intent:<n>`)
- 生时引导 / 每日星语:各自路由的 requestId
4. E1 #5(校正非 `message` 动作):`createRectificationRunBilling.complete` 在 `action !== "message"` 分支改为调 `recordSystemUsage(... "rectification.opening" ...)`,不再直接 `return true`;`reserve / release` 分支不变。
**验收**:七处各有一条测试证明 usage 被转发(mock accounting 断言 feature_key 与 token 数);`database-billing-*.test.ts` 新增 `record_system_usage` 幂等与「不动积分、不进公平使用窗口」两条 SQL 用例;聚合端点 JSON 出现七个新键且 `hasData` 语义不变。
### T3(P1)· 失败 / 释放的运行记成本
1. `release_usage` 增加可选参数 `p_actual_usage jsonb default null`;非空时向 `usage_events` 写一行(`metadata.outcome = "released"`,其余字段同 `complete_usage`),**不写 `usage_ledger`**(保持「ledger = 已完成交付」的语义),退款逻辑逐字不变。
2. `releaseUsage()` TS 封装加可选 usage;三条计费路径(聊天 `settleRun` 失败分支、报告 `completeReportFailed`、校正 `billing.release()`)在拿得到 `totalUsage` 的地方传进去,拿不到就照旧传空。
3. 聚合端点每个 feature 增加 `released: { runs, costMicrousd: { avg, p50, p95, max } }`,从 `usage_events where metadata->>'outcome' = 'released'` 聚合;测算页「单功能表」加一列「失败成本占比」= released 成本 / (completed + released 成本)。
**验收**:SQL 用例:释放带 usage 后 `usage_events` 有行、`usage_ledger` 无行、积分退回金额与之前测试一致;`admin-usage-aggregate-contract.test.ts` 扩展 released 结构。
### T4(P1)· `metadata.cache` 累加 + 推理 token 可见
1. `complete_usage` 的 upsert 分支:`cache.readTokens / writeTokens / noCacheTokens` 三个数按事件**累加**,`hit` = 累加后 `readTokens > 0`;`metadata` 其他键维持浅合并。写在 SQL 里,不靠调用方先读后写。
2. `ActualUsage.metadata` 加可选 `reasoningTokens`;`promptCacheUsage` 旁新增 `reasoningTokenUsage(raw)` 解析 `completion_tokens_details.reasoning_tokens`(及 AI SDK 的 `outputTokenDetails.reasoningTokens`),四个调用点带上。不参与算钱(见「非缺陷」)。
**验收**:SQL 用例:同一预留两次 `complete_usage` 各带 cache 后 ledger 的 `readTokens` 等于两次之和;`agent-generation-settings.test.ts` 加推理 token 解析用例。
### T5(P1)· 后台把 token 摆出来
1. `pricing-simulator.tsx` 「单功能表」增加列:输入 p50 / 输出 p50 / 缓存读 p50 / 命中率(复用聚合端点已有字段,命中率来自 `metadata.cache`);`source === "unavailable"` 时显示「无实测数据」不显示 0。
2. 表格上方一行说明文字:「成本 = (输入 − 缓存读) × 输入价 + 缓存读 × 缓存价 + 输出 × 输出价;单价在 `/admin/models` 配置;`cache_price_missing` 行表示有命中但未配缓存价」。
3. 写 `docs/testing/cost-accounting-20260927.md`:产品负责人部署后照做的清单——在 `/admin/models` 给当前发布模型新建一版草稿、填三档单价、连接测试、发布(T1.0 之前做不到这一步),在测算页填汇率 → 用可控账号每类功能跑 ≥5 次(聊天含多领域、校正跑到出卡、报告完整生成、另各触发一次标题 / 摘要 / 开场 / 每日星语)→ 在 `/admin/pricing-simulator` 读表 → 把「功能 → p50 成本 → p95 成本 → 命中率 → 失败成本占比」抄回 PROGRESS。
**验收**:`pricing-simulation.test.ts` / 组件测试覆盖新列的空态与有数态;`docs/testing/` 清单存在且每步可照做;`DESIGN.md` 不需要改(后台 antd 页不在 C 端设计合同内),若改了 C 端任何文件必须说明。
### T6(P2)· 状态板与文档收口
- `docs/tasks/README.md`:本单加行;`TASK-round2-cost-and-delivery-20260831.md` 行改为「任务 0 并入 TASK-cost-accounting-gaps-20260927;任务 1–3 未执行」。
- `CHANGELOG.md`:一条「后台成本统计:缓存分价、辅助调用与失败运行入账、token 分位数可见」;用户可感知行为无变化要写明。
- `docs/BUG_HISTORY.md`:见下节编号。
## BUG 编号起点
开工时核对 `docs/BUG_HISTORY.md` 当前最大号(2026-09-27 为 **BUG-1063**),从 **BUG-1064** 起连续编号。建议拆法(执行方可合并,但不得漏记):
| 建议编号 | 现象 | 对应 |
| --- | --- | --- |
| BUG-1064 | 缓存命中输入按全价计入 `cost_microusd`,账本高估 / 低估方向取决于单价配置 | E2 / T1 |
| BUG-1065 | 七处模型调用不进账本,成本系统性低估 | E1 / T2 |
| BUG-1066 | 校正开场轮 agent run 的 token 被 `action !== "message"` 分支丢弃 | E1 #5 / T2.4 |
| BUG-1067 | 失败 / 释放运行无成本记录 | E4 / T3 |
| BUG-1068 | 多事件账本 `metadata.cache` 被覆盖,命中率失真 | E3 / T4 |
| BUG-1069 | 后台模型草稿表单缺输入 / 输出单价、模型档位、上下文窗口字段,单价只能是 0 | E0 / T1.0 |
每条按 §5 格式写:状态、现象、触发条件、根因、修复、验证、防复发、关联记录(关联 `PROGRESS-billing-pricing-20260830.md` 任务 4 缺口)。**部署前状态写 `resolved(待部署)`**,部署核对后由验收方回填 SHA。
## 验收口径(验收方执行)
- 前端:`./node_modules/.bin/tsc --noEmit` 0 错;`npm run lint` 0 error 且 warning ≤ 开工基线;`npm test` 失败清单与基线逐条一致、总数不降;`next build` 后 `/` 仍 `○ Static`,首屏 gzip ±2%(本单不应触碰 C 端首屏,超出即异常)。
- 数据库:`npm run test:db` 全绿(Docker);无 Docker 视为**未通过**而非缺口,因为本单动表。
- 部署:`https://staging.jyotisha.chat/api/health` 的 `deployment.gitCommit` 等于合入 SHA;随后由产品负责人按 `docs/testing/cost-accounting-20260927.md` 采数,采到的表回填 PROGRESS。在采到非零真实成本之前,**本单不得被写成「成本已可信」**,只能写「记账链路已补全」。