Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 29949e0a29 接受删除旧报告页,并跟上最新 staging
Independent Staging Quality Gate / validate (push) Failing after 8m31s
Independent Staging Quality Gate / publish (push) Skipped
旧的结构化报告页已由另一单删除,标识留在现在用的阅读页。测试不再读取那个文件。更新日志和 Bug 历史两边都保留。

自测:tsc 退出码 0;lint 0 error、123 warning;修复单点名的 3 个测试 7 通过、0 失败、0 取消。

Co-Authored-By: Grok (agent loop)
2026-10-14 12:27:33 +08:00
Jesse_ChenandClaude Opus 5.5 7290477ead docs(tasks): beta main path fix brief (agent loop, issue #73, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 12:15:10 +08:00
Claude 400af57e86 merge origin/staging into frontend dead code (issue #63)
Independent Staging Quality Gate / validate (push) Failing after 7m39s
Independent Staging Quality Gate / publish (push) Skipped
Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 11:20:59 +08:00
Claude 4130d1dba9 merge origin/staging into consult prep parallel (issue #62)
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m43s
Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 11:14:51 +08:00
jesse-uxandGrok (agent loop) 9e29310941 合并 staging,并让发问准备的检查跟上新位置
两条按源码原文查找的检查改认搬走后的写法,意思没有放宽。对照脚本挪到测试目录,镜像构建不再去找测试文件。业务代码和三个开关的默认值没改。

自测:相关前端测试 40 通过,失败 0,取消 0。假时钟仍是并行 160、串行 210。tsc 0 错,lint 0 error。

Co-Authored-By: Grok (agent loop)
2026-10-14 10:56:17 +08:00
jesse-uxandGrok (agent loop) fca62fda43 给别人出报告能读到档案,对话和报告标明 AI 生成
BUG-1063:后台按列只读档案,授权前数据库拒绝读表,授权后通过。对话输入框下和报告标题下加上 AI 生成说明。状态板按已部署提交改措辞,不标 resolved。下载文件元数据没有做。

自测:tsc 退出码 0;lint 0 error;定向测试 51 通过;test:db 82 项里 13 项为 Windows 已知失败,无新增;隐私标记退出码 0。

Co-Authored-By: Grok (agent loop)
2026-10-14 09:30:51 +08:00
jesse-uxandGrok (agent loop) a0e223e20c 发问准备可以并行,三个开关先保持关闭(BUG-1337~1339)
开流前的注销检查可以和读会话、解析模型同时进行;审核改到扣点前等待,不再挡住第一行;模型版本可以短缓存;两个领域可以一起算。本机没有数据库中位数,也没有带星盘网络库的镜像,所以默认仍是关闭、缓存 0、领域逐个算。

自测:tsc 0 错;lint 0 error;相关测试失败 0、取消 0。test:db 未跑。

Co-Authored-By: Grok (agent loop)
2026-10-14 07:23:58 +08:00
38 changed files with 37730 additions and 18198 deletions
+7
View File
@@ -1,5 +1,12 @@
# BLOCKED
## TASK-consult-prep-parallel-20261013:审核真实延迟、Postgres 中位数、多领域镜像(2026-10-14)
- **阿里云审核**:本机没有审核密钥。1500 毫秒挡首帧的结论来自测试里的假等待,不是真实供应商。开关 `CONSULT_PREP_PARALLEL` 保持关闭。
- **本机 Postgres**:没有量 `getUser`、注销检查、读会话、按版本读模型各 30 次的中位数。因此不能判断首帧是否真的快了两成,也不能判断模型准备是否快了一半。两个相关开关保持关闭(并行关、版本缓存 0 毫秒)。
- **多领域并发**:没有拿带星盘网络库的镜像重跑。任务书里的起草试算大约只快一成,不到两成五的开启线。`CONSULT_DOMAIN_CONCURRENCY` 保持 1。
- **staging 前后对比**:要等这支分支部署之后才能看真实首行时间。本轮不改默认值。
## TASK-rectification-explicit-entry-20261012:T0 日志、T5 模型(BUG-1306,2026-10-12)
- **T0**:本 worktree 没有 staging 主机,读不到 web 容器 `docker logs`,也没有数据库。无法核对 10-11 那一轮是否调用了 `offer-birth-time-rectification`。不阻塞实现。产品可照做的查法写在 `docs/tasks/PROGRESS-rectification-explicit-entry-20261012.md`,只看工具名、status,以及该会话 messages 里有没有 `rectificationOffer`。
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-10-14 — 给别人出报告能读到档案;对话和报告标明 AI 生成(待合入 staging)
- 给人物档案里的其他人出报告时,后台只能按出报告要用的那些列读取,不能改档案(BUG-1063)。
- 对话输入框下方,以及报告标题下,标明由 AI 生成、仅供参考。报告结尾原有的免责还在。Skill 版本不变(BUG-1347)。
## 2026-10-14 — 删掉三个没人调用的接口和约 3,000 行旧代码(待合入 staging)
- 删掉三个没有调用方的接口,其中一个不用登录就会触发大约 20 秒的计算。同时清掉大约 3,000 行已经没有页面使用的旧代码。Skill 版本不 bump(BUG-1342)。
+36087 -18020
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
# PROGRESS · 内测主路径:给别人出报告、AI 生成标识、状态板清账(issue #73)
- 分支:`codex/beta-main-path-20261014`
- 实际基线:`origin/staging` `f4c53e7f9fad1a3de08b613c3470f8e4a2f62116`
- 任务书基线写成 `48a2fadd`,计划评论里的 base 是 `13bde11539bc99f76330376145c756da22d149e3`。开工时远端 staging 已是上面的 `f4c53e7f`。方向没有改,不需要拍板。
- 产品 2026-10-14 已同意标识文案,并指定 BUG-1063 用逐列只读授权(任务书 §11)。
- 开工时 `docs/BUG_HISTORY.md` 最大号是 BUG-1346。本单新增 BUG-1347。BUG-1246 在本文件里没有对应标题,没有改。
- 未改 `page.tsx`。没有授写权限,没有改 RLS,没有改 `authenticated` 现有授权。Skill 版本不变。
## 做了什么
T0:给别人出报告的后台在授权之前读不到档案表。同一条数据库用例失败,错误原文是 `permission denied for table chart_profiles`,代码 `42501`。这不是绿的,所以继续做 T1。
T1:新迁移只给 `service_role` 增加档案表上、报告读档所需各列的 `SELECT`。合同测试核对选择列都在授权里,并且新文件里没有写授权、没有策略改动、没有改登录用户授权。BUG-1063 记为 fixed-pending-verify:部署并真机给别人出一份报告之前不标 resolved。
T2:对话输入框下方一行中文说明「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」报告的两种阅读页在标题下加「本报告由 AI 生成,仅供参考。」英文阅读用「This report is AI-generated and for reference only.」英文对话那句写在文案口径里,界面是中文,不另画一行。没有单独的公开分享页,两种阅读页都带上了这行。DESIGN、VOICE 同提交更新。
T2b 让掉:仓库里没有 PDF / DOCX 导出器。Markdown 下载是按字节锁定的,没有改生成正文,也没有往下载文件里写元数据。记在 BUG-1347。
T3–T5:状态行只改措辞和部署证据。下列提交都已在 `origin/staging` 上:`82c8aeb0`(906、907)、`dec36bce`(1080)、`8112f62b`(1128 字体部分)、`e62d0a32`(1244、1245)、`52d14e8f`(1252、1253、1254)、`b8385adb`(1255)、`c76fdda7`(1256)、`61d6f248`(1257)、`6ec8efd5`(1258)、`11333460`(1247–1251)、`0ed45a4b`(1259)。仍是 fixed-pending-verify 或 investigating,没有改成 resolved。1254 写明门禁 run 1747、1750、1753、1756、1759、1762 连续六次通过,再观察前不标 resolved。BUG-283 状态改为 superseded(由 BUG-299 取代),根因原文保留。
T6:`scratchpad/beta-smoke-checklist-20261014.md` 不在这棵树上。冒烟清单已经在 `docs/testing/beta-smoke-20261014.md`,本单只补了第 4、8、9 行(对话标识、他人报告、报告标题中英文标识)。
## 断言三栏
没有改既有断言。新增用例只加期望。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0,约 51 秒。中途一次失败是测试里 `createRef()` 没写元素类型,已改成文本框类型后重跑通过 |
| 规范 | `npm run lint --prefix frontend` | 退出码 0,0 error,125 条原有 warning。本单改的文件没有新增 warning |
| 定向测试 | `tsx --test`:AI 标识、授权合同、输入框合同、报告阅读 | tests 51,pass 51,fail 0,cancelled 0,退出码 0,约 3.3 秒 |
| 数据库 | `npm run test:db --prefix frontend` | 退出码 1。tests 82,pass 69,fail 13,cancelled 0,约 1390 秒。13 条失败的名字与 `tools/agent-loop/windows-known-failures.txt` 逐条相同,没有多出来的。本单那条是 ok 54 |
| 全量前端测试 / 构建 / Python 快速门 | 未跑 | 留给验收。本单不改 `scripts/` |
| 隐私标记 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 退出码 0,约 59 秒 |
## 偏离
- 任务书点名的数据库文件可以新建 `database-report-worker-subject.test.ts`,用的是这个名字,没有改旧的 `database-chart-subject.test.ts`。
- 英文对话说明没有画在界面上,因为对话外壳只有中文。句子留在 VOICE。
- T2b 按让步顺序跳过。
- 冒烟草稿不在树里,改的是已经落盘的清单,没有另起一份。
## 第 1 次打回(2026-10-14)
接受最新 `origin/staging` 的合并。旧报告页 `personal-report-document-view.tsx` 按修复单删除,标识留在现在用的 Markdown 阅读页。AI 标识测试不再读取那个已删文件。更新日志和 Bug 历史两边都留着。Bug 历史在 staging 上本来就有前后两份,合并后仍是两份,本单只多了 BUG-1347 这一条。任务索引里补上修复单,并写上进度文件。
### 断言三栏(本轮)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `frontend/tests/ai-generated-notice-20261014.test.ts` 报告阅读用例 | 渲染已删除的旧结构化报告页,并检查中文标识和 `id="report-disclaimer"` | 删掉对旧页面的读取和这两条断言;Markdown 中英文标题下的断言、输入框断言保留 | 第 1 次打回:旧页面已由另一单删除,现行报告页已有标识 |
### 本轮自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 规范 | `npm run lint --prefix frontend` | 退出码 0,0 error,123 warning(原有警告,本轮没有新增 error) |
| 定向测试 | 在 `frontend` 下 `tsx --test` 跑修复单点名的 3 个文件 | tests 7,pass 7,fail 0,cancelled 0,退出码 0 |
| 全量前端测试 / 构建 / 数据库 | 未跑 | 修复单不要求。数据库上一轮已过,这轮没改迁移 |
@@ -0,0 +1,70 @@
# PROGRESS · 发问准备并行(2026-10-14)
- 分支:`codex/consult-prep-parallel-20261013`
- 基线:`origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f`(任务书写的 `77057a819` 的后代;计划评论里的 `2994588ad` 更早,不因此停工)
- 个人长报告写作开关仍是关闭,本轮没有改报告章节
- 编号:本分支上的 `docs/BUG_HISTORY.md` 最大号是 BUG-1322。BUG-1323~1336 被其它未合并任务书占用。本单用 BUG-1337~1339
## 开关
三个都保持代码里的默认,没有单独提交去打开:
| 开关 | 默认 | 原因 |
| --- | --- | --- |
| `CONSULT_PREP_PARALLEL` | 关 | 没有本机 Postgres 中位数,不满足首帧快两成 |
| `MODEL_VERSION_CACHE_TTL_MS` | 0 | 没有量到「准备模型」快一半 |
| `CONSULT_DOMAIN_CONCURRENCY` | 1 | 没有带星盘网络库的镜像重测;起草试算预计达不到快两成五 |
因此没有写 `CHANGELOG.md`,也没有改 `frontend/DESIGN.md`。
## 做了什么
- T1:开流前的登录、注销检查、请求体、会话、模型收进 `loadConsultTurnHead`。并行时注销检查和后面三条同时开始,失败仍按旧的先后返回。423 仍在任何扣点之前。
- T2:并行打开时,审核在开流前启动、在扣点前等待。拦住时不扣点,并且比资料错误优先。关掉时仍是开流前等待审核。
- T3:按模型和版本缓存成功的解析,最长 15 秒;失败和空结果不缓存;管理端清目录缓存时一并清掉。地址检查函数本身没改。已发布版本的供应商代码、类型和地址不可变,但启用开关和密钥仍可变,所以不能超过目录缓存的 15 秒。
- T4:问题保存成功后立刻读标题,和分类同时进行,用到标题时再等。分类仍在扣点和保存之后。
- T5:领域并发开关。为 1 时仍是原来的逐个循环,含时间预算。为 2 时一起算,结果按计划顺序组装,不再逐个套时间预算,领域个数上限不变。
- T6:准备阶段记一条 `consultation-prep-v1`,只有阶段名、毫秒和状态,没有问题原文。
- T7:见 `docs/testing/consult-prep-parallel-ab-20261013.md`。假时钟并行 160、串行 210。这不是开启依据。
- 测试注入:模型目录增加 `setModelCatalogIoForTests`,只给测试替换查库、解密和地址检查。生产路径仍调用原来的函数。
## 改过的既有断言
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `content-moderation.test.ts` | 源码含 `await moderate(visibleQuestion, "input"` 且早于 `reserve_consultation_usage` | `await settleInputModeration()` 早于扣点;准备服务里 `beforeReserve` 早于 `input.reserve()`;拦住时扣点次数是 0 | 审核挪到流内、扣点前。关掉开关时开流前的等待还在 |
| `model-catalog.test.ts` | `return await readVersion(modelId, configVersion)` | `const model = await readVersion(...)`,只有非空才写入短缓存 | 绑版本的解析可以缓存,失败和空结果不写 |
| `model-catalog.test.ts` | 咨询路由里 `if (!sessionModel)` 后面是 503 | 准备函数里 `if (!sessionModel)` 返回 `modelUnavailable()`,状态 503 | 模型不可用仍是开流前的 503,文案随准备步骤搬走 |
| `consult-first-frame-and-pacing-20260928.test.tsx` | 401、请求体、会话不存在、会话类型、模型已变这些字面量在路由的 `runTurn` 之前 | 字面量在 `turn-prep.ts`;路由在 `runTurn` 之前调用 `loadConsultTurnHead` 并返回失败 | 开流前的判断收进同一个函数,状态码仍在开流前 |
| `rectification-session-composer-guard.test.ts` | 咨询路由里会话缺失返回 404,类型不对返回 `session_not_consultation` | 同样两句在 `turn-prep.ts` | 会话判断搬走,文案和状态码没变 |
`consultation-agentic-runtime.test.ts` 的领域循环帮助函数现在也接受异步的假计算,以便断言「后完成的领域仍排在计划后面」。原有断言没有改松。
## 并发为 2 时的时间预算
领域数仍先被原来的上限截断(现在是 2)。开关为 2 时,这两个领域一起开始,不再逐个问「剩下的时间还够不够下一个」。开关为 1 时,原来的逐个判断还在。
## 自测
test:db 未跑:本轮没有改查询语句,任务书也不要求。前端全量和 Python 快速门留给验收。`npm run lint` 0 error,原有 warning 没动。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `node frontend/node_modules/typescript/bin/tsc --noEmit -p frontend` | 0 错 |
| 规范 | `npm run lint --prefix frontend` | 0 error,125 条原有 warning |
| 准备与审核 | `tsx --test` 下列文件:`consult-turn-prep-20261013`、`consult-prep-gate-20261013`、`content-moderation`、`model-catalog`、`consult-first-frame-and-pacing-20260928`、`rectification-session-composer-guard` | 准备函数 3 通过;门禁文件 5 通过;其余在同一次 41 例里通过(当时缓存测试还在改,后来单文件 5 通过、失败 0、取消 0) |
| 计费、注销、标题、寒暄、领域 | 同上命令:`application-billing-contract`、`account-deletion`、`session-title-persist-contract`、`consultation-smalltalk`、`consultation-agentic-runtime` | 136 通过,失败 0,取消 0 |
| 假时钟 | `consult-prep-parallel-ab.mts` | 并行 160,串行 210,同时步数 2 对 1 |
| 领域镜像 | `consult-domain-concurrency-ab.py` | 退出 0,写明没有跑镜像 |
## 第 1 次打回
合并最新 `origin/staging`。业务代码没改,三个开关默认值没改。
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `chat-session-authority.test.ts` | 在 `route.ts` 里找 `if (!chatSession)` 后的 404 | 在 `turn-prep.ts` 里锁 `sessionMissing()` 的 404,和分开的 `notConsultation()` 409 | 判定搬走了。仍不许并成一个判断 |
| `consultation-workflow-contract.test.ts` | `beforeReserve: ({ consultationMode }) => createConsultationPlan({`,以及 `reserve: () => reserveConsultationModel(` | `beforeReserve` 的 async 函数体里调用 `createConsultationPlan({`,并且早于 `reserve`;`reserve` 的 async 函数体里调用 `reserveConsultationModel(` | 改成了 async。没有放宽成「文件里出现过函数名」 |
| `consult-prep-parallel-ab.mts` | `frontend/scripts/research/`,从 `frontend/tests` 导入 | `frontend/tests/consult-prep-parallel-ab.mts` | 镜像会复制 scripts、不复制 tests。另一个新增的领域脚本是 Python,不导入测试目录 |
这次自测:上述合同测试加出生时间模式、镜像导入检查,40 通过,失败 0,取消 0。假时钟脚本挪走后仍是并行 160、串行 210。`tsc --noEmit` 0 错。`npm run lint` 0 error,125 条原有 warning。全量 `npm test` 留给验收。`git diff a0e223e2` 到本修复会带上合并进来的测试环境前端文件;相对那次合并的 `origin/staging`,`frontend/src` 没有新增改动。
+4 -3
View File
@@ -332,7 +332,8 @@
| `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 已实现 `ab1ade59`,已部署;2026-09-13 验收通过(实现单由执行方自拟,无产品决策记录段;收益口径见研究文档) | `codex/rectification-refresh-r3-r4-20260913` |
| `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) |
| `TASK-upstream-sync7-20261014.md` | [PROGRESS-upstream-sync7-20261014.md](PROGRESS-upstream-sync7-20261014.md) | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待验收 | `codex/upstream-sync7-20261014`(BUG-1318~1321 已改;BUG-1322 星历路径核查中,未改代码) |
| `TASK-beta-main-path-20261014.md` | — | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 待执行 | — |
| `TASK-beta-main-path-20261014.md` | `PROGRESS-beta-main-path-20261014.md` | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 第 1 次打回后已修,待验收 | `codex/beta-main-path-20261014` |
| `TASK-beta-main-path-fix-20261014.md` | `PROGRESS-beta-main-path-20261014.md` | **#73 第 1 次打回**:`fca62fda` 内容通过;与 #63 删除的旧报告视图有代码冲突,接受删除即可 | 待验收 | `codex/beta-main-path-20261014` |
| `TASK-release-path-production-20261014.md` | — | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待执行 | — |
| `TASK-beta-safety-net-20261014.md` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — |
| `TASK-beta-capacity-20261014.md` | — | **内测容量**:按用户限流、重计算短排队与对话重试、兑换码当邀请码、staging 10/20 并发压测。**等 #58 合入后开工,压测等 #57 合入** | 待前置 | — |
@@ -475,8 +476,8 @@
| `TASK-api-disk-cache-retention-20261013.md` | `PROGRESS-api-disk-cache-retention-20261013.md` | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待验收 | `codex/api-disk-cache-retention-20261013` |
| `TASK-report-reference-perf-20261013.md` | `PROGRESS-report-reference-perf-20261013.md` | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待验收 | `codex/report-reference-perf-20261013` |
| `TASK-report-reference-perf-fix-20261014.md` | `PROGRESS-report-reference-perf-20261013.md` | **#61 第 1 次打回**:`69c739fb` 本单全过;与清理规则合并后,长报告缓存目录补进自行清理名单 | 待验收 | `codex/report-reference-perf-20261013` |
| `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-fix-20261014.md` | — | **#62 第 1 次打回**:`a0e223e2` 业务逻辑通过;两条源码文本断言未跟改(§7.8)、A/B 脚本从 `frontend/tests` 导入(BUG-1140) | 待领取 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-20261013.md` | `PROGRESS-consult-prep-parallel-20261013.md` | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待验收 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-fix-20261014.md` | `PROGRESS-consult-prep-parallel-20261013.md` | **#62 第 1 次打回**:两条源码文本检查改认新位置,对照脚本挪进测试目录 | 待验收 | `codex/consult-prep-parallel-20261013` |
| `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-api-chart-subprocess-gate-20261013.md` | — | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待领取(排队:api-disk-cache-retention + report-reference-perf) | `codex/api-chart-subprocess-gate-20261013` |
@@ -0,0 +1,36 @@
# TASK · 内测主路径修复单(issue #73,第 1 次打回:与 #63 代码冲突)
- 日期:2026-10-14
- 原任务书:`docs/tasks/TASK-beta-main-path-20261014.md`
- 被验 SHA:`fca62fda43b3f8b7bb76303bf0c83427d25c63ee`(`codex/beta-main-path-20261014`)
- 执行方:Grok,同一分支续做(merge 最新 `origin/staging`,不改写已推历史)
- BUG 编号:沿用 BUG-1063 等本单编号
## 1. 验收结论:内容通过,与 staging 合并有代码冲突
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 迁移 `20261014010000_chart_profiles_service_role_select.sql` | 通过 | 只加逐列 SELECT,列与 `CHART_SUBJECT_SELECT` 一致;不授写、不改 RLS、不动 authenticated;只增不删,对已部署代码向后兼容(§7.6) |
| `test:db` | 以 Grok 为准 | 82 项 / 失败 13,与 `windows-known-failures.txt` 逐名相同 |
| AI 生成标识 | 通过 | 输入框下一行、报告标题下一行;英文句只锁不显示 |
| 状态板清账 | 通过 | 写「已部署 staging `xxxx`」的 11 个提交号,我逐个核对都在 `origin/staging` 上;未把任何一条标成 resolved |
| 与最新 staging 合并 | **未通过** | `CONFLICT (modify/delete): frontend/src/components/personal-report/personal-report-document-view.tsx deleted in origin/staging and modified in HEAD` |
**原因**:#63(BUG-1342,已在 staging)把从不挂载的旧报告视图 `personal-report-document-view.tsx` 整个删了;本单给它的 `ReportCover` 加了一行 AI 标识。两单同时在做,不是你的疏漏。
## 2. 修法要求
1. `git merge origin/staging`,冲突里**接受删除**(`git rm frontend/src/components/personal-report/personal-report-document-view.tsx`)。现行报告页是 `personal-report-markdown-view.tsx`,那里的标识已经加上,不需要再找新位置。
2. `frontend/tests/ai-generated-notice-20261014.test.ts` 里若有读取 / 断言该已删文件的,删掉那部分,按 §7.3 写三栏;markdown 视图、输入框的断言保留不动。
3. `CHANGELOG.md`、`docs/BUG_HISTORY.md` 的文档冲突两边都保留。
4. 合并后在本机跑:`tsc --noEmit`、`npm run lint`、`npx tsx --test tests/ai-generated-notice-20261014.test.ts tests/runtime-sources-no-test-imports.test.ts tests/class-name-definition-contract.test.ts`,fail 与 cancelled 都为 0。
5. 业务代码除上面这处外不改。PROGRESS 末尾记一行。
## 3. 验收标准
- `git merge-base --is-ancestor origin/staging <新 SHA>` 成立;
- Claude 前端全量 `npm test` 失败清单与 staging 基线逐名相同。
## 4. 让步顺序
无。第 1 次打回。
+3 -3
View File
@@ -8,11 +8,11 @@
| 1 | 新人第一次进站 | 一个从没用过的邮箱;手机 Safari | 打开登录页 → 勾选同意 → 输入邮箱 → 发验证码 → 输入固定码 → 填出生资料(城市、日期、时间) | 不要求设密码;建盘后先看到群星汇聚过场,**不先弹兑换窗**;过场后进入首页 | BUG-1080、1152;`staging-otp-skip-password-20261001.md`、`birth-sky-cover-checklist.md` | | | |
| 2 | 首页 | 第 1 条的账号 | 停留首页、刷新一次 | 不卡在「正在载入账户」;今日星语有当天内容(不是「没写出来」);会话列表正常 | BUG-936、283 / 299;`home-first-load-20260930.md`、`session-list-rebuild-20260921.md` | | | |
| 3 | 寒暄不扣点 | 记下当前点数 | 新对话发「你好」 | 一句简短回应,不排盘、不出长解读;点数不变 | BUG-976、977;`consult-smalltalk-fastpath-20260920.md` | | | |
| 4 | 普通对话 + AI 标识 | 有点数 | 问一个事业问题,再追问一句 | 回答几段人话、不念内部清单;输入框下方有「回答由 AI 生成,仅供参考……」一行;iPhone 上正文右侧无大块空白、不抖;收起键盘页面不上移 | BUG-1252、1253、1255、920、内测主路径单 T2;`consult-readable-20261006.md`、`mobile-viewport-scroll-lock-20260917.md` | | | |
| 4 | 普通对话 + AI 标识 | 有点数 | 问一个事业问题,再追问一句;再打开一条生时校正看输入框 | 回答几段人话、不念内部清单;两处输入框下方都有「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」,窄屏不换行、不挡住发送;iPhone 上正文右侧无大块空白、不抖;收起键盘页面不上移 | BUG-1252、1253、1255、920、1347、内测主路径单 T2;`consult-readable-20261006.md`、`mobile-viewport-scroll-lock-20260917.md` | | | |
| 5 | 明说要校正 | 档案出生时间是「大约」 | 新对话说「帮我做一下生时纠正」;另开一个对话说「这个解读我不认同」 | 第一句直接进入校正,不先写解读;第二句**不**进入校正,只改读法 | BUG-1306、1317;`rectification-explicit-entry-20261012.md` 第 1、1b、4、9 步 | | | |
| 6 | 校正开场 | 第 5 条进入的校正 | 看开场三句 | 说出填的时间离最近换段差几分钟、两边哪几件事不同;离得近时有「这里连本命上升都换」;不出现秒、「记录时间是错的」 | BUG-1307、1309;`rectification-nearest-boundary-opening-20261012.md` | | | |
| 7 | 人物档案不串人 | 人物档案里加一位家人 | 切换到家人 → 设为默认 → 回到本人 → 再切家人 | 每次显示的资料都是对应的人,名字、出生资料不串 | BUG-1030;`people-archive-p1-20260924.md` | | | |
| 8 | 给别人出报告 | 第 7 条的家人;有点数 | 选家人 → 生成报告 → 等完成 → 打开 | 报告能生成完成(不一直排队、不失败退款),标题下有「本报告由 AI 生成,仅供参考。」 | BUG-1063、内测主路径单 T1/T2 | | | |
| 9 | 本人报告 | 本人 | 生成一份中文完整报告并打开;切英文看一眼 | 开篇重点卡正常;英文版无中文残留;结尾免责在 | BUG-1312~1316;`report-translation-gaps-20261013.md`、`report-focus-card-20261013.md` | | | |
| 8 | 给别人出报告 | 第 7 条的家人;有点数 | 选家人 → 生成报告 → 等完成 → 打开 | 报告能生成完成(不一直排队、不失败退款),标题下有「本报告由 AI 生成,仅供参考。」 | BUG-1063、1347、内测主路径单 T1/T2 | | | |
| 9 | 本人报告 | 本人 | 生成一份中文完整报告并打开;切英文看一眼 | 开篇重点卡正常;中文标题下有「本报告由 AI 生成,仅供参考。」,英文标题下是 “This report is AI-generated and for reference only.”;英文版无中文残留;结尾免责在 | BUG-1312~1316、1347;`report-translation-gaps-20261013.md`、`report-focus-card-20261013.md` | | | |
| 10 | 反馈与注销 | 任一测试账号 | 对一条回答点踩并写一句反馈 → 后台「反馈」页查看;账户设置里申请注销 → 再撤销 | 后台能看到反馈;注销申请显示 7 天冷静期,撤销后账户恢复 | `surface-feedback-20260929.md`、`legal-consent-20260930.md`、`compliance-launch-20260930.md` | | | |
| 11 | 限流与排队(容量单合入后) | 两台设备同一账号 | 1 分钟内连发 7 次提问;另找 3 个账号同时做校正 | 第 7 次得到一句「太快了,稍等一下」类提示,不扣点;同时校正的人看到「排一下队」后照常继续,不报错 | 内测容量单 T1/T2 | | | |
@@ -0,0 +1,36 @@
# 发问准备并行:本轮量到的和没量到的(2026-10-14)
开关都还关着。用户发问后的第一行,这轮不会变快。
## 进程内假时钟
命令:`node node_modules/tsx/dist/cli.mjs tests/consult-prep-parallel-ab.mts`(在 `frontend` 目录)。
假延迟:注销检查 50 毫秒,读会话 80 毫秒,解析模型 80 毫秒。这不是数据库实测。
| | 逻辑耗时 | 同时进行的步数 |
| --- | --- | --- |
| 串行 | 210 | 1 |
| 并行 | 160 | 2 |
并行比这三条假延迟的相加更短。它不能代替「首帧中位数下降两成」的开启条件,因为本机没有量 Postgres。
审核密钥不在这台机器上。测试里把审核挂起 1500 毫秒,第一小段在 400 毫秒内已经写出,然后才收到拦截。这是假等待,不是供应商的真实时间。
## 版本缓存
行为测试:15 秒内第二次解析不再查库、不再做地址检查;清缓存后会再查;缓存关掉后每次都查;查库失败和空结果不会被记住。没有量到真实「准备模型」那一段快了多少,所以缓存时间保持 0。
## 两个领域一起算
命令:`.venv\Scripts\python.exe frontend\scripts\research\consult-domain-concurrency-ab.py`
本轮没有启动计算服务,也没有带星盘网络库的镜像。任务书起草时的试算大约只快一成,热缓存时反而更慢,达不到快两成五。默认仍是一个一个算。
## 仍然关闭的原因
| 开关 | 现在 | 要打开还缺什么 |
| --- | --- | --- |
| 发问准备并行 | 关 | 本机 Postgres 上鉴权、注销检查、读会话、读模型各 30 次的中位数,并且首帧中位数至少快两成 |
| 模型版本缓存 | 0 毫秒 | 同上,准备模型那一段至少快一半 |
| 两个领域一起算 | 1 | 带星盘网络库的镜像上墙钟至少快两成五,争用时 429 不增加,响应除约定字段外一致 |
+2 -2
View File
@@ -339,11 +339,11 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
### Input and composer
- **Structure:** a column, not a row — warm canvas field on top, the composer's own bottom row beneath it. The row carries the model picker on the left and the send/stop control on the right, with the character count inline beside it.
- **No strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has.
- **No tool strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has. A single line of copy (`.composer-ai-notice`, 2026-10-14) sits under the box: it is not a tool row, has no 44px slot, and on a narrow screen it stays one line and ellipsizes instead of wrapping over the send control.
- **States:** default, hover, focus with deep-brown ring, disabled only for structural reasons (readonly, session loading, onboarding, the other surface open, **a rectification session whose surface is not yet open**), invalid. Generating does not disable the textarea; the send control becomes stop. On a rectification session the ordinary composer is only a disabled field with placeholder「正在打开生时校正…」; if opening failed, a「重新打开生时校正」button sits above it. No spinner.
- **Queue:** one pending card (`.composer-queue`) sits above the field while an answer is in flight. Copy: “已排队,回答结束后发出”, plus the text and a 44px “撤回”. A second Enter appends to the same card with a newline. Successful settlement sends it; stop, failure, or recovery puts the text back in the field. The send button is disabled while a card is waiting. No spinner.
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot. The only thing under the box is the one-line AI notice, not another control.
- **Inner field corners:** the textarea inside `.composer` has `border-radius: 0`. Rounding stays on the outer `.composer` only. The field has no horizontal padding, so a radius on the textarea itself clips the first glyph's left edge (BUG-1311).
### Answer start anchor
+6
View File
@@ -239,6 +239,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
星盘、星历、报告列表几秒内就会出现,等待时只放轨道环动画,不在屏幕上写「这一张盘还没拿到。」一类句子;这些句子只给读屏器。首页不再写「上次那次校正还没完成,可以在历史对话里接着做。」。
## AI 生成标识(2026-10-14)
对话输入框下方一行,普通对话和生时校正共用,不进回答正文:「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」英文壳还没有,对应英文先定下:「AI-generated. For reference only; not medical, legal or financial advice.」
报告标题下(中文阅读页、结构化阅读页、英文版)一行:「本报告由 AI 生成,仅供参考。」英文:「This report is AI-generated and for reference only.」报告结尾原有的免责保留,不改生成正文。
## 协议与同意(2026-09-30)
- 勾选框:「我已阅读并同意《用户协议》和《隐私政策》」;未勾选就提交:「请先阅读并勾选同意,再继续。」(只在框下一行,不弹 toast)。
@@ -0,0 +1,24 @@
"""Two-domain concurrency bench.
The vedastro image and the local API were not started in this round.
The task book's draft on one public chart was about 10 percent faster when
cold and slower when the chart was already cached. The switch opens only
when the vedastro image is at least 25 percent faster, contention 429s do
not rise, and the byte diff matches. Default stays 1.
"""
from __future__ import annotations
def main() -> int:
print("CONSULT_DOMAIN_CONCURRENCY default stays 1")
print("vedastro image bench: not run")
print("local API bench: not run")
print("contention 429 count: not run")
print("byte diff: not run")
print("reason: this round has no vedastro measurement; the published draft missed the 25 percent gate")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+158 -88
View File
@@ -10,7 +10,15 @@ import {
runConsultationWorkflow,
} from "@/mastra";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { consultDomainConcurrency, consultPrepParallelEnabled, modelVersionCacheTtlMs } from "@/lib/consult/prep-flags";
import {
ConsultationInputBlockedError,
consultPrepObservabilityEvent,
type ConsultPrepPhase,
} from "@/lib/consult/prep-log";
import { loadConsultTurnHead } from "@/lib/consult/turn-prep";
import { inputBlockedCopy, moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/lib/moderation";
import type { ModerationResult } from "@/lib/moderation";
import { recordModerationEvent } from "@/lib/moderation/log";
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
import { parseAgentReply } from "@/lib/agent-reply";
@@ -304,78 +312,43 @@ export async function POST(request: Request) {
return jsonForSupabaseSetupFailure(error, "POST /api/consult");
}
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
{ status: 401 },
);
const prepParallel = consultPrepParallelEnabled();
const prepPhases: ConsultPrepPhase[] = [];
const head = await loadConsultTurnHead({
parallel: prepParallel,
getUser: () => supabase.auth.getUser(),
readPendingDeletion: (userId) => refuseWhenAccountDeletionPending(userId),
readBody: async () => chatRequestSchema.safeParse(await request.json().catch(() => null)),
readSession: async (userId, parsed) => await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
.eq("id", parsed.sessionId)
.eq("user_id", userId)
.maybeSingle(),
resolveModel: async (chatSession) => {
if (!chatSession.model_id) return null;
return await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
},
});
if (!head.ok) {
// A pending account deletion freezes the account: nothing new is charged.
const frozen = head.frozen ? head.response : null;
if (frozen) return frozen;
return head.response;
}
// A pending account deletion freezes the account: nothing new is charged.
const frozen = await refuseWhenAccountDeletionPending(user.id);
if (frozen) return frozen;
const parsed = chatRequestSchema.safeParse(
await request.json().catch(() => null),
const { user, parsed, chatSession, sessionModel } = head;
prepPhases.push(
{ phase: "prep.auth", durationMs: head.timings.authMs, status: "completed" },
{ phase: "prep.deletion", durationMs: head.timings.deletionMs, status: "completed" },
{ phase: "prep.session", durationMs: head.timings.sessionMs, status: "completed" },
{ phase: "prep.model", durationMs: head.timings.modelMs, status: "completed" },
{ phase: prepParallel ? "prep.flag.parallel-on" : "prep.flag.parallel-off", durationMs: 0, status: "completed" },
{ phase: modelVersionCacheTtlMs() > 0 ? "prep.flag.cache-on" : "prep.flag.cache-off", durationMs: 0, status: "completed" },
{ phase: consultDomainConcurrency() === 2 ? "prep.flag.domains-2" : "prep.flag.domains-1", durationMs: 0, status: "completed" },
);
if (!parsed.success) {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() },
{ status: 400 },
);
}
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
if (chatSessionError) {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!chatSession) {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
if (chatSession.session_type !== "consultation") {
return NextResponse.json(
{
error: "这是生时校正会话",
code: "session_not_consultation",
message: "请在生时校正里继续。",
},
{ status: 409 },
);
}
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
const sessionModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!sessionModel) {
return NextResponse.json(
{
error: "模型暂不可用",
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
},
{ status: 503 },
);
}
const sessionContextWindow = sessionModel.contextWindow;
if (parsed.data.entrypoint === "birth_time_rectification") {
@@ -421,14 +394,58 @@ export async function POST(request: Request) {
// Content moderation, input side (2026-09-30): before any reservation, so a
// blocked question is never sent to a model and never charged.
const moderationContext = { route: "consult", userId: user.id, requestId: parsed.data.requestId, modelId: chatSession.model_id };
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
if (inputModeration.verdict === "block") {
let inputModerationPending: Promise<ModerationResult> | null = null;
let inputModerationRecorded = false;
let moderationTimed = false;
let prepLogged = false;
const moderationStartedAt = Date.now();
function logPrep() {
if (prepLogged) return;
prepLogged = true;
logAgentObservability(consultPrepObservabilityEvent({
requestId,
sessionId,
phases: prepPhases,
}));
}
async function settleInputModeration(): Promise<ModerationResult | null> {
if (!inputModerationPending) return null;
const result = await inputModerationPending;
if (!moderationTimed) {
moderationTimed = true;
prepPhases.push({
phase: "prep.moderation",
durationMs: Math.max(0, Date.now() - moderationStartedAt),
status: result.verdict === "block" ? "failed" : "completed",
});
}
if (result.verdict !== "pass" && !inputModerationRecorded) {
inputModerationRecorded = true;
await recordModerationEvent(accounting, result, "input", moderationContext);
}
return result;
}
function contentBlockedResponse(result: ModerationResult) {
return NextResponse.json(
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(inputModeration) },
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(result) },
{ status: 400 },
);
}
if (!prepParallel) {
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
prepPhases.push({
phase: "prep.moderation",
durationMs: Math.max(0, Date.now() - moderationStartedAt),
status: inputModeration.verdict === "block" ? "failed" : "completed",
});
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
if (inputModeration.verdict === "block") {
logPrep();
return contentBlockedResponse(inputModeration);
}
} else {
inputModerationPending = moderate(visibleQuestion, "input", moderationContext);
}
type SelectedModel = NonNullable<Awaited<ReturnType<typeof resolveSessionLanguageModel>>>;
type ReservationResult = { success: boolean; credits: number | null; error_code: string | null };
type ModelSelection = Awaited<ReturnType<typeof reserveConsultationModel<SelectedModel, ReservationResult>>>;
@@ -443,6 +460,7 @@ export async function POST(request: Request) {
// pre-stream order because its text/plain replies cannot ride an NDJSON stream.
const runTurn = async (): Promise<Response> => {
let prepared: PreparedConsultationRoute<ModelSelection, ConsultationPlan>;
const prepareStarted = Date.now();
try {
prepared = await prepareConsultationRoute<ModelSelection, ConsultationPlan>({
userId,
@@ -475,13 +493,25 @@ export async function POST(request: Request) {
return data;
},
loadCandidateRange: (profileUserId) => loadReportCandidateRange(accounting, { userId: profileUserId }),
beforeReserve: ({ consultationMode }) => createConsultationPlan({
userIntent: parsed.data.question,
theme: consultationTheme,
consultationMode,
modelCreditCost: sessionModel.creditCost,
}),
reserve: () => reserveConsultationModel(
beforeReserve: async ({ consultationMode }) => {
prepPhases.push({
phase: "prep.subject",
durationMs: Math.max(0, Date.now() - prepareStarted),
status: "completed",
});
const inputModeration = await settleInputModeration();
if (inputModeration?.verdict === "block") throw new ConsultationInputBlockedError(inputModeration);
return createConsultationPlan({
userIntent: parsed.data.question,
theme: consultationTheme,
consultationMode,
modelCreditCost: sessionModel.creditCost,
});
},
reserve: async () => {
const reserveStarted = Date.now();
try {
return await reserveConsultationModel(
chatSession.model_id,
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
async (model) => {
@@ -503,9 +533,22 @@ export async function POST(request: Request) {
error_code: reservation.data.reason,
};
},
),
);
} finally {
prepPhases.push({
phase: "prep.reserve",
durationMs: Math.max(0, Date.now() - reserveStarted),
status: "completed",
});
}
},
});
} catch (error) {
const blocked = await settleInputModeration();
if (blocked?.verdict === "block") {
logPrep();
return contentBlockedResponse(blocked);
}
if (error instanceof FeaturePricingError) {
console.error(
`[billing] reservation failed request=${requestId} reason=${error.code}`,
@@ -626,6 +669,7 @@ export async function POST(request: Request) {
}
let appendedQuestion: { success: boolean; error_code?: string | null };
const appendStarted = Date.now();
try {
appendedQuestion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("append_consultation_question", {
@@ -640,7 +684,18 @@ export async function POST(request: Request) {
}
return parsedAppend.data;
});
prepPhases.push({
phase: "prep.append",
durationMs: Math.max(0, Date.now() - appendStarted),
status: "completed",
});
} catch {
prepPhases.push({
phase: "prep.append",
durationMs: Math.max(0, Date.now() - appendStarted),
status: "failed",
});
logPrep();
await cancel();
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
@@ -670,6 +725,14 @@ export async function POST(request: Request) {
let classificationOutcome: string | undefined;
let classificationDurationMs: number | null = null;
let latencyState: ReturnType<typeof createConsultationRuntimeState> | null = null;
const titleStarted = Date.now();
const titleRead = supabase
.from("chat_sessions")
.select("title")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
const classifyStarted = Date.now();
const turn = parsed.data.entrypoint === undefined
? await classifyConsultationTurn({
model: selectedModel,
@@ -705,8 +768,14 @@ export async function POST(request: Request) {
},
})
: { kind: "consult" as const };
prepPhases.push({
phase: "prep.classify",
durationMs: classificationDurationMs ?? Math.max(0, Date.now() - classifyStarted),
status: "completed",
});
if (turn.kind === "smalltalk") {
logPrep();
return streamSmalltalkResponse({
requestId,
reply: turn.reply,
@@ -738,12 +807,13 @@ export async function POST(request: Request) {
}
const expectedTitle = typeof chatSession.title === "string" ? chatSession.title : "";
const { data: titleRow } = await supabase
.from("chat_sessions")
.select("title")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
const { data: titleRow } = await titleRead;
prepPhases.push({
phase: "prep.title",
durationMs: Math.max(0, Date.now() - titleStarted),
status: "completed",
});
logPrep();
async function checkpointConsultationContext() {
try {
+7
View File
@@ -1839,6 +1839,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
outline-offset: 3px;
}
.composer { width: min(760px, 100%); display: flex; flex-direction: column; gap: var(--space-2); margin: 0 auto; border: 1px solid var(--color-border-strong); transition: border-color 140ms ease-out; padding: var(--space-3) var(--space-3) var(--space-2); border-radius: var(--radius-lg); background: var(--color-canvas); }
.composer-ai-notice { width: min(760px, 100%); margin: var(--space-2) auto 0; overflow: hidden; color: var(--color-ink-tertiary); font-size: var(--type-overline); line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
/* The composer's own bottom row. It replaces .composer-footer, a strip below
the composer that held a permanent 44px whether or not it had anything to
show. The row only exists when there is a toolbar or a count to render. */
@@ -4341,6 +4342,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
line-height: 1.5;
}
.personal-report-ai-notice {
margin: 0 0 var(--space-3);
color: var(--color-ink-tertiary);
font-size: var(--type-overline);
line-height: 1.45;
}
.personal-report-focus-card {
display: grid;
gap: var(--space-3);
@@ -12,6 +12,7 @@ import {
serverComposerDraftSnapshot,
subscribeComposerDraft,
} from "@/lib/composer-draft";
import { CHAT_AI_NOTICE_ZH } from "@/lib/ai-generated-notice";
import { COMPOSER_QUEUE_LABEL, COMPOSER_QUEUE_RECALL_LABEL } from "@/lib/queued-draft";
export const composerRemainingId = "composer-character-remaining";
@@ -133,6 +134,7 @@ export function ChatComposer({
</div>
</div>
</form>
<p className="composer-ai-notice">{CHAT_AI_NOTICE_ZH}</p>
</>
);
}
@@ -12,6 +12,7 @@ import {
parseReportChartBlock,
toNorthIndianChart,
} from "@/lib/report-chart-block";
import { reportAiNotice } from "@/lib/ai-generated-notice";
import { parseReportFocusBlock, reportTitleByline } from "@/lib/report-focus-block";
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
@@ -69,10 +70,12 @@ function markdownComponents(
h1: ({ children }) => {
const title = flattenText(children).trim();
const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language });
const notice = byline.shown ? null : reportAiNotice(language);
byline.shown = true;
return (
<>
<h1>{children}</h1>
{notice ? <p className="personal-report-ai-notice">{notice}</p> : null}
{line ? <p className="personal-report-md-byline">{line}</p> : null}
</>
);
+14
View File
@@ -0,0 +1,14 @@
import type { ReportLanguage } from "./report-language.ts";
/** Visible label under the chat field. The product shell is Chinese. */
export const CHAT_AI_NOTICE_ZH = "回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。";
/** English equivalent, locked for a future English shell. Not shown in the Chinese UI. */
export const CHAT_AI_NOTICE_EN = "AI-generated. For reference only; not medical, legal or financial advice.";
export const REPORT_AI_NOTICE_ZH = "本报告由 AI 生成,仅供参考。";
export const REPORT_AI_NOTICE_EN = "This report is AI-generated and for reference only.";
export function reportAiNotice(language: ReportLanguage): string {
return language === "en" ? REPORT_AI_NOTICE_EN : REPORT_AI_NOTICE_ZH;
}
+62
View File
@@ -0,0 +1,62 @@
/** Call-time switches for consult preparation. Read on each request, not at import. */
const warned = new Set<string>();
function warnOnce(name: string, raw: string): void {
if (warned.has(name)) return;
warned.add(name);
console.warn(`[consult-prep] ${name} 的值无法识别,按默认处理`);
void raw;
}
function readRaw(env: Readonly<Record<string, string | undefined>>, name: string): string | undefined {
const raw = env[name];
if (raw === undefined) return undefined;
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
}
export function consultPrepParallelEnabled(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
const raw = readRaw(env, "CONSULT_PREP_PARALLEL");
if (raw === undefined) return false;
const value = raw.toLowerCase();
if (value === "on") return true;
if (value === "off") return false;
warnOnce("CONSULT_PREP_PARALLEL", raw);
return false;
}
/** 0 closes the cache. Anything outside 0–15000 stays on the default, which is off. */
export function modelVersionCacheTtlMs(
env: Readonly<Record<string, string | undefined>> = process.env,
): number {
const raw = readRaw(env, "MODEL_VERSION_CACHE_TTL_MS");
if (raw === undefined) return 0;
if (!/^\d+$/.test(raw)) {
warnOnce("MODEL_VERSION_CACHE_TTL_MS", raw);
return 0;
}
const parsed = Number(raw);
if (parsed > 15_000) {
warnOnce("MODEL_VERSION_CACHE_TTL_MS", raw);
return 0;
}
return parsed;
}
/** 1 is today's serial loop. 2 starts the capped domains together. */
export function consultDomainConcurrency(
env: Readonly<Record<string, string | undefined>> = process.env,
): 1 | 2 {
const raw = readRaw(env, "CONSULT_DOMAIN_CONCURRENCY");
if (raw === undefined || raw === "1") return 1;
if (raw === "2") return 2;
warnOnce("CONSULT_DOMAIN_CONCURRENCY", raw);
return 1;
}
export function resetConsultPrepFlagWarningsForTests(): void {
warned.clear();
}
+34
View File
@@ -0,0 +1,34 @@
import type { ModerationResult } from "@/lib/moderation";
export const CONSULT_PREP_AGENT_VERSION = "consultation-prep-v1";
export class ConsultationInputBlockedError extends Error {
readonly result: ModerationResult;
constructor(result: ModerationResult) {
super("content_blocked");
this.name = "ConsultationInputBlockedError";
this.result = result;
}
}
export type ConsultPrepPhase = {
phase: string;
durationMs: number;
status: "completed" | "failed" | "skipped";
};
/** Closed observability event. The question text is not a field. */
export function consultPrepObservabilityEvent(input: {
requestId: string;
sessionId: string;
phases: readonly ConsultPrepPhase[];
}) {
return {
requestId: input.requestId,
sessionId: input.sessionId,
agentVersion: CONSULT_PREP_AGENT_VERSION,
contractPhases: input.phases.map((phase) => ({ ...phase })),
billingSettlementResult: "not_applicable" as const,
};
}
+195
View File
@@ -0,0 +1,195 @@
import { NextResponse } from "next/server";
export type ConsultPrepTimings = {
authMs: number;
deletionMs: number;
sessionMs: number;
modelMs: number;
};
type AuthResult<User> = {
data: { user: User | null };
error: unknown;
};
type BodyResult<Parsed> =
| { success: true; data: Parsed }
| { success: false; error: { flatten: () => unknown } };
type SessionResult<Session> = {
data: Session | null;
error: unknown;
};
type SessionRow = {
model_id: string | null;
session_type: string | null;
model_config_version?: number | null;
messages?: unknown;
title?: unknown;
theme?: unknown;
chart_profile_id?: unknown;
chart_profile_name?: unknown;
chart_profile_role?: unknown;
context_summary?: unknown;
};
type ParsedRequest = {
sessionId: string;
modelId: string;
};
export type ConsultTurnHeadDeps<User extends { id: string }, Parsed extends ParsedRequest, Session extends SessionRow, Model> = {
parallel: boolean;
getUser: () => Promise<AuthResult<User>>;
readPendingDeletion: (userId: string) => Promise<Response | null>;
readBody: () => Promise<BodyResult<Parsed>>;
readSession: (userId: string, parsed: Parsed) => Promise<SessionResult<Session>>;
resolveModel: (chatSession: Session) => Promise<Model | null>;
};
export type ConsultTurnHead<User, Parsed, Session, Model> =
| { ok: false; response: Response; frozen: boolean }
| {
ok: true;
user: User;
parsed: { success: true; data: Parsed };
chatSession: Session & { model_id: string };
sessionModel: Model;
timings: ConsultPrepTimings;
};
function elapsedSince(started: number): number {
return Math.max(0, Date.now() - started);
}
function loginRequired(): Response {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
{ status: 401 },
);
}
function badBody(error: { flatten: () => unknown }): Response {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: error.flatten() },
{ status: 400 },
);
}
function sessionUnreadable(): Response {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
function sessionMissing(): Response {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
function notConsultation(): Response {
return NextResponse.json(
{
error: "这是生时校正会话",
code: "session_not_consultation",
message: "请在生时校正里继续。",
},
{ status: 409 },
);
}
function modelChanged(): Response {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
function modelUnavailable(): Response {
return NextResponse.json(
{
error: "模型暂不可用",
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
},
{ status: 503 },
);
}
type Chain<Parsed, Session, Model> =
| { ok: false; response: Response }
| {
ok: true;
parsed: { success: true; data: Parsed };
chatSession: Session & { model_id: string };
sessionModel: Model;
};
/**
* Auth, then either the old serial checks or deletion in parallel with
* body → session → model. Failures are still reported in the old order.
*/
export async function loadConsultTurnHead<
User extends { id: string },
Parsed extends ParsedRequest,
Session extends SessionRow,
Model,
>(
deps: ConsultTurnHeadDeps<User, Parsed, Session, Model>,
): Promise<ConsultTurnHead<User, Parsed, Session, Model>> {
const timings: ConsultPrepTimings = { authMs: 0, deletionMs: 0, sessionMs: 0, modelMs: 0 };
const authStarted = Date.now();
const auth = await deps.getUser();
timings.authMs = elapsedSince(authStarted);
const user = auth.data.user;
if (auth.error || !user) return { ok: false, response: loginRequired(), frozen: false };
const readChain = async (): Promise<Chain<Parsed, Session, Model>> => {
const body = await deps.readBody();
if (!body.success) return { ok: false, response: badBody(body.error) };
const sessionStarted = Date.now();
const session = await deps.readSession(user.id, body.data);
timings.sessionMs = elapsedSince(sessionStarted);
if (session.error) return { ok: false, response: sessionUnreadable() };
if (!session.data) return { ok: false, response: sessionMissing() };
const chatSession = session.data;
if (chatSession.session_type !== "consultation") return { ok: false, response: notConsultation() };
if (!chatSession.model_id || chatSession.model_id !== body.data.modelId) {
return { ok: false, response: modelChanged() };
}
const modelStarted = Date.now();
const sessionModel = await deps.resolveModel(chatSession);
timings.modelMs = elapsedSince(modelStarted);
if (!sessionModel) return { ok: false, response: modelUnavailable() };
return {
ok: true,
parsed: body,
chatSession: chatSession as Session & { model_id: string },
sessionModel,
};
};
if (!deps.parallel) {
const deletionStarted = Date.now();
const frozen = await deps.readPendingDeletion(user.id);
timings.deletionMs = elapsedSince(deletionStarted);
if (frozen) return { ok: false, response: frozen, frozen: true };
const chain = await readChain();
if (!chain.ok) return { ok: false, response: chain.response, frozen: false };
return { ...chain, user, timings };
}
const deletionStarted = Date.now();
const deletion = deps.readPendingDeletion(user.id).then((frozen) => {
timings.deletionMs = elapsedSince(deletionStarted);
return frozen;
});
const chain = readChain();
const [frozen, settled] = await Promise.all([deletion, chain]);
if (frozen) return { ok: false, response: frozen, frozen: true };
if (!settled.ok) return { ok: false, response: settled.response, frozen: false };
return { ...settled, user, timings };
}
+47 -6
View File
@@ -3,8 +3,22 @@ import "server-only";
import type { MastraModelConfig } from "@mastra/core/llm";
import { queryAdminRows } from "@/lib/admin/database";
import { modelVersionCacheTtlMs } from "@/lib/consult/prep-flags";
import { assertAllowedModelProviderUrl } from "@/lib/epay/gateway-policy";
import { decryptModelProviderApiKey, type ModelProviderType } from "@/lib/model-provider-policy";
/** Tests replace the database, decrypt and DNS steps. Production leaves these as the real functions. */
const catalogIo = {
queryAdminRows,
assertAllowedModelProviderUrl,
decryptModelProviderApiKey,
};
export function setModelCatalogIoForTests(next: Partial<typeof catalogIo> | null): void {
catalogIo.queryAdminRows = next?.queryAdminRows ?? queryAdminRows;
catalogIo.assertAllowedModelProviderUrl = next?.assertAllowedModelProviderUrl ?? assertAllowedModelProviderUrl;
catalogIo.decryptModelProviderApiKey = next?.decryptModelProviderApiKey ?? decryptModelProviderApiKey;
}
import type { LanguageModelCatalog, ResolvedLanguageModel } from "@/mastra/model";
const cacheTtlMs = 15_000;
@@ -17,7 +31,11 @@ type PublishedModelRow = {
context_window: number | null;
};
type Cache = { expiresAt: number; catalog: LanguageModelCatalog };
const state = globalThis as typeof globalThis & { jyotishaModelCatalogCache?: Cache };
type VersionHit = { expiresAt: number; model: ResolvedLanguageModel };
const state = globalThis as typeof globalThis & {
jyotishaModelCatalogCache?: Cache;
jyotishaModelVersionCache?: Map<string, VersionHit>;
};
function isSecretSettingKey(key: string) {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -37,7 +55,7 @@ function publicModel(model: ResolvedLanguageModel) {
return { id: model.id, label: model.label, description: model.description, creditCost: model.creditCost, isDefault: model.isDefault };
}
async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel | null> {
const apiKey = decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
const apiKey = catalogIo.decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
if (!apiKey) return null;
let model: MastraModelConfig;
if (row.provider_type === "openai") {
@@ -45,7 +63,7 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
} else if (row.provider_type === "anthropic") {
model = { providerId: "anthropic", modelId: row.provider_model.replace(/^anthropic\//, ""), apiKey };
} else {
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await catalogIo.assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
}
return {
id: row.model_id, label: row.label, description: row.description, creditCost: row.credit_cost,
@@ -55,7 +73,7 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
};
}
async function queryCatalog(where: string, values: readonly unknown[] = []) {
return queryAdminRows<PublishedModelRow>(`
return catalogIo.queryAdminRows<PublishedModelRow>(`
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
p.code provider_code,p.provider_type,p.base_url,p.encrypted_api_key,
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost,
@@ -86,9 +104,32 @@ async function readVersion(modelId: string, version: number) {
const rows = await queryCatalog("c.model_id=$1 and v.version=$2 and v.status in ('published','retired') and v.enabled and p.enabled", [modelId, version]);
return rows[0] ? resolveRow(rows[0]) : null;
}
function versionCache(): Map<string, VersionHit> {
return state.jyotishaModelVersionCache ??= new Map();
}
export async function resolveSessionLanguageModel(modelId: string, configVersion: number | null | undefined) {
if (configVersion !== null && configVersion !== undefined) { try { return await readVersion(modelId, configVersion); } catch { return null; } }
if (configVersion !== null && configVersion !== undefined) {
const ttl = modelVersionCacheTtlMs();
const key = `${modelId}:${configVersion}`;
const cache = versionCache();
if (ttl > 0) {
const hit = cache.get(key);
if (hit && hit.expiresAt > Date.now()) return hit.model;
}
try {
const model = await readVersion(modelId, configVersion);
// Failures and null stay uncached so a disabled provider is not pinned.
if (model && ttl > 0) cache.set(key, { expiresAt: Date.now() + ttl, model });
return model;
} catch {
return null;
}
}
const catalog = await loadLanguageModelCatalog();
return catalog.models.find((model) => model.id === modelId) ?? null;
}
export function invalidateLanguageModelCatalog() { delete state.jyotishaModelCatalogCache; }
export function invalidateLanguageModelCatalog() {
delete state.jyotishaModelCatalogCache;
state.jyotishaModelVersionCache?.clear();
}
+108 -50
View File
@@ -27,6 +27,7 @@ import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit
import type { AgentModelFinishReason, EvidenceCardTelemetry } from "../lib/agent-observability.ts";
import { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
import { chartCalculationProgressLabel, evidenceLookupActivityLabel } from "../lib/consultation-activity-labels.ts";
import { consultDomainConcurrency } from "../lib/consult/prep-flags.ts";
import {
buildEvidenceCard,
citedEvidenceCardFields,
@@ -1227,57 +1228,114 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
try {
const userIntent = ctx.plan?.userIntent ?? input.question;
const executions: DomainExecution[] = [];
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
if (consultDomainConcurrency() === 1) {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
executions.push({
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
executions.push({
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
});
}
} else {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
}
const parallelExecutions = await Promise.all(domains.map(async (domain) => {
if (!domain) return null;
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
return {
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
};
}));
for (const execution of parallelExecutions) {
if (execution) executions.push(execution);
}
}
// Domains the cap refused and domains the clock ran out on are the
// same disclosure to the caller: requested but not calculated.
@@ -0,0 +1,37 @@
-- BUG-1063: the report worker reads another person's archive through
-- service_role (loadSubjectBirth → CHART_SUBJECT_SELECT). The 2026-07-18
-- repair revoked every privilege on chart_profiles and never granted SELECT
-- back. Column-level SELECT only, matching CHART_SUBJECT_SELECT. No write,
-- no RLS change, no change to authenticated grants.
begin;
grant select (
id,
user_id,
role,
name,
birth_date,
reported_birth_time,
birth_time_source,
birth_time_period,
declared_window_start,
declared_window_end,
uncertainty_before_minutes,
uncertainty_after_minutes,
latitude,
longitude,
timezone_id,
timezone_offset,
birth_place_label,
birth_place_type,
birth_place_provider,
birth_place_provider_id,
ayanamsa,
active_birth_time,
active_birth_date,
active_birth_timezone_offset,
birth_time_status,
gender
) on table public.chart_profiles to service_role;
commit;
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { createElement, createRef } from "react";
import { readFileSync } from "node:fs";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ChatComposer } from "../src/components/chat-composer.tsx";
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
import {
CHAT_AI_NOTICE_EN,
CHAT_AI_NOTICE_ZH,
REPORT_AI_NOTICE_EN,
REPORT_AI_NOTICE_ZH,
} from "../src/lib/ai-generated-notice.ts";
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const rectification = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
function composerHtml(): string {
return renderToStaticMarkup(createElement(ChatComposer, {
inputRef: createRef<HTMLTextAreaElement>(),
value: "",
inputLabel: "输入你的问题",
placeholder: "想聊什么都可以",
maxLength: 500,
inputDisabled: false,
submitLabel: "发送",
submitBlocked: false,
stopVisible: false,
stopLabel: "停止回答",
stopTitle: "停止回答",
onSubmit() {},
onChange() {},
onKeyDown() {},
onStop() {},
}));
}
test("both chat surfaces share the composer notice, under the field and not over the send control", () => {
assert.equal(CHAT_AI_NOTICE_ZH, "回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。");
assert.equal(CHAT_AI_NOTICE_EN, "AI-generated. For reference only; not medical, legal or financial advice.");
const html = composerHtml();
const noticeAt = html.indexOf("composer-ai-notice");
const sendAt = html.indexOf("发送");
assert.ok(noticeAt > sendAt, "the notice follows the send control");
assert.match(html, new RegExp(CHAT_AI_NOTICE_ZH));
assert.match(page, /<ChatComposer/);
assert.match(rectification, /<ChatComposer/);
assert.doesNotMatch(page, /CHAT_AI_NOTICE_ZH|composer-ai-notice/);
assert.doesNotMatch(rectification, /CHAT_AI_NOTICE_ZH|composer-ai-notice/);
assert.match(styles, /\.composer-ai-notice\s*\{[^}]*white-space:\s*nowrap/);
assert.match(styles, /\.composer-ai-notice\s*\{[^}]*overflow:\s*hidden/);
assert.doesNotMatch(styles, /\.composer-footer\s*\{/);
});
test("report readers show the AI notice in Chinese and English under the title", () => {
assert.equal(REPORT_AI_NOTICE_ZH, "本报告由 AI 生成,仅供参考。");
assert.equal(REPORT_AI_NOTICE_EN, "This report is AI-generated and for reference only.");
const chinese = renderToStaticMarkup(createElement(PersonalReportMarkdownView, {
markdown: "# 虚构报告\n\n一段说明。",
language: "zh",
}));
const english = renderToStaticMarkup(createElement(PersonalReportMarkdownView, {
markdown: "# Fictional report\n\nA paragraph.",
language: "en",
}));
assert.ok(chinese.indexOf("<h1>") < chinese.indexOf(REPORT_AI_NOTICE_ZH));
assert.match(english, new RegExp(REPORT_AI_NOTICE_EN.replaceAll(".", "\\.")));
});
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import test from "node:test";
import { CHART_SUBJECT_SELECT } from "../src/lib/subject-birth.ts";
// BUG-1063: every column loadSubjectBirth selects from chart_profiles must
// have a service_role SELECT grant after the 2026-07-18 revoke.
const REVOKE = "20260718100000";
const directory = new URL("../supabase/migrations/", import.meta.url);
const grantFile = new URL(
"../supabase/migrations/20261014010000_chart_profiles_service_role_select.sql",
import.meta.url,
);
function serviceRoleChartSelectGrants(): Set<string> {
const granted = new Set<string>();
for (const name of readdirSync(directory).filter((file) => file.endsWith(".sql")).sort()) {
if (name.slice(0, 14) < REVOKE) continue;
const sql = readFileSync(new URL(name, directory), "utf8").replace(/--.*$/gm, "");
const pattern = /grant\s+([a-z ,]+?)\s*\(([^)]*)\)\s*on\s+table\s+public\.chart_profiles\s+to\s+([a-z_, ]+);/gi;
for (const match of sql.matchAll(pattern)) {
if (!/select/i.test(match[1]!) || !/service_role/.test(match[3]!)) continue;
for (const column of match[2]!.split(",")) {
const trimmed = column.trim();
if (trimmed) granted.add(trimmed);
}
}
}
return granted;
}
test("service_role chart_profiles select covers CHART_SUBJECT_SELECT and nothing writable", () => {
const sql = readFileSync(grantFile, "utf8").replace(/--.*$/gm, "");
assert.doesNotMatch(sql, /grant\s+(insert|update|delete)\b/i);
assert.doesNotMatch(sql, /create\s+policy|alter\s+policy|enable\s+row\s+level\s+security/i);
assert.doesNotMatch(sql, /to\s+authenticated/i);
const missing = CHART_SUBJECT_SELECT.split(",").map((column) => column.trim()).filter((column) => !serviceRoleChartSelectGrants().has(column));
assert.deepEqual(missing, []);
});
+12 -7
View File
@@ -7,6 +7,7 @@ const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", impor
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const turnPrep = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
const sql = readFileSync(
new URL("../supabase/migrations/20260901010000_append_consultation_question.sql", import.meta.url),
"utf8",
@@ -59,14 +60,18 @@ test("session list GET omits messages while detail GET returns them", () => {
});
test("consult POST on a rectification session is 409, a missing id is 404", () => {
// 原值:!chatSession || session_type !== consultation 一律 404「咨询会话不存在」
// 新值:查不到 404;类型不对 409 session_not_consultation
// 原因:BUG-925 前端无法区分不存在与类型不对
assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*status: 404/);
assert.match(consultRoute, /code: "session_not_consultation"/);
assert.match(consultRoute, /status: 409/);
// 原值:consult route 里 /if (!chatSession) {[\s\S]*status: 404/,以及同文件的 session_not_consultation 与 409
// 新值:turn-prep.ts 里 sessionMissing() 为 404,notConsultation() 为 409 session_not_consultation,两处调用分开
// 原因:判定从 route.ts 挪进 turn-prep.ts;仍是查不到 404、类型不对 409,不得并成一个判断
assert.match(turnPrep, /function sessionMissing\(\)[\s\S]*status: 404/);
assert.match(turnPrep, /if \(!session\.data\) return \{ ok: false, response: sessionMissing\(\) \}/);
assert.match(turnPrep, /function notConsultation\(\)[\s\S]*code: "session_not_consultation"[\s\S]*status: 409/);
assert.match(
turnPrep,
/if \(chatSession\.session_type !== "consultation"\) return \{ ok: false, response: notConsultation\(\) \}/,
);
assert.doesNotMatch(
consultRoute,
turnPrep,
/if \(!chatSession \|\| chatSession\.session_type !== "consultation"\)/,
);
});
@@ -255,18 +255,23 @@ test("a turn that throws before producing any response ends the stream with a ge
test("the consult route opens the stream before subject reads, reservation, question append and classification", () => {
const route = source("../src/app/api/consult/route.ts");
const head = source("../src/lib/consult/turn-prep.ts");
const openIndex = route.indexOf("const runTurn = async (): Promise<Response> =>");
assert.ok(openIndex > 0);
// Stays in front of the stream: auth, body, session existence and type, the session model.
// 原值: 401、请求体、会话不存在、会话类型、模型已变这些字面量都在 route.ts 的 runTurn 之前。
// 新值: 这些字面量在 turn-prep.ts;route.ts 在 runTurn 之前调用 loadConsultTurnHead 并返回失败响应。提示词拦截仍在 route.ts。
// 原因: 开流前的独立读取收进同一个函数,两种开关的状态码仍在开流前返回。
assert.ok(route.indexOf("loadConsultTurnHead") < openIndex);
assert.ok(route.indexOf("if (!head.ok)") < openIndex);
assert.ok(route.indexOf("if (blocksPromptExtraction(userControlledPrompt))") < openIndex);
for (const marker of [
"status: 401",
'{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() }',
'{ error: "出生资料或问题格式不正确", details: error.flatten() }',
'error: "咨询会话不存在"',
'code: "session_not_consultation"',
'error: "会话模型已经变化"',
"if (blocksPromptExtraction(userControlledPrompt))",
]) {
assert.ok(route.indexOf(marker) < openIndex, `${marker} must answer with an HTTP status`);
assert.ok(head.includes(marker), `${marker} stays an HTTP response before the stream`);
}
// Inside the stream: the slow part, and every rejection it can raise.
for (const marker of [
@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { register } from "node:module";
import test from "node:test";
import { agentObservabilityEventSchema } from "../src/lib/agent-observability.ts";
import { ConsultationInputBlockedError, consultPrepObservabilityEvent } from "../src/lib/consult/prep-log.ts";
import { inputBlockedCopy, MODERATION_SELF_HARM_COPY } from "../src/lib/moderation/index.ts";
import type { ModerationResult } from "../src/lib/moderation/types.ts";
import { streamFirstResponse } from "../src/lib/stream-first-response.ts";
const blocked: ModerationResult = {
verdict: "block",
categories: ["self_harm"],
ruleIds: ["lexicon"],
providerId: "local",
};
test("a blocked question records once and never reaches reserve", async () => {
let reserves = 0;
let records = 0;
const pending = Promise.resolve(blocked);
async function beforeReserve() {
const result = await pending;
if (result.verdict !== "pass") records += 1;
if (result.verdict === "block") throw new ConsultationInputBlockedError(result);
reserves += 1;
}
await assert.rejects(beforeReserve, (error: unknown) => {
assert.ok(error instanceof ConsultationInputBlockedError);
assert.equal(inputBlockedCopy(error.result), MODERATION_SELF_HARM_COPY);
return true;
});
assert.equal(reserves, 0);
assert.equal(records, 1);
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const blockedAt = route.indexOf('blocked?.verdict === "block"');
const profileAt = route.indexOf("error instanceof ConsultationProfileTruthError");
assert.ok(blockedAt > 0 && blockedAt < profileAt);
});
test("moderation that takes 1.5s does not hold the first frame", async () => {
let release: (result: ModerationResult) => void = () => {};
const pending = new Promise<ModerationResult>((resolve) => {
release = resolve;
});
const started = Date.now();
const response = streamFirstResponse({
requestId: "prep-frame",
run: async () => {
const result = await pending;
return Response.json(
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(result) },
{ status: 400 },
);
},
});
const reader = response.body!.getReader();
const first = await reader.read();
assert.ok(Date.now() - started < 400, "first frame waited for moderation");
assert.ok(first.value && first.value.byteLength > 0);
setTimeout(() => release(blocked), 1_500);
const chunks: string[] = [];
const decoder = new TextDecoder();
while (true) {
const next = await reader.read();
if (next.value) chunks.push(decoder.decode(next.value));
if (next.done) break;
}
const events = chunks.join("").trim().split("\n").map((line) => JSON.parse(line));
const rejected = events.find((event) => event.type === "run.failed");
assert.equal(rejected.message, MODERATION_SELF_HARM_COPY);
assert.equal(rejected.reason, "content_blocked");
assert.equal(rejected.status, 400);
});
test("prep observability accepts the closed schema and drops the question", () => {
const question = "这句问题原文不该出现在日志里";
const event = consultPrepObservabilityEvent({
requestId: "req-prep",
sessionId: "sess-prep",
phases: [
{ phase: "prep.model", durationMs: 3, status: "completed" },
{ phase: "prep.moderation", durationMs: 12, status: "completed" },
],
});
assert.deepEqual(agentObservabilityEventSchema.parse(event), event);
assert.equal(JSON.stringify(event).includes(question), false);
assert.equal(event.agentVersion, "consultation-prep-v1");
});
test("readVersion cache skips a second database and DNS lookup inside the ttl", async () => {
await register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
let queries = 0;
let dns = 0;
const row = {
model_id: "model-a",
version: 4,
label: "Model A",
description: "fixture",
provider_model: "demo",
credit_cost: 1,
is_default: false,
provider_code: "demo",
provider_type: "openai-compatible",
base_url: "https://example.com/v1",
encrypted_api_key: "enc",
input_cost: 1,
output_cost: 1,
context_window: 8000,
};
const catalog = await import("../src/lib/model-catalog.ts");
const flags = await import("../src/lib/consult/prep-flags.ts");
catalog.setModelCatalogIoForTests({
queryAdminRows: (async () => {
queries += 1;
if (queries === 3) throw new Error("database down");
if (queries >= 4) return [];
return [row];
}) as typeof import("../src/lib/admin/database.ts").queryAdminRows,
assertAllowedModelProviderUrl: (async (value: string) => {
dns += 1;
return { url: new URL(value), addresses: [] };
}) as typeof import("../src/lib/epay/gateway-policy.ts").assertAllowedModelProviderUrl,
decryptModelProviderApiKey: (() => "test-key") as typeof import("../src/lib/model-provider-policy.ts").decryptModelProviderApiKey,
});
process.env.MODEL_VERSION_CACHE_TTL_MS = "15000";
try {
catalog.invalidateLanguageModelCatalog();
const first = await catalog.resolveSessionLanguageModel("model-a", 4);
const second = await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(first?.id, "model-a");
assert.equal(second, first);
assert.equal(queries, 1);
assert.equal(dns, 1);
catalog.invalidateLanguageModelCatalog();
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(queries, 2);
process.env.MODEL_VERSION_CACHE_TTL_MS = "0";
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.ok(queries >= 3);
const failed = await catalog.resolveSessionLanguageModel("model-a", 4);
const empty = await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(failed, null);
assert.equal(empty, null);
const queriesAfterMiss = queries;
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.ok(queries > queriesAfterMiss);
} finally {
flags.resetConsultPrepFlagWarningsForTests();
delete process.env.MODEL_VERSION_CACHE_TTL_MS;
catalog.invalidateLanguageModelCatalog();
catalog.setModelCatalogIoForTests(null);
}
});
test("the title read starts after the question is saved and is awaited at the old use site", () => {
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const append = route.indexOf('accounting.rpc("append_consultation_question"');
const title = route.indexOf('.select("title")');
const classify = route.indexOf("await classifyConsultationTurn({");
const awaited = route.indexOf("await titleRead");
assert.ok(append > 0 && title > append && classify > title && awaited > classify);
});
@@ -0,0 +1,79 @@
/**
* In-process overlap check for consult preparation.
*
* This does not measure local Postgres. Those medians were not collected,
* so the switches stay at their code defaults.
*/
import { register } from "node:module";
await register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
const { loadConsultTurnHead } = await import("../src/lib/consult/turn-prep.ts");
const delays = { deletion: 50, session: 80, model: 80 };
async function run(parallel: boolean) {
let now = 0;
const waiters: Array<{ at: number; resolve: () => void }> = [];
let finished = false;
let inFlight = 0;
let maxInFlight = 0;
function sleep(ms: number) {
return new Promise<void>((resolve) => {
waiters.push({ at: now + ms, resolve });
});
}
async function occupy(ms: number) {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await sleep(ms);
inFlight -= 1;
}
const pending = loadConsultTurnHead({
parallel,
getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }),
readPendingDeletion: () => occupy(delays.deletion).then(() => null),
readBody: async () => ({ success: true as const, data: { sessionId: "s", modelId: "m" } }),
readSession: () => occupy(delays.session).then(() => ({
data: { model_id: "m", model_config_version: 2, session_type: "consultation" },
error: null,
})),
resolveModel: () => occupy(delays.model).then(() => ({ id: "m" })),
}).then((head) => {
finished = true;
return head;
});
for (let guard = 0; guard < 40 && !finished; guard += 1) {
await Promise.resolve();
await Promise.resolve();
if (!waiters.length) continue;
waiters.sort((left, right) => left.at - right.at);
now = waiters[0]!.at;
const due: Array<() => void> = [];
while (waiters.length && waiters[0]!.at <= now) due.push(waiters.shift()!.resolve);
for (const resolve of due) resolve();
}
if (!finished) throw new Error("clock did not settle");
const head = await pending;
if (!head.ok) throw new Error("head failed");
return { elapsed: now, maxInFlight };
}
const on = await run(true);
const off = await run(false);
const report = {
kind: "logical-clock",
delays,
parallelElapsed: on.elapsed,
serialElapsed: off.elapsed,
parallelMaxInFlight: on.maxInFlight,
serialMaxInFlight: off.maxInFlight,
postgresMedians: "not-measured",
aliyunKey: "absent",
moderationSimulationMs: [300, 1500],
decision: {
CONSULT_PREP_PARALLEL: "off",
MODEL_VERSION_CACHE_TTL_MS: 0,
reason: "开启条件要求本机 Postgres 中位数。本轮没有量到,默认保持关闭。",
},
};
console.log(JSON.stringify(report));
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadConsultTurnHead } from "../src/lib/consult/turn-prep.ts";
type User = { id: string };
type Parsed = { sessionId: string; modelId: string };
type Session = { model_id: string | null; model_config_version: number | null; session_type: string | null };
type Model = { id: string };
function okSession(): Session {
return { model_id: "m", model_config_version: 2, session_type: "consultation" };
}
function deps(overrides: Record<string, unknown> = {}) {
const calls = { deletion: 0, session: 0, model: 0 };
const base = {
parallel: false,
getUser: async () => ({ data: { user: { id: "user-1" } as User }, error: null }),
readPendingDeletion: async () => {
calls.deletion += 1;
return null;
},
readBody: async () => ({ success: true as const, data: { sessionId: "session-1", modelId: "m" } as Parsed }),
readSession: async () => {
calls.session += 1;
return { data: okSession(), error: null };
},
resolveModel: async () => {
calls.model += 1;
return { id: "m" } as Model;
},
...overrides,
};
return { calls, deps: base };
}
async function snapshot(response: Response) {
return { status: response.status, body: await response.json() };
}
async function both(overrides: Record<string, unknown> = {}) {
const off = deps({ ...overrides, parallel: false });
const on = deps({ ...overrides, parallel: true });
const [left, right] = await Promise.all([
loadConsultTurnHead(off.deps),
loadConsultTurnHead(on.deps),
]);
assert.equal(left.ok, false);
assert.equal(right.ok, false);
if (left.ok || right.ok) return;
assert.deepEqual(await snapshot(left.response), await snapshot(right.response));
}
test("serial and parallel heads return the same status for each gate", async () => {
const anonymous = deps({
getUser: async () => ({ data: { user: null }, error: null }),
});
const missingUser = await loadConsultTurnHead({ ...anonymous.deps, parallel: false });
assert.equal(missingUser.ok, false);
if (!missingUser.ok) assert.equal(missingUser.response.status, 401);
assert.equal(anonymous.calls.deletion, 0);
await both({
readPendingDeletion: async () => Response.json({ error: "账号正在注销" }, { status: 423 }),
readBody: async () => ({ success: false as const, error: { flatten: () => ({ formErrors: ["bad"] }) } }),
});
await both({
readBody: async () => ({ success: false as const, error: { flatten: () => ({ formErrors: ["bad"] }) } }),
});
await both({ readSession: async () => ({ data: null, error: { message: "down" } }) });
await both({ readSession: async () => ({ data: null, error: null }) });
await both({
readSession: async () => ({ data: { ...okSession(), session_type: "rectification" }, error: null }),
});
await both({
readSession: async () => ({ data: { ...okSession(), model_id: "other" }, error: null }),
});
await both({ resolveModel: async () => null });
});
test("a frozen account wins over a bad request body", async () => {
const frozen = deps({
parallel: true,
readPendingDeletion: async () => Response.json({ error: "账号正在注销" }, { status: 423 }),
readBody: async () => ({ success: false as const, error: { flatten: () => ({}) } }),
});
const head = await loadConsultTurnHead(frozen.deps);
assert.equal(head.ok, false);
if (!head.ok) {
assert.equal(head.frozen, true);
assert.equal(head.response.status, 423);
}
});
test("parallel head overlaps deletion with session and model", async () => {
const delays = { deletion: 50, session: 80, model: 80 };
async function run(parallel: boolean) {
let now = 0;
const waiters: Array<{ at: number; resolve: () => void }> = [];
function sleep(ms: number) {
return new Promise<void>((resolve) => {
waiters.push({ at: now + ms, resolve });
});
}
let deletionStarted = 0;
let chainStarted = 0;
let inFlight = 0;
let maxInFlight = 0;
let finished = false;
async function occupy(ms: number, mark: () => void) {
mark();
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await sleep(ms);
inFlight -= 1;
}
const pending = loadConsultTurnHead({
parallel,
getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }),
readPendingDeletion: () => occupy(delays.deletion, () => { deletionStarted = now; }).then(() => null),
readBody: async () => ({ success: true as const, data: { sessionId: "s", modelId: "m" } }),
readSession: () => occupy(delays.session, () => { chainStarted = now; }).then(() => ({ data: okSession(), error: null })),
resolveModel: () => occupy(delays.model, () => {}).then(() => ({ id: "m" })),
}).then((head) => {
finished = true;
return head;
});
for (let guard = 0; guard < 40 && !finished; guard += 1) {
await Promise.resolve();
await Promise.resolve();
if (!waiters.length) continue;
waiters.sort((left, right) => left.at - right.at);
now = waiters[0]!.at;
const due: Array<() => void> = [];
while (waiters.length && waiters[0]!.at <= now) due.push(waiters.shift()!.resolve);
for (const resolve of due) resolve();
}
if (!finished) throw new Error("clock did not settle");
const head = await pending;
assert.equal(head.ok, true);
return { elapsed: now, deletionStarted, chainStarted, maxInFlight };
}
const on = await run(true);
const off = await run(false);
assert.ok(on.elapsed < 230, `parallel elapsed ${on.elapsed}`);
assert.equal(on.maxInFlight, 2);
assert.equal(off.maxInFlight, 1);
assert.equal(on.deletionStarted, on.chainStarted);
assert.ok(off.chainStarted >= delays.deletion);
assert.ok(off.elapsed > on.elapsed);
});
@@ -138,7 +138,7 @@ type PlanResult = Record<string, unknown> & {
async function runDomainPlan(
domains: string[],
runWorkflow: (theme: string) => ReturnType<typeof workflow>,
runWorkflow: (theme: string) => ReturnType<typeof workflow> | Promise<ReturnType<typeof workflow>>,
options: { now?: () => number; requestId?: string } = {},
) {
const state = createConsultationRuntimeState();
@@ -623,6 +623,63 @@ test("the single-value domain form stays available to callers without the model
assert.throws(() => canonicalDomainPlan({ theme: "prashna" }, {}), /unsupported_consultation_domain/);
});
test("two domains stay in plan order when concurrency is 2 and the second finishes first", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "2";
const finished: string[] = [];
try {
const { result } = await runDomainPlan(["career", "marriage"], async (theme) => {
await new Promise((resolve) => setTimeout(resolve, theme === "career" ? 40 : 5));
finished.push(theme);
return workflow(theme);
});
assert.deepEqual(finished, ["marriage", "career"]);
assert.deepEqual(result.domains, ["career", "marriage"]);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("a 429 from one parallel domain keeps workflow_rate_limited", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "2";
try {
await assert.rejects(
() => runDomainPlan(["career", "marriage"], async () => {
throw new ConsultationWorkflowError("workflow_rate_limited", "busy");
}),
(error: unknown) => {
assert.equal(consultationToolFailureCode(error), "workflow_rate_limited");
return true;
},
);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("concurrency 1 still runs domains one at a time", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "1";
let active = 0;
let maxActive = 0;
try {
await runDomainPlan(["career", "marriage"], async (theme) => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 20));
active -= 1;
return workflow(theme);
});
assert.equal(maxActive, 1);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("daily entrypoint ignores a model domain rewrite instead of executing it", async () => {
const calls: string[] = [];
const state = createConsultationRuntimeState();
@@ -157,8 +157,20 @@ test("consultation plans are server-owned and bounded", () => {
const modelToolInput = tools.slice(tools.indexOf("const consultationToolInputSchema"), tools.indexOf("const MAX_RECORDED_STEPS"));
assert.doesNotMatch(modelToolInput, /birth|latitude|longitude|engine|script|credit/i);
const prepareCall = route.slice(route.indexOf("prepared = await prepareConsultationRoute"), route.indexOf("const modelSelection"));
assert.match(prepareCall, /beforeReserve: \(\{ consultationMode \}\) => createConsultationPlan\(\{/);
assert.ok(prepareCall.indexOf("beforeReserve:") < prepareCall.indexOf("reserve: () => reserveConsultationModel("));
// 原值:beforeReserve: ({ consultationMode }) => createConsultationPlan({,且早于 reserve: () => reserveConsultationModel(
// 新值:beforeReserve 的 async 函数体里调用 createConsultationPlan({,并且整段早于 reserve;reserve 的 async 函数体里调用 reserveConsultationModel(
// 原因:两处改成 async 函数体。仍锁调用发生在对应回调里,并且 beforeReserve 在 reserve 之前
const beforeReserveAt = prepareCall.indexOf("beforeReserve:");
const reserveAt = prepareCall.indexOf("reserve: async");
assert.ok(beforeReserveAt >= 0 && reserveAt > beforeReserveAt);
assert.match(
prepareCall.slice(beforeReserveAt, reserveAt),
/beforeReserve:\s*async\s*\(\{\s*consultationMode\s*\}\)[\s\S]*createConsultationPlan\(\{/,
);
assert.match(
prepareCall.slice(reserveAt),
/reserve:\s*async\s*\(\)\s*=>[\s\S]*reserveConsultationModel\(/,
);
assert.ok(routeService.indexOf("const preReserveResult = input.beforeReserve") < routeService.indexOf("const reservation = await input.reserve()"));
assert.match(route, /plan: prepared\.preReserveResult,[\s\S]*theme: consultationTheme/);
assert.match(route, /runConsultationWorkflow\(toolInput, \{[\s\S]*plan: prepared\.preReserveResult/);
+9 -2
View File
@@ -239,8 +239,15 @@ test("a blocked answer is replaced in place and onComplete settles the notice",
test("routes check input before any charge and output before settlement", () => {
const consult = read("../src/app/api/consult/route.ts");
assert.ok(consult.indexOf('await moderate(visibleQuestion, "input"') > 0);
assert.ok(consult.indexOf('await moderate(visibleQuestion, "input"') < consult.indexOf("reserve_consultation_usage"), "input check precedes the reservation");
const prepare = read("../src/lib/consultation-route-service.ts");
// 原值: 源码含 await moderate(visibleQuestion, "input" 且位置早于 reserve_consultation_usage
// 新值: beforeReserve 里 await settleInputModeration(),而 prepare 里 beforeReserve 早于 input.reserve()
// 原因: 审核从开流前挪到流内、预留前;开关关掉时仍在开流前 await moderate
assert.match(consult, /await settleInputModeration\(\)/);
assert.ok(consult.indexOf("await settleInputModeration()") < consult.indexOf("reserve_consultation_usage"));
const beforeReserve = prepare.indexOf("input.beforeReserve");
const reserveCall = prepare.indexOf("input.reserve()");
assert.ok(beforeReserve > 0 && beforeReserve < reserveCall);
assert.equal((consult.match(/moderateOutput: moderateAnswer/g) ?? []).length, 3, "every agentic answer stream is checked");
assert.match(consult, /if \(!outputModerated\) await moderateAnswer\(reply\.text\);\n\s+if \(outputBlocked\) return await completeModeratedResponse\(usage\);/);
assert.match(consult, /complete_consultation_moderated/);
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
import { loadReportWorkerProfile } from "../src/lib/personal-report-worker-subject.ts";
import { CHART_SUBJECT_SELECT } from "../src/lib/subject-birth.ts";
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
// BUG-1063: the report worker reads another person's archive through
// service_role. Fictional rows only.
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
const SERVICE_URL_ROLE = ["service_runtime", "service-runtime-test-password"] as const;
function applyMigrations(fixture: PostgresFixture): void {
const result = spawnSync(process.execPath, [runner], {
encoding: "utf8",
env: {
...process.env,
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
},
});
assert.equal(result.status, 0, result.stderr || result.stdout);
}
test("service_role can read another person's chart profile for a report", {
skip: docker ? false : "docker unavailable",
}, async () => {
const fixture = startPostgresFixture();
const serviceUrl = fixture.connectionUrl(...SERVICE_URL_ROLE);
try {
applyMigrations(fixture);
const owner = randomUUID();
const subjectId = randomUUID();
fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at)
values ('${owner}', 'Fictional owner', '${owner}@example.invalid', true, now());`);
fixture.psql(`insert into public.chart_profiles (
id, user_id, role, profile, updated_at, name, birth_date, reported_birth_time,
birth_time_source, latitude, longitude, timezone_id, timezone_offset,
birth_place_label, ayanamsa, birth_time_status
) values (
'${subjectId}', '${owner}', 'other', '{"name":"虚构乙"}'::jsonb, now(),
'虚构乙', '1990-01-01', '08:00', 'family_exact', 22.3, 114.1,
'Asia/Shanghai', 8, '虚构港', 'lahiri', 'reported'
);`);
const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role");
const probe = await admin
.from("chart_profiles")
.select(CHART_SUBJECT_SELECT)
.eq("id", subjectId)
.maybeSingle();
assert.equal(probe.error, null, probe.error?.message ?? "chart_profiles select failed");
const loaded = await loadReportWorkerProfile(admin, owner, subjectId) as { name?: string };
assert.equal(loaded.name, "虚构乙");
for (const column of CHART_SUBJECT_SELECT.split(",")) {
assert.equal(
fixture.psql(`select has_column_privilege('service_role', 'public.chart_profiles', '${column}', 'select')`),
"t",
column,
);
}
for (const privilege of ["insert", "update", "delete"]) {
assert.equal(
fixture.psql(`select has_table_privilege('service_role', 'public.chart_profiles', '${privilege}')`),
"f",
privilege,
);
}
assert.equal(
fixture.psql("select has_table_privilege('authenticated', 'public.chart_profiles', 'select')"),
"t",
);
} finally {
await closeLocalPostgresDataPool(serviceUrl);
fixture.stop();
}
});
+11 -2
View File
@@ -45,9 +45,18 @@ test("catalog cache is short-lived and failure stays fail-closed without env fal
test("session model resolution is async and pins published or retired database versions", () => {
assert.match(catalog, /export async function resolveSessionLanguageModel/);
assert.match(catalog, /c\.model_id=\$1 and v\.version=\$2 and v\.status in \('published','retired'\)/);
assert.match(catalog, /return await readVersion\(modelId, configVersion\)/);
// 原值: return await readVersion(modelId, configVersion)
// 新值: const model = await readVersion(modelId, configVersion),只有非空结果写入短缓存
// 原因: 绑版本的解析与目录共用 15 秒上限,失败和空结果不缓存
assert.match(catalog, /const model = await readVersion\(modelId, configVersion\)/);
assert.match(catalog, /if \(model && ttl > 0\) cache\.set\(key, \{ expiresAt: Date\.now\(\) \+ ttl, model \}\)/);
assert.match(routes.consult, /await resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version/);
assert.match(routes.consult, /if \(!sessionModel\)[\s\S]*status: 503/);
// 原值: route.ts 里 if (!sessionModel) 之后是 status 503
// 新值: turn-prep.ts 里 if (!sessionModel) 返回 modelUnavailable(),正文 status 503
// 原因: 模型不可用仍是开流前的 503,文案随准备步骤一起搬走
const head = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
assert.match(head, /if \(!sessionModel\) return \{ ok: false, response: modelUnavailable\(\) \}/);
assert.match(head, /function modelUnavailable\(\)[\s\S]*status: 503/);
assert.doesNotMatch(routes.consult, /languageModelConfigurationMessage/);
});
@@ -18,7 +18,6 @@ import {
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const run = read("../src/hooks/use-consultation-run.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const consultRoute = read("../src/app/api/consult/route.ts");
const pageSource = read("../src/app/(app)/page.tsx");
const sendFn = run.slice(run.indexOf("async function send("), run.indexOf('await fetch("/api/consult"'));
@@ -50,13 +49,16 @@ test("pending consult replay pointing at a rectification session is dropped", ()
});
test("consult POST splits missing session from wrong session type", () => {
assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*咨询会话不存在[\s\S]*status: 404/);
assert.match(
consultRoute,
/if \(chatSession\.session_type !== "consultation"\) \{[\s\S]*session_not_consultation[\s\S]*status: 409/,
);
assert.match(consultRoute, /这是生时校正会话/);
assert.match(consultRoute, /请在生时校正里继续。/);
const head = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
// 原值: consult route 里 if (!chatSession) 返回 404,类型不对返回 session_not_consultation。
// 新值: 同样两句在 turn-prep.ts,route 把读会话的结果交给它。
// 原因: 开流前的会话判断收进准备函数,文案和状态码没变。
assert.match(head, /if \(!session\.data\) return \{ ok: false, response: sessionMissing\(\) \}/);
assert.match(head, /function sessionMissing\(\)[\s\S]*咨询会话不存在[\s\S]*status: 404/);
assert.match(head, /function notConsultation\(\)[\s\S]*session_not_consultation[\s\S]*status: 409/);
assert.match(head, /session_type !== "consultation"/);
assert.match(head, /这是生时校正会话/);
assert.match(head, /请在生时校正里继续。/);
assert.equal(SESSION_NOT_CONSULTATION_CODE, "session_not_consultation");
assert.match(run, /caught\.code === SESSION_NOT_CONSULTATION_CODE/);
});
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,8 @@
const empty = new URL("./server-only-empty.mjs", import.meta.url).href;
export async function resolve(specifier, context, nextResolve) {
if (specifier === "server-only") {
return { url: empty, shortCircuit: true };
}
return nextResolve(specifier, context);
}