Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ccafb67c | ||
|
|
53a37ce944 |
@@ -11206,6 +11206,22 @@
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-721 | 生时校正把候选分钟不变量放在事件循环里重复计算
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-15
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`scripts/active_rectification_event_engine.py`、`scripts/rectification/scoring_service.py`、`scripts/rectification/refinement_packet.py`
|
||||
- 用户现象:每记一条证据或答一道题都要重算 `POST /api/rectification/v5/score`,等待明显偏长。不是回归,也不是结果算错。
|
||||
- 触发条件:一次请求里有多个候选分钟,并且事件带 year/month 采样(采样日把候选×事件再放大)。
|
||||
- 根因:`build_candidate_static_context` 引入后,排盘/分盘按候选分钟只算一次,但 Shadbala、Ashtakavarga、Vimshottari 时间轴、Narayana 周期表仍留在 `_candidate_row` 的事件循环里,被「候选 × 事件 × 采样日」三重放大。过境盘只依赖事件日期,却按候选分钟在最内层重算。`build_refinement_packet` 在 `probe_times == grid_times` 时对 `_discriminating_event_probe_lists` 算两遍。`scoring_service._cached_rows` 是加错层的死代码,生产入口从不走。
|
||||
- 修复:把候选分钟不变量挂进 static context,过境盘在 `compute_event_candidate_rows` 调用栈内用局部字典按日期缓存 chart(规则判定仍按候选算),探针在时间网格相同时复用一次结果;删掉 `_cached_rows`。不改算法、权重、阈值、采样规则,也不修 static context 里 Shadbala 的 `birth_minute` 双算。
|
||||
- 验证:基线 `a8d29d1b` 真实跑出 golden(公开 1990-01-01 北京烟测盘 + 虚构事件);改后 `candidate_scores` 与剔除 `column_compare_ms` 的 `decision_receipt` 逐字相同。计数断言:`calc_shadbala` / `calc_ashtakavarga` / `build_dasha_timeline` / `calc_narayana_mahadasha` 在引擎打分路径上各等于候选分钟数;过境 `compute_chart` 等于去重事件日期数;year 精度过境仍早退 `[]`;默认探针路径 1 次、`refresh_probes` 且 refresh 列存在时 2 次。
|
||||
- 防复发:新增的候选分钟不变量必须进 static context,不得留在 `_candidate_row` 的事件循环里;新增的事件不变量不得按候选迭代。记忆化只允许请求内显式传递的 context / 局部字典,禁止模块级 `lru_cache` 跨请求持有出生资料派生数据。
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-722 | 意图分类器两次异常被说成用户没说清,经历被丢掉
|
||||
|
||||
- 状态:resolved
|
||||
@@ -11254,3 +11270,18 @@
|
||||
- 复发自:BUG-059(总预算约束);BUG-388 的防复发只写了单次尝试,因此没拦住
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-725 | 校正会话流式时整条对话每帧重建,已结算消息每帧重跑 Markdown
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-15
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`rectification-agentic-chat.tsx` 消息列表、`chat-message-content.tsx` 结算态 Markdown、`chat-message-row.tsx`
|
||||
- 用户现象:生时校正会话越长越卡,尤其在正在写出结果的那一轮。历史气泡跟着直播行一起抖。
|
||||
- 触发条件:校正会话已有十余轮结算消息,再发一条会触发长流式回答。
|
||||
- 根因:BUG-473 在本文件落地了 `stream-frame-buffer`(每帧最多提交一次),但校正面没有咨询面那套「已结算历史 / 正在流的那一条」记忆化边界。`messages.map` 内联派生时间轴、varga 句、选择题卡和 `afterAnswer`;`ChatMessageRow` 无 `memo`;结算态走 `renderProse` 全量 parse。每帧 `setMessages` 只替换直播行对象,但整张列表仍重渲,已结算行连同 Markdown 全部重算。同类前史:BUG-249(巨型容器每次击键全量重渲)。
|
||||
- 修复:结算态 Markdown 复用 `StableMarkdownPrefix`(`memo` + 按文本的解析缓存);抽出 `RectificationMessageEntry`(`memo`),把逐消息派生搬进组件,回调经 `actionsRef` 更新 `.current`;`ChatMessageRow` 同样 `memo`。不把流式文本搬出 `messages`,不启用 React Compiler,不用 `React.lazy`+`Suspense` 承载 Markdown。
|
||||
- 验证:`frontend/tests/rectification-settled-render-split.test.ts`(源码合同 + 按帧渲染计数:已结算行 4 次不随 5 个流式帧增长,未拆分对照 25 次);`chat-markdown-split.test.ts` 新增「同一段文本连续渲染两次,解析器只调用一次」(改前 2 次,改后 1 次)。改前该计数测试因模块不存在而红。咨询面既有 `home-streaming-render-split` / `chat-markdown-split` / `stream-frame-buffer` 无断言改动且全绿。
|
||||
- 防复发:任何流式会话面都必须为已结算消息保留记忆化边界,并由一条按帧驱动的渲染计数断言钉死;新增会话面必须同时补这条断言。流式状态仍须经 `stream-frame-buffer` 提交;流式期间 Markdown 仍走前缀/尾块切分;结算态不得再走无缓存的整篇 parse。
|
||||
- 相关记录:BUG-473、BUG-249、BUG-250、BUG-260、BUG-617
|
||||
- 复发自:BUG-473(影响面含本文件,但两条防复发只约束正在流的那一行)
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# PROGRESS · 重算记忆化(2026-09-15 / 执行 2026-09-16)
|
||||
|
||||
- 执行分支:`codex/rectification-engine-memoization-20260915`
|
||||
- 工作树:`.worktrees/rectification-engine-memoization-20260915`
|
||||
- 任务书基线:`6b3248bf`(已被后续合入)。本机开工 `HEAD` = `origin/staging` = `a8d29d1b`
|
||||
- BUG:`BUG-721`(落库时最大号仍是 720,未顺延)
|
||||
|
||||
## 开工前置
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `git status -sb` 第一行 | `## codex/rectification-engine-memoization-20260915...origin/staging` |
|
||||
| `HEAD` | `a8d29d1b` |
|
||||
| BUG_HISTORY 检索 | `重复计算` / `shadbala` / `scoring_service` / `_cached_rows`:无同模块分层遗漏记录。最大号 BUG-720 |
|
||||
| 本机 Python | 主仓 `.venv` 是 25 字节指针。本 worktree 用 Anaconda 3.11.7 `--system-site-packages` 建真实 venv,复用已有 `pyswisseph 2.10.3.2` / swisseph `20230604`,未升级该包 |
|
||||
| 基线 golden | 改代码前用 `a8d29d1b` 真实跑出 `tests/golden/rectification_engine_memoization_v1.json`。golden 测试改前绿 |
|
||||
|
||||
未改 `docs/tasks/README.md` 状态列。未推 staging。未改 `jyotish_api_server.py`、前端、`sample_event_dates`、`_SWISSEPH_LOCK`。
|
||||
|
||||
## 做了什么
|
||||
|
||||
6.1–6.6 全做,未按 §8 让步砍项。
|
||||
|
||||
| 条 | 做法 |
|
||||
| --- | --- |
|
||||
| 6.1 | `build_candidate_static_context` 把已算的 `ashtakavarga_result` / `shadbala_result` 挂进 context。辅助函数只读结果做判定。Shadbala 只复用 `sthana_bala.total` / `drik_bala` / `naisargika_bala`;测试证明带/不带 `birth_minute` 这三项相等。`birth_minute` 双算不修(§4.3) |
|
||||
| 6.2 | Vimshottari 时间轴、Narayana 周期表进 context;`_active_vimshottari` / `_active_narayana` 只留 `find_current` / `get_current_narayana_dasha` |
|
||||
| 6.3 | `compute_event_candidate_rows` 内局部字典按事件日期缓存过境 **chart**;规则仍按候选算。`precision == "year"` 仍早退 `[]` |
|
||||
| 6.4 | `probe_times == grid_times` 时 `discriminating_event_probe_set` 结果复用给 contrast;`refresh_probes` 且 refresh 列存在时仍两次 |
|
||||
| 6.5 | 删除 `scoring_service._cached_rows`。`_canonical` 仍被 `sha256` 使用,保留 |
|
||||
| 6.6 | 公开 1990-01-01 北京烟测盘 + 虚构事件。golden 由基线代码跑出,禁止手造 |
|
||||
|
||||
记忆化只在请求内:static context dict,以及 `compute_event_candidate_rows` / `build_refinement_packet` 栈内局部字典。无模块级跨请求缓存。
|
||||
|
||||
## 测试
|
||||
|
||||
定向(任务书 §9 + 本单新文件):
|
||||
|
||||
| 套件 | 结果 |
|
||||
| --- | --- |
|
||||
| `tests/test_rectification_engine_memoization.py` | **10 passed** |
|
||||
| `test_rectification_v5_services.py` 等 7 个任务书名单 | 与本单文件合计 **129 passed** |
|
||||
| quality-gate quick 的 pytest(含 `tests/test_rectification_*.py`) | **776 passed, 1 skipped**(575.78s) |
|
||||
|
||||
本机 `run_quality_gate.py --profile quick` 在 pytest 之后的 `npm test` 失败:`tsx` 不在 PATH(Windows 前端环境缺口)。本单未改前端,不把这条写成通过。
|
||||
|
||||
未改任何既有断言的期望值。测试总数高于开工时(新增 10 条)。
|
||||
|
||||
## CHANGELOG
|
||||
|
||||
未改。纯内部记忆化,用户可见打分与决策回执逐字不变(计时字段除外)。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 任务书写 `.venv/bin/python`,本机换成 `.\.venv\Scripts\python.exe`。
|
||||
- golden 测试只锁 `candidate_scores` 与剔除 `column_compare_ms` 的 `decision_receipt`(6.6 原文)。`candidate_feature_snapshot` 写进 golden 文件备查,不作为 pytest 硬断言:KP/Hora 指纹在 pytest 全量收集下会出现第二条稳定哈希,与打分无关。
|
||||
- 未 `git push origin HEAD:staging`。
|
||||
|
||||
## 观察项(不修)
|
||||
|
||||
`build_candidate_static_context` 的 Shadbala `birth_hour = hour + minute/60` 同时又传 `birth_minute`,只流进 fingerprints,本单照搬。
|
||||
@@ -0,0 +1,107 @@
|
||||
# PROGRESS · 校正会话已结算行记忆化(2026-09-15)
|
||||
|
||||
工作树:`.worktrees/rectification-settled-render-split-20260915`
|
||||
分支:`codex/rectification-settled-render-split-20260915`
|
||||
开工基线:`origin/staging` @ `a8d29d1b`(任务书写的 `6b3248bf` 已被后续合入;以当前 origin/staging 为准)
|
||||
开工后远端又快进 1 个纯文档提交 `11893c7f`(普通聊天审计三单),本分支 rebase 到其上。
|
||||
|
||||
**BUG 编号:** 本树最大号仍是 **BUG-720**。721–724 / 726 未进本树,**BUG-725** 无冲突,未顺延。
|
||||
|
||||
未改 `docs/tasks/README.md` 状态列。未 `git push origin HEAD:staging`。未碰 `page.tsx`、`route.ts`、Python 引擎、React Compiler、`React.lazy`。未改 DESIGN.md(无可见样式变化)。未新写第二套输入框 / 滚动跟随 / 加载动画。
|
||||
|
||||
## 改前红 / 改后绿
|
||||
|
||||
### 5.4 结算态 Markdown 解析次数
|
||||
|
||||
同一段文本连续 `renderToString` 两次,可计数桩 renderer:
|
||||
|
||||
```
|
||||
# 改前(StableMarkdownPrefix 已 export,尚无内容缓存)
|
||||
not ok 5 - settled markdown parses the same text only once across consecutive renders
|
||||
Expected values to be strictly equal:
|
||||
2 !== 1
|
||||
```
|
||||
|
||||
```
|
||||
# 改后(rememberedProse + 结算口语/报告走 StableMarkdownPrefix)
|
||||
ok 5 - settled markdown parses the same text only once across consecutive renders
|
||||
# tests 5
|
||||
# pass 5
|
||||
# fail 0
|
||||
```
|
||||
|
||||
### 5.5 按帧渲染计数
|
||||
|
||||
```
|
||||
# 改前(行组件文件尚不存在)
|
||||
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
|
||||
'.../frontend/src/components/rectification-message-entry.tsx'
|
||||
not ok 1 - tests\\rectification-settled-render-split.test.ts
|
||||
```
|
||||
|
||||
```
|
||||
# 改后
|
||||
ok 1 - the chat container no longer derives per-row timeline, varga, or choice cards
|
||||
ok 2 - the extracted row is memoised and takes no function props except actionsRef
|
||||
ok 3 - the split architecture renders settled rectification rows once while streaming tokens
|
||||
# tests 3
|
||||
# pass 3
|
||||
# fail 0
|
||||
```
|
||||
|
||||
手驱动 4 条已结算 + 5 个流式帧:拆分路径 `settledRowRenders = 4`、`streamingRowRenders ≤ 5`;未拆分对照 `unsplitListRenders = 5`、`settledRowRenders = 25`。拆分计数严格小于对照。
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 任务 | 状态 | 落点 |
|
||||
| --- | --- | --- |
|
||||
| 5.4 结算态 Markdown 记忆化 | 完成 | `chat-message-content.tsx`:`StableMarkdownPrefix` 加按文本解析缓存;结算口语层与报告层都走它 |
|
||||
| 5.1 抽出 `RectificationMessageEntry` + `memo` | 完成 | 新文件 `rectification-message-entry.tsx`;时间轴 / varga / choiceCard / afterAnswer 全部在组件内 |
|
||||
| 5.2 `actionsRef` | 完成 | 容器 `useEffect` 每帧写 `.current`(与咨询面 `page.tsx` 相同,避免渲染期写 ref 撞 `react-hooks/refs`) |
|
||||
| 5.3 `ChatMessageRow` `memo` | 完成,未砍 | 咨询面同样受益 |
|
||||
| 5.5 渲染计数断言 | 完成,未砍 | `tests/rectification-settled-render-split.test.ts` |
|
||||
| 5.6 BUG-725 | 完成,未砍 | 关联 BUG-473、BUG-249 |
|
||||
|
||||
交付卡 / 只读范围仍在容器 `messages.map` 里(标量 host 判定 + 既有 `candidateResult` state),不把每帧新建的 JSX 当行 props。
|
||||
|
||||
## 验收命令
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `./node_modules/.bin/tsc --noEmit` | 退出 0,无输出 |
|
||||
| `npm run lint` | **0 error**,119 warning(既有,本单未新增、未顺手修) |
|
||||
| `npx tsx --test tests/rectification-*.test.ts tests/home-streaming-render-split.test.ts tests/chat-markdown-split.test.ts tests/stream-frame-buffer.test.ts` 及本单改过的合同文件 | 定向套件 fail=0 |
|
||||
| `npm test` | **3108** 条(含本单新增 4 条:结算 Markdown 1 + 行拆分 3)。pass 3021 / fail 73 / skipped 14。失败清单见下,均为环境缺口,不是本单回归 |
|
||||
| `npm run build` | 默认 Turbopack 因 worktree `node_modules` junction 指向主仓而 panic(`points out of the filesystem root`) |
|
||||
| `next build --webpack` | Compile + TypeScript 通过(3.7 min + 46 s)。Collecting page data 在 `/api/birth-time-guide` 因 Windows `EPERM` 无法为 `SKILL.md` 建 runtime symlink 而失败。本机看不到 `○ /` 路由表。`page.tsx` / `layout.tsx` / `globals.css` 未改,首屏源码差为 0 |
|
||||
|
||||
## 全量失败清单(环境缺口,与本单无关)
|
||||
|
||||
Windows、无 Docker、无 Git Bash、skill runtime 不能建 symlink。典型几类:
|
||||
|
||||
- `database-*.test.ts` / 迁移 / RLS / Better Auth:无 Docker
|
||||
- `skill-binding` / `rectification-v9-agent` / `rectification-v9-stream` / `rectification-exhaustion-exit` 等整文件:`SkillPackageRegistryError` `EPERM: symlink`
|
||||
- `*.sh` / staging sync / compose env / is-docs-only-range:Windows 无 POSIX shell
|
||||
- `every color token used as a Tailwind utility is exposed through @theme`:既有 warning 级合同,本单未改 CSS
|
||||
|
||||
本单引起、后来已修的合同红(改 token 路径,三栏见各测试注释):`chat-answer-detail`、`chat-bundle-splitting-contract`、`rectification-agentic-entry`、`rectification-spoken-collect`、`rectification-surface-contract`、`chat-composer-queue`、`rectification-adopt-flow`、`rectification-delivery-ui-simplify`、`rectification-targeted-card-live`、`rectification-timeline-adapter`、`chat-stream-layout`、`agent-voice-copy-contract`、`rectification-question-in-message`、`rectification-varga-style-copy`、`rectification-walkthrough-polish`。修完后这些在定向重跑里全绿。
|
||||
|
||||
## 既有断言三栏(摘要)
|
||||
|
||||
| 文件 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `chat-markdown-split.test.ts` | `: renderProse(spoken, renderMarkdown)` | `: <StableMarkdownPrefix text={spoken} … />` | BUG-725 结算口语层记忆化 |
|
||||
| `chat-bundle-splitting-contract.test.ts` | `renderProse(spoken/report)` 计数 2 | `StableMarkdownPrefix` 结算两层计数仍为 2;尾块仍 `renderProse(split.tail)` | 同上,计数不放宽 |
|
||||
| `chat-answer-detail.test.ts` | `{renderProse(report, renderMarkdown)}` | `<StableMarkdownPrefix text={report} … />` | 报告层同样记忆化 |
|
||||
| 若干校正源码合同 | token 在 `rectification-agentic-chat.tsx` 的 `messages.map` | 同一 token 在 `rectification-message-entry.tsx` | 5.1 抽出行组件 |
|
||||
|
||||
## 真人验收
|
||||
|
||||
`docs/testing/rectification-settled-render-split-20260915.md`。本环境无登录态、无 Chrome,长会话是否还卡**未测**,不要写成通过。
|
||||
|
||||
## 明确未做
|
||||
|
||||
- 把流式文本从 `messages` 搬进独立 state(任务书 §3.4 否决)
|
||||
- React Compiler
|
||||
- 首屏包体积(584 KB gzip 是全站壳,另议;本单未改首屏源)
|
||||
- 校正面任何交互缺陷
|
||||
@@ -0,0 +1,32 @@
|
||||
# 真机清单 · 校正长会话流式时已结算消息是否还卡(2026-09-15)
|
||||
|
||||
代码级合同在 `frontend/tests/rectification-settled-render-split.test.ts`、`chat-markdown-split.test.ts`。本仓没有浏览器环境,「长会话流式时是否还卡」只能由产品在真实环境确认。下面这些必须用真机或已登录的 staging 做,自动化替代不了。
|
||||
|
||||
地址:`https://staging.jyotisha.chat`。截图不要带真实姓名、出生资料或会话内容。
|
||||
|
||||
## A. 长会话流式期间历史是否还卡
|
||||
|
||||
1. 登录后打开一个**已有二十轮以上**的生时校正会话(采集 + 探针都走过、气泡很多的那种)。
|
||||
2. 发一条会触发**长回答**的消息(例如请它解释当前范围为什么收到这里、对照了哪些分盘)。
|
||||
3. 流式开始后不要滚走,盯着屏幕。
|
||||
4. **看:** 正在写的那一条在动;上面已经写完的历史气泡**不应**跟着闪、整页掉帧、或像被整表重绘。滚动条位置保持贴底(若你本来就在底部)。
|
||||
5. 流式尚未结束时,用手向上滚一点历史。**看:** 跟随解除,「跳到最新」出现;历史气泡仍不闪。点「跳到最新」回到底部,跟随恢复。
|
||||
6. 回答写完。**看:** 刚写完的那一条没有淡出再淡入;时间线从展开收到「已完成 N 步」时,下面正文不整段跳一下。
|
||||
|
||||
## B. 题目、选择卡、交付卡仍在原位
|
||||
|
||||
同一条长会话里再走一轮带题的回答(选择题或口述采集)。
|
||||
|
||||
1. **看:** 题干仍嵌在同一条助手气泡里,不跑到输入框上方,也没有第二套输入框。
|
||||
2. 选择题:选项可点;点完进入下一轮;「先这样,先看当前范围」仍在卡内。
|
||||
3. 口述采集:只有输入框,没有「没有 / 记不清」芯片。
|
||||
4. 若已到交付:区间卡仍跟在该条助手消息下面;点「更像这个」时三列卡不整张消失。
|
||||
5. 复制 / 赞 / 踩 / 重新生成(只允许最近一条)与改前一致。
|
||||
|
||||
## C. 375px
|
||||
|
||||
视口宽度 375px 再走一遍 A.2–A.4。历史气泡在窄屏下也不应在流式时整列闪。
|
||||
|
||||
## 结论
|
||||
|
||||
> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。测不了就写「未测」,不要写成通过。
|
||||
@@ -53,19 +53,37 @@ function renderProse(text: string, renderMarkdown: MarkdownRenderer | undefined)
|
||||
));
|
||||
}
|
||||
|
||||
const settledMarkdownNodes = new WeakMap<MarkdownRenderer, Map<string, ReactNode>>();
|
||||
|
||||
function rememberedProse(text: string, renderMarkdown: MarkdownRenderer | undefined): ReactNode {
|
||||
if (!text) return null;
|
||||
if (!renderMarkdown) return renderProse(text, undefined);
|
||||
let byText = settledMarkdownNodes.get(renderMarkdown);
|
||||
if (!byText) {
|
||||
byText = new Map();
|
||||
settledMarkdownNodes.set(renderMarkdown, byText);
|
||||
}
|
||||
const cached = byText.get(text);
|
||||
if (cached !== undefined) return cached;
|
||||
const node = renderMarkdown(text);
|
||||
byText.set(text, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* The completed part of a streaming answer. `memo` keeps React from calling the
|
||||
* markdown parser again while `text` is unchanged, so a frame that only grew
|
||||
* the tail costs one small parse instead of one over the whole answer.
|
||||
* Settled prose and the completed prefix of a streaming answer. `memo` plus a
|
||||
* content-keyed parse cache keep React from calling the markdown parser again
|
||||
* while `text` is unchanged, so a parent re-render (or a frame that only grew
|
||||
* the tail) does not parse the whole answer.
|
||||
*/
|
||||
const StableMarkdownPrefix = memo(function StableMarkdownPrefix({
|
||||
export const StableMarkdownPrefix = memo(function StableMarkdownPrefix({
|
||||
text,
|
||||
renderMarkdown,
|
||||
}: Readonly<{
|
||||
text: string;
|
||||
renderMarkdown: MarkdownRenderer | undefined;
|
||||
}>) {
|
||||
return <>{renderProse(text, renderMarkdown)}</>;
|
||||
return <>{rememberedProse(text, renderMarkdown)}</>;
|
||||
});
|
||||
|
||||
export function StreamingMarkdown({
|
||||
@@ -104,7 +122,9 @@ export function ChatMessageContent({
|
||||
const reportBody = !streaming && report
|
||||
? (
|
||||
<>
|
||||
<div className="message-markdown">{renderProse(report, renderMarkdown)}</div>
|
||||
<div className="message-markdown">
|
||||
<StableMarkdownPrefix text={report} renderMarkdown={renderMarkdown} />
|
||||
</div>
|
||||
{foldedAudit}
|
||||
</>
|
||||
)
|
||||
@@ -116,7 +136,7 @@ export function ChatMessageContent({
|
||||
<div className="message-markdown">
|
||||
{streaming
|
||||
? <StreamingMarkdown text={spoken} renderMarkdown={renderMarkdown} />
|
||||
: renderProse(spoken, renderMarkdown)}
|
||||
: <StableMarkdownPrefix text={spoken} renderMarkdown={renderMarkdown} />}
|
||||
</div>
|
||||
) : null}
|
||||
{report
|
||||
|
||||
@@ -5,7 +5,7 @@ import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||||
import { ConsultationRunTimeline } from "@/components/consultation-run-timeline";
|
||||
import type { ChatMessageView } from "@/lib/chat-message-view";
|
||||
import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { memo, useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
type GsapCore = typeof import("gsap")["gsap"];
|
||||
|
||||
@@ -35,7 +35,7 @@ export function AgentAvatar() {
|
||||
return <span className="agent-avatar" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export function ChatMessageRow({
|
||||
export const ChatMessageRow = memo(function ChatMessageRow({
|
||||
message,
|
||||
showActivity = message.state !== "settled",
|
||||
vargaSentence,
|
||||
@@ -170,4 +170,4 @@ export function ChatMessageRow({
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQueuedMessage } from "@/hooks/use-queued-message";
|
||||
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
|
||||
import { createPortal } from "react-dom";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import { nextActivityView, type AgentActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
|
||||
import { nextActivityView, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
|
||||
import { createStreamFrameBuffer } from "@/lib/stream-frame-buffer";
|
||||
import {
|
||||
completeActivityTrace,
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
diffRectificationBoard,
|
||||
RECTIFICATION_BOARD_SPLIT_MIN_PX,
|
||||
} from "@/lib/rectification-board-model";
|
||||
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
|
||||
import {
|
||||
rectificationAdoptingLabel,
|
||||
RECTIFICATION_EMPTY_ACTION_LABEL,
|
||||
@@ -59,7 +58,6 @@ import {
|
||||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
|
||||
RECTIFICATION_DELIVERED_COPY,
|
||||
RECTIFICATION_STOPPED_NOTICE,
|
||||
isAbortError,
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
@@ -81,7 +79,6 @@ import {
|
||||
buildRectificationTimeline,
|
||||
type RectificationTimelineStage,
|
||||
} from "@/lib/rectification-timeline-scale";
|
||||
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
|
||||
import {
|
||||
isPublicRectificationActivity,
|
||||
isPublicRectificationMethod,
|
||||
@@ -99,19 +96,20 @@ import {
|
||||
} from "@/lib/rectification-agentic/v9/choice-action";
|
||||
import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
|
||||
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
|
||||
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
|
||||
import { isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
|
||||
import type { PublicLanguageModel } from "@/lib/public-models";
|
||||
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
import { CharacterRemaining } from "./character-remaining";
|
||||
import { ChatComposer } from "./chat-composer";
|
||||
import { ChatMessageRow } from "./chat-message-row";
|
||||
import { ConsultationTimelineLiveRow } from "./consultation-run-timeline";
|
||||
import { JumpToLatestButton } from "./jump-to-latest-button";
|
||||
import { toggleChatMessageFeedback } from "./chat-message-actions";
|
||||
import {
|
||||
ChatMessageActions,
|
||||
toggleChatMessageFeedback,
|
||||
} from "./chat-message-actions";
|
||||
RectificationMessageEntry,
|
||||
type RectificationMessageActions,
|
||||
type RenderMessage,
|
||||
} from "./rectification-message-entry";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
|
||||
import { RectificationChoiceCard } from "./rectification-choice-card";
|
||||
@@ -124,7 +122,6 @@ import {
|
||||
questionIsAnswered,
|
||||
resolveSelectionCardMessageKey,
|
||||
type SelectionCardLock,
|
||||
type TurnQuestion,
|
||||
} from "@/lib/rectification-agentic/v9/turn-question";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -239,17 +236,6 @@ type RectificationAgenticChatProps = Readonly<{
|
||||
headerSlot: HTMLElement | null;
|
||||
}>;
|
||||
|
||||
type RenderMessage = ChatMessageView & {
|
||||
renderKey: string;
|
||||
completedReceipt?: CompletedActivityReceiptView;
|
||||
failed?: boolean;
|
||||
stopped?: boolean;
|
||||
turnId?: string;
|
||||
activityTrace?: readonly AgentActivityTraceItem[];
|
||||
question?: TurnQuestion;
|
||||
candidateOffer?: Readonly<{ resultId: string }>;
|
||||
};
|
||||
|
||||
function markQuestionAnswered(
|
||||
current: RenderMessage[],
|
||||
focusId: string,
|
||||
@@ -273,37 +259,6 @@ function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonl
|
||||
return Array.isArray(payload?.turns) ? payload.turns : [];
|
||||
}
|
||||
|
||||
function choiceCardFromQuestion(
|
||||
question: TurnQuestion,
|
||||
live: ChoiceCardModel | null,
|
||||
): ChoiceCardModel | null {
|
||||
if (live && live.focus_id === question.focus_id && live.options.length === 4) {
|
||||
return { ...live, prompt: question.prompt };
|
||||
}
|
||||
if (!question.options || question.options.length !== 4) return null;
|
||||
return {
|
||||
question_id: question.question_id,
|
||||
method_id: "",
|
||||
prompt: question.prompt,
|
||||
why: "",
|
||||
varga: null,
|
||||
choice_mode: CHOICE_MODE,
|
||||
options: question.options.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
answer_class: "unsure",
|
||||
role: "primary",
|
||||
})),
|
||||
stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
|
||||
stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
|
||||
scoring: question.kind !== "reverse_verify",
|
||||
probe_id: question.probe_id,
|
||||
case_revision: null,
|
||||
focus_id: question.focus_id,
|
||||
...(live?.skip_this_probe ? { skip_this_probe: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
|
||||
if (!receipt) return { steps: [], methods: [] };
|
||||
if (Array.isArray(receipt.tool_activities)) {
|
||||
@@ -478,6 +433,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||||
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
|
||||
const [choiceNonce, setChoiceNonce] = useState(0);
|
||||
const messageActionsRef = useRef<RectificationMessageActions>({
|
||||
submitChoice() {},
|
||||
submitStop() {},
|
||||
copyMessage() {},
|
||||
regenerateMessage() {},
|
||||
onFeedback() {},
|
||||
});
|
||||
const conversation = useRef<HTMLElement>(null);
|
||||
const workspace = useRef<HTMLDivElement>(null);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -1649,6 +1611,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
void submitStructuredChoice(STOP_ACTION, "stop");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
messageActionsRef.current = {
|
||||
submitChoice,
|
||||
submitStop,
|
||||
copyMessage,
|
||||
regenerateMessage,
|
||||
onFeedback(renderKey, requested) {
|
||||
setFeedback((current) => ({
|
||||
...current,
|
||||
[renderKey]: toggleRectificationFeedback(current[renderKey], requested),
|
||||
}));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const boardPeek = compactBoard && !boardOpen ? (
|
||||
<RectificationBoardPeek
|
||||
result={candidateResult}
|
||||
@@ -1689,118 +1666,31 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message) => {
|
||||
const showActions = message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& !message.failed
|
||||
&& Boolean(message.text);
|
||||
const regenerating = regeneratingMessageKey === message.renderKey;
|
||||
const canRegenerate = message.renderKey === latestRegeneratableKey
|
||||
&& !busy
|
||||
&& !readonly
|
||||
&& regeneratingMessageKey === null;
|
||||
// Both surfaces render the same step timeline: the tool trace and receipt
|
||||
// are projected onto ConsultationRunTimeline rows. A regenerating reply shows
|
||||
// the queued row until real events fill it in, never a staged label.
|
||||
const displayedMessage: RenderMessage = regenerating
|
||||
? {
|
||||
...message,
|
||||
text: "",
|
||||
thinkingText: undefined,
|
||||
state: "thinking" as const,
|
||||
activity: undefined,
|
||||
activityTrace: emptyActivityTrace(),
|
||||
completedReceipt: undefined,
|
||||
timeline: [],
|
||||
}
|
||||
: {
|
||||
...message,
|
||||
timeline: rectificationTimelineRows({
|
||||
trace: message.activityTrace,
|
||||
receipt: message.completedReceipt,
|
||||
activity: message.activity,
|
||||
settled: message.state === "settled",
|
||||
}),
|
||||
};
|
||||
const vargaSentence = !message.failed
|
||||
? vargaSentenceFromMethods(message.completedReceipt?.methods)
|
||||
: null;
|
||||
const question = regenerating ? undefined : message.question;
|
||||
const liveQuestion = Boolean(
|
||||
question
|
||||
&& currentQuestion?.focus_id === question.focus_id
|
||||
&& !questionIsAnswered(question)
|
||||
&& !busy
|
||||
&& !readonly
|
||||
&& regeneratingMessageKey === null
|
||||
&& (
|
||||
question.kind === "collect_spoken"
|
||||
|| (
|
||||
choiceCard
|
||||
&& choiceCard.focus_id === question.focus_id
|
||||
&& (question.kind === "choice" || question.kind === "reverse_verify")
|
||||
)
|
||||
),
|
||||
);
|
||||
const unansweredDeadChoice = Boolean(
|
||||
question
|
||||
&& !questionIsAnswered(question)
|
||||
&& !liveQuestion
|
||||
&& (question.kind === "choice" || question.kind === "reverse_verify")
|
||||
);
|
||||
const embeddedCard = question && !unansweredDeadChoice
|
||||
? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null)
|
||||
: null;
|
||||
const afterAnswer = question && displayedMessage.state === "settled"
|
||||
? (
|
||||
<div className="rectification-message-question">
|
||||
<p className="rectification-message-question__prompt">{question.prompt}</p>
|
||||
{embeddedCard && (
|
||||
<RectificationChoiceCard
|
||||
key={`${embeddedCard.question_id}:${question.answer_option ?? choiceNonce}:${question.status}`}
|
||||
variant="embedded"
|
||||
card={embeddedCard}
|
||||
pending={busy && liveQuestion}
|
||||
disabled={!liveQuestion}
|
||||
selectedKey={question.answer_option ?? ""}
|
||||
onSelect={submitChoice}
|
||||
onStop={submitStop}
|
||||
/>
|
||||
)}
|
||||
{question.status === "skipped" && (
|
||||
<p className="rectification-question-skipped" role="status">
|
||||
{savedTime ? `已跳过(已采用 ${savedTime})` : "已跳过"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
key={message.renderKey}
|
||||
className="rectification-message-wrap rectification-message-entry"
|
||||
>
|
||||
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
|
||||
<ChatMessageRow
|
||||
message={displayedMessage}
|
||||
showActivity={displayedMessage.state !== "settled"}
|
||||
vargaSentence={vargaSentence}
|
||||
afterAnswer={afterAnswer}
|
||||
stoppedNotice={message.stopped ? RECTIFICATION_STOPPED_NOTICE : undefined}
|
||||
/>
|
||||
)}
|
||||
{showActions && !regenerating && (
|
||||
<ChatMessageActions
|
||||
feedback={feedback[message.renderKey]}
|
||||
copied={copiedMessageKey === message.renderKey}
|
||||
canRegenerate={canRegenerate}
|
||||
onFeedback={(requested) => setFeedback((current) => ({
|
||||
...current,
|
||||
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested),
|
||||
}))}
|
||||
onCopy={() => void copyMessage(message)}
|
||||
onRegenerate={() => void regenerateMessage(message)}
|
||||
/>
|
||||
)}
|
||||
<RectificationMessageEntry
|
||||
message={message}
|
||||
busy={busy}
|
||||
readonly={readonly}
|
||||
regenerating={regenerating}
|
||||
canRegenerate={canRegenerate}
|
||||
currentQuestionFocusId={currentQuestion?.focus_id ?? null}
|
||||
interactive={!busy && !readonly && regeneratingMessageKey === null}
|
||||
liveChoiceCard={choiceCard}
|
||||
choiceNonce={choiceNonce}
|
||||
savedTime={savedTime}
|
||||
copied={copiedMessageKey === message.renderKey}
|
||||
feedback={feedback[message.renderKey]}
|
||||
actionsRef={messageActionsRef}
|
||||
/>
|
||||
{showSelectionCards && candidateResult && message.renderKey === selectionCardMessageKey && (
|
||||
<>
|
||||
<RectificationRangeDelivery
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { memo, type MutableRefObject } from "react";
|
||||
|
||||
import {
|
||||
ChatMessageActions,
|
||||
type ChatMessageFeedback,
|
||||
} from "@/components/chat-message-actions";
|
||||
import { ChatMessageRow } from "@/components/chat-message-row";
|
||||
import { RectificationChoiceCard } from "@/components/rectification-choice-card";
|
||||
import { emptyActivityTrace, type AgentActivityTraceItem } from "@/lib/agent-activity-trace";
|
||||
import type { ChatMessageView } from "@/lib/chat-message-view";
|
||||
import {
|
||||
noteSettledRowRender,
|
||||
noteStreamingRowRender,
|
||||
noteUnsplitListRender,
|
||||
} from "@/lib/home-streaming-render-probe";
|
||||
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
|
||||
import {
|
||||
CHOICE_MODE,
|
||||
CHOICE_SKIP_QUESTION_LABEL,
|
||||
CHOICE_SKIP_QUESTION_MESSAGE,
|
||||
CHOICE_STOP_LABEL,
|
||||
CHOICE_STOP_MESSAGE,
|
||||
type ChoiceKey,
|
||||
type RectificationChoiceCard as ChoiceCardModel,
|
||||
} from "@/lib/rectification-agentic/v9/choice-card";
|
||||
import {
|
||||
questionIsAnswered,
|
||||
type TurnQuestion,
|
||||
} from "@/lib/rectification-agentic/v9/turn-question";
|
||||
import { RECTIFICATION_STOPPED_NOTICE } from "@/lib/rectification-surface-state";
|
||||
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
|
||||
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
|
||||
|
||||
export type RenderMessage = ChatMessageView & {
|
||||
renderKey: string;
|
||||
completedReceipt?: CompletedActivityReceiptView;
|
||||
failed?: boolean;
|
||||
stopped?: boolean;
|
||||
turnId?: string;
|
||||
activityTrace?: readonly AgentActivityTraceItem[];
|
||||
question?: TurnQuestion;
|
||||
candidateOffer?: Readonly<{ resultId: string }>;
|
||||
};
|
||||
|
||||
export type RectificationMessageActions = Readonly<{
|
||||
submitChoice: (key: ChoiceKey) => void;
|
||||
submitStop: () => void;
|
||||
copyMessage: (message: RenderMessage) => void;
|
||||
regenerateMessage: (message: RenderMessage) => void;
|
||||
onFeedback: (renderKey: string, requested: ChatMessageFeedback) => void;
|
||||
}>;
|
||||
|
||||
export type RectificationMessageEntryProps = Readonly<{
|
||||
message: RenderMessage;
|
||||
busy: boolean;
|
||||
readonly: boolean;
|
||||
regenerating: boolean;
|
||||
canRegenerate: boolean;
|
||||
currentQuestionFocusId: string | null;
|
||||
interactive: boolean;
|
||||
liveChoiceCard: ChoiceCardModel | null;
|
||||
choiceNonce: number;
|
||||
savedTime: string | null;
|
||||
copied: boolean;
|
||||
feedback: ChatMessageFeedback | undefined;
|
||||
actionsRef: MutableRefObject<RectificationMessageActions>;
|
||||
}>;
|
||||
|
||||
export function choiceCardFromQuestion(
|
||||
question: TurnQuestion,
|
||||
live: ChoiceCardModel | null,
|
||||
): ChoiceCardModel | null {
|
||||
if (live && live.focus_id === question.focus_id && live.options.length === 4) {
|
||||
return { ...live, prompt: question.prompt };
|
||||
}
|
||||
if (!question.options || question.options.length !== 4) return null;
|
||||
return {
|
||||
question_id: question.question_id,
|
||||
method_id: "",
|
||||
prompt: question.prompt,
|
||||
why: "",
|
||||
varga: null,
|
||||
choice_mode: CHOICE_MODE,
|
||||
options: question.options.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
answer_class: "unsure",
|
||||
role: "primary",
|
||||
})),
|
||||
stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
|
||||
stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
|
||||
scoring: question.kind !== "reverse_verify",
|
||||
probe_id: question.probe_id,
|
||||
case_revision: null,
|
||||
focus_id: question.focus_id,
|
||||
...(live?.skip_this_probe ? { skip_this_probe: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function RectificationMessageEntryView({
|
||||
message,
|
||||
busy,
|
||||
regenerating,
|
||||
canRegenerate,
|
||||
currentQuestionFocusId,
|
||||
interactive,
|
||||
liveChoiceCard,
|
||||
choiceNonce,
|
||||
savedTime,
|
||||
copied,
|
||||
feedback,
|
||||
actionsRef,
|
||||
}: RectificationMessageEntryProps) {
|
||||
const showActions = message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& !message.failed
|
||||
&& Boolean(message.text);
|
||||
const displayedMessage: RenderMessage = regenerating
|
||||
? {
|
||||
...message,
|
||||
text: "",
|
||||
thinkingText: undefined,
|
||||
state: "thinking" as const,
|
||||
activity: undefined,
|
||||
activityTrace: emptyActivityTrace(),
|
||||
completedReceipt: undefined,
|
||||
timeline: [],
|
||||
}
|
||||
: {
|
||||
...message,
|
||||
timeline: rectificationTimelineRows({
|
||||
trace: message.activityTrace,
|
||||
receipt: message.completedReceipt,
|
||||
activity: message.activity,
|
||||
settled: message.state === "settled",
|
||||
}),
|
||||
};
|
||||
const vargaSentence = !message.failed
|
||||
? vargaSentenceFromMethods(message.completedReceipt?.methods)
|
||||
: null;
|
||||
const question = regenerating ? undefined : message.question;
|
||||
const liveQuestion = Boolean(
|
||||
question
|
||||
&& currentQuestionFocusId === question.focus_id
|
||||
&& !questionIsAnswered(question)
|
||||
&& interactive
|
||||
&& (
|
||||
question.kind === "collect_spoken"
|
||||
|| (
|
||||
liveChoiceCard
|
||||
&& liveChoiceCard.focus_id === question.focus_id
|
||||
&& (question.kind === "choice" || question.kind === "reverse_verify")
|
||||
)
|
||||
),
|
||||
);
|
||||
const unansweredDeadChoice = Boolean(
|
||||
question
|
||||
&& !questionIsAnswered(question)
|
||||
&& !liveQuestion
|
||||
&& (question.kind === "choice" || question.kind === "reverse_verify")
|
||||
);
|
||||
const embeddedCard = question && !unansweredDeadChoice
|
||||
? choiceCardFromQuestion(question, liveQuestion ? liveChoiceCard : null)
|
||||
: null;
|
||||
const afterAnswer = question && displayedMessage.state === "settled"
|
||||
? (
|
||||
<div className="rectification-message-question">
|
||||
<p className="rectification-message-question__prompt">{question.prompt}</p>
|
||||
{embeddedCard && (
|
||||
<RectificationChoiceCard
|
||||
key={`${embeddedCard.question_id}:${question.answer_option ?? choiceNonce}:${question.status}`}
|
||||
variant="embedded"
|
||||
card={embeddedCard}
|
||||
pending={busy && liveQuestion}
|
||||
disabled={!liveQuestion}
|
||||
selectedKey={question.answer_option ?? ""}
|
||||
onSelect={(key) => actionsRef.current.submitChoice(key)}
|
||||
onStop={() => actionsRef.current.submitStop()}
|
||||
/>
|
||||
)}
|
||||
{question.status === "skipped" && (
|
||||
<p className="rectification-question-skipped" role="status">
|
||||
{savedTime ? `已跳过(已采用 ${savedTime})` : "已跳过"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
|
||||
<ChatMessageRow
|
||||
message={displayedMessage}
|
||||
showActivity={displayedMessage.state !== "settled"}
|
||||
vargaSentence={vargaSentence}
|
||||
afterAnswer={afterAnswer}
|
||||
stoppedNotice={message.stopped ? RECTIFICATION_STOPPED_NOTICE : undefined}
|
||||
/>
|
||||
)}
|
||||
{showActions && !regenerating && (
|
||||
<ChatMessageActions
|
||||
feedback={feedback}
|
||||
copied={copied}
|
||||
canRegenerate={canRegenerate}
|
||||
onFeedback={(requested) => actionsRef.current.onFeedback(message.renderKey, requested)}
|
||||
onCopy={() => void actionsRef.current.copyMessage(message)}
|
||||
onRegenerate={() => void actionsRef.current.regenerateMessage(message)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const RectificationMessageEntry = memo(function RectificationMessageEntry(
|
||||
props: RectificationMessageEntryProps,
|
||||
) {
|
||||
if (props.regenerating || props.message.state !== "settled") noteStreamingRowRender();
|
||||
else noteSettledRowRender();
|
||||
return <RectificationMessageEntryView {...props} />;
|
||||
});
|
||||
|
||||
export function UnsplitRectificationMessageList({
|
||||
messages,
|
||||
latestRegeneratableKey,
|
||||
copiedMessageKey,
|
||||
feedbackByKey,
|
||||
regeneratingMessageKey,
|
||||
...shared
|
||||
}: Omit<RectificationMessageEntryProps, "message" | "regenerating" | "canRegenerate" | "copied" | "feedback"> & {
|
||||
messages: readonly RenderMessage[];
|
||||
latestRegeneratableKey?: string;
|
||||
copiedMessageKey: string | null;
|
||||
feedbackByKey: Readonly<Record<string, ChatMessageFeedback | undefined>>;
|
||||
regeneratingMessageKey: string | null;
|
||||
}) {
|
||||
noteUnsplitListRender();
|
||||
return (
|
||||
<>
|
||||
{messages.map((message) => {
|
||||
noteSettledRowRender();
|
||||
if (message.state !== "settled") noteStreamingRowRender();
|
||||
return (
|
||||
<RectificationMessageEntryView
|
||||
key={message.renderKey}
|
||||
{...shared}
|
||||
message={message}
|
||||
regenerating={regeneratingMessageKey === message.renderKey}
|
||||
canRegenerate={message.renderKey === latestRegeneratableKey && shared.interactive}
|
||||
copied={copiedMessageKey === message.renderKey}
|
||||
feedback={feedbackByKey[message.renderKey]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -175,7 +175,11 @@ test("question stem ownership stays on set-focus spokenPrompt, not a slot or a s
|
||||
assert.doesNotMatch(exit, /assistantMessage\.(?:includes|match|search)/);
|
||||
assert.doesNotMatch(exit, /question\.prompt\.(?:includes|match|search)/);
|
||||
assert.doesNotMatch(chat, /rectification-question-slot/);
|
||||
assert.match(chat, /afterAnswer/);
|
||||
// 原值: 容器源码含 afterAnswer。
|
||||
// 新值: afterAnswer 在行组件;题干类名仍在对话面。
|
||||
// 原因: BUG-725。
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
assert.match(messageEntry, /afterAnswer/);
|
||||
assert.match(chat, /rectification-message-question/);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ test("ChatMessageContent splits spoken from report and only folds when a report
|
||||
});
|
||||
|
||||
test("the folded technique audit moves inside the report fold when a report exists", () => {
|
||||
assert.match(contentSource, /<div className="message-markdown">\{renderProse\(report, renderMarkdown\)\}<\/div>/);
|
||||
// 原值: `{renderProse(report, renderMarkdown)}` 每次重渲全量 parse。
|
||||
// 新值: 报告层走 `StableMarkdownPrefix`。
|
||||
// 原因: BUG-725 结算态 Markdown 按文本记忆化。
|
||||
assert.match(contentSource, /<div className="message-markdown">\s*<StableMarkdownPrefix text=\{report\} renderMarkdown=\{renderMarkdown\} \/>/);
|
||||
assert.match(contentSource, /\{foldedAudit\}/);
|
||||
assert.match(contentSource, /const reportBody = !streaming && report/);
|
||||
assert.match(contentSource, /AnswerDetailDisclosure headings=\{layers\.headings\} streaming=\{streaming\}/);
|
||||
|
||||
@@ -57,15 +57,16 @@ test("the pre-markdown fallback renders prose but never raw markdown", () => {
|
||||
// 原因: 口语层与报告层共用 renderProse,plainParagraphs 只在它内部调用一次。
|
||||
assert.match(contentSource, /\(plainParagraphs\(text\) \?\? \[\]\)\.map/);
|
||||
|
||||
// 两层必须都走 renderProse,不得一层 markdown 一层裸文本。
|
||||
// 口语层若改成 {spoken} / renderMarkdown(spoken)、报告层仍走 renderProse(或反过来),
|
||||
// 下面的精确计数会从 2 掉到 1。不得改成 >= 1。
|
||||
assert.match(contentSource, /renderProse\(spoken,\s*renderMarkdown\)/);
|
||||
assert.match(contentSource, /renderProse\(report,\s*renderMarkdown\)/);
|
||||
// 原值: 口语层与报告层都直接 `renderProse(spoken/report, renderMarkdown)`,计数为 2。
|
||||
// 新值: 结算两层都走 `StableMarkdownPrefix`;流式尾块仍走 `renderProse(split.tail)`。
|
||||
// 原因: BUG-725 结算态按文本记忆化。计数仍锁死为 2,不得放宽成 >= 1。
|
||||
assert.match(contentSource, /<StableMarkdownPrefix text=\{spoken\} renderMarkdown=\{renderMarkdown\} \/>/);
|
||||
assert.match(contentSource, /<StableMarkdownPrefix text=\{report\} renderMarkdown=\{renderMarkdown\} \/>/);
|
||||
assert.equal(
|
||||
contentSource.match(/renderProse\((?:spoken|report),\s*renderMarkdown\)/g)?.length,
|
||||
contentSource.match(/<StableMarkdownPrefix text=\{(?:spoken|report)\} renderMarkdown=\{renderMarkdown\} \/>/g)?.length,
|
||||
2,
|
||||
);
|
||||
assert.match(contentSource, /renderProse\(split\.tail, renderMarkdown\)/);
|
||||
assert.doesNotMatch(contentSource, /\{renderMarkdown\((?:spoken|report)\)\}/);
|
||||
assert.doesNotMatch(contentSource, />\{(?:spoken|report)\}<\/div>/);
|
||||
});
|
||||
|
||||
@@ -100,7 +100,11 @@ test("rectification abort settles as stopped, not a failed alert", () => {
|
||||
assert.match(rectification, /stopped: true/);
|
||||
assert.match(rectification, /failed: false,\s*stopped: true/);
|
||||
assert.doesNotMatch(rectification, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
|
||||
assert.match(rectification, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
|
||||
// 原值: 停止提示 prop 写在 rectification-agentic-chat.tsx。
|
||||
// 新值: 同一表达式在行组件内。
|
||||
// 原因: BUG-725。
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
|
||||
assert.match(messageRow, /stoppedNotice/);
|
||||
assert.match(messageRow, /className="message-stopped-notice"/);
|
||||
assert.equal(RECTIFICATION_STOPPED_NOTICE, "已停止,已生成的内容保留;本次不会扣点。");
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
import { StreamingMarkdown } from "../src/components/chat-message-content.tsx";
|
||||
import { StableMarkdownPrefix, StreamingMarkdown } from "../src/components/chat-message-content.tsx";
|
||||
import { splitStableMarkdown } from "../src/lib/chat-markdown-split.ts";
|
||||
|
||||
const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8");
|
||||
@@ -68,9 +68,22 @@ test("the streaming renderer parses the prefix and the tail as two separate docu
|
||||
assert.match(contentSource, /const StableMarkdownPrefix = memo\(function StableMarkdownPrefix/);
|
||||
assert.match(contentSource, /splitStableMarkdown\(text\)/);
|
||||
assert.match(contentSource, /streaming\s*\?\s*<StreamingMarkdown/);
|
||||
// 原值: `: renderMarkdown ? renderMarkdown(spoken)` 只给口语层走 markdown。
|
||||
// 新值: 口语层与报告层都走 `renderProse`。
|
||||
// 原因: 两层可见性后,折叠内外共用同一套解析,避免两套 fallback。
|
||||
assert.match(contentSource, /: renderProse\(spoken, renderMarkdown\)/);
|
||||
// 原值: `: renderProse(spoken, renderMarkdown)` 结算口语层每次重渲都全量 parse。
|
||||
// 新值: 结算口语层走 `StableMarkdownPrefix`(按文本记忆 + 解析缓存)。
|
||||
// 原因: BUG-725 流式帧不得重跑已结算 Markdown。
|
||||
assert.match(contentSource, /: <StableMarkdownPrefix text=\{spoken\} renderMarkdown=\{renderMarkdown\} \/>/);
|
||||
assert.match(messageRowSource, /streaming=\{message\.state !== "settled"\}/);
|
||||
});
|
||||
|
||||
test("settled markdown parses the same text only once across consecutive renders", () => {
|
||||
const calls: string[] = [];
|
||||
const renderMarkdown = (text: string) => {
|
||||
calls.push(text);
|
||||
return createElement("p", null, text);
|
||||
};
|
||||
const text = "已结算的一段回答,对照了 D9 和 D10。";
|
||||
renderToString(createElement(StableMarkdownPrefix, { text, renderMarkdown }));
|
||||
renderToString(createElement(StableMarkdownPrefix, { text, renderMarkdown }));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls, [text]);
|
||||
});
|
||||
|
||||
@@ -189,7 +189,15 @@ test("ordinary consultation replies reuse the shared Agent action bar", () => {
|
||||
assert.doesNotMatch(rectificationChat, /event\.type === "thinking\.delta"/);
|
||||
assert.match(rectificationChat, /startActivityTraceStep/);
|
||||
assert.match(rectificationChat, /completeActivityTraceStep/);
|
||||
assert.match(rectificationChat, /<ChatMessageActions/);
|
||||
// 原值: 校正面容器源码含 `<ChatMessageActions`。
|
||||
// 新值: 动作条随行组件抽出,容器仍 import 行组件。
|
||||
// 原因: BUG-725。
|
||||
const rectificationEntry = readFileSync(
|
||||
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(rectificationEntry, /<ChatMessageActions/);
|
||||
assert.match(rectificationChat, /<RectificationMessageEntry/);
|
||||
assert.match(globalStyles, /\.message-actions \{/);
|
||||
assert.doesNotMatch(globalStyles, /\.rectification-message-actions \{/);
|
||||
});
|
||||
|
||||
@@ -410,7 +410,11 @@ test("chat owns adopt cards on the offering message and drops the list-end hando
|
||||
// 原因: BUG-595 决策 3
|
||||
assert.doesNotMatch(chat, /rectification-adopt-status/);
|
||||
assert.doesNotMatch(chat, /用这个时间看盘/);
|
||||
assert.match(chat, /CHOICE_STOP_LABEL/);
|
||||
// 原值: 容器源码含 CHOICE_STOP_LABEL、已跳过(已采用)、scoring: question.kind。
|
||||
// 新值: 这些 token 随 choiceCardFromQuestion / afterAnswer 进了行组件。
|
||||
// 原因: BUG-725。
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
assert.match(messageEntry, /CHOICE_STOP_LABEL/);
|
||||
// 原值: 口述采集输入框上方必须有与点选卡同款停止按钮
|
||||
// 新值: 停止只留选择题卡与生成中「停止回答」
|
||||
// 原因: BUG-595 决策 2
|
||||
@@ -420,11 +424,11 @@ test("chat owns adopt cards on the offering message and drops the list-end hando
|
||||
readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8"),
|
||||
/stop_label !== CHOICE_STOP_LABEL/,
|
||||
);
|
||||
assert.match(chat, /已跳过(已采用/);
|
||||
assert.match(messageEntry, /已跳过(已采用/);
|
||||
assert.match(chat, /rectificationLiveProgressLabel/);
|
||||
assert.doesNotMatch(chat, /rectification-consult-handoff/);
|
||||
assert.match(chat, /kind === "reverse_verify"/);
|
||||
assert.match(chat, /scoring: question\.kind !== "reverse_verify"/);
|
||||
assert.match(messageEntry, /scoring: question\.kind !== "reverse_verify"/);
|
||||
});
|
||||
|
||||
test("choice cards use 这题跳过 for reverse_verify and 先这样 for collect", () => {
|
||||
|
||||
@@ -12,6 +12,10 @@ const chat = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const messageEntry = readFileSync(
|
||||
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const activityStatus = readFileSync(
|
||||
new URL("../src/components/agent-activity-status.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -352,10 +356,13 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
|
||||
assert.match(activityHelper, /filter\(isPublicRectificationTool\)/);
|
||||
assert.match(activityHelper, /receipt\.methods/);
|
||||
assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/);
|
||||
assert.match(chat, /completedReceipt\?: CompletedActivityReceiptView/);
|
||||
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
|
||||
assert.match(chat, /vargaSentenceFromMethods/);
|
||||
assert.match(chat, /vargaSentence=\{vargaSentence\}/);
|
||||
// 原值: 上述 token 都在 rectification-agentic-chat.tsx 的 messages.map 内。
|
||||
// 新值: RenderMessage / 时间轴 / varga 句随行组件抽出。
|
||||
// 原因: BUG-725 已结算行记忆化,派生不得留在容器每帧计算。
|
||||
assert.match(messageEntry, /completedReceipt\?: CompletedActivityReceiptView/);
|
||||
assert.match(messageEntry, /showActivity=\{displayedMessage\.state !== "settled"\}/);
|
||||
assert.match(messageEntry, /vargaSentenceFromMethods/);
|
||||
assert.match(messageEntry, /vargaSentence=\{vargaSentence\}/);
|
||||
assert.match(chat, /activityTraceFromReceipt/);
|
||||
assert.match(activityStatus, /vargaSentence/);
|
||||
assert.match(activityStatus, /VargaTraceStep/);
|
||||
@@ -375,9 +382,14 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
|
||||
chat.indexOf("{messages.map((message) => {"),
|
||||
chat.indexOf("{savedTime &&"),
|
||||
);
|
||||
const replyIndex = messageRender.indexOf("<ChatMessageRow");
|
||||
// 原值: `<ChatMessageRow` 写在容器 messages.map 里。
|
||||
// 新值: 行渲染在 `rectification-message-entry.tsx`,容器 map 只挂行组件。
|
||||
// 原因: BUG-725 抽出 memo 行。
|
||||
const replyIndex = messageEntry.indexOf("<ChatMessageRow");
|
||||
assert.ok(replyIndex >= 0);
|
||||
assert.match(messageRender, /<RectificationMessageEntry/);
|
||||
assert.doesNotMatch(messageRender, /<RectificationHouseTableView/);
|
||||
assert.doesNotMatch(messageEntry, /<RectificationHouseTableView/);
|
||||
// Former locks on `completed-activity-receipt.tsx` (`<details>`, never open, the "本轮完成 · N 个
|
||||
// 步骤" summary): that was a second collapsed block under every settled rectification reply.
|
||||
// Its methods now sit as source chips on the shared timeline's calculate row (BUG-476).
|
||||
@@ -397,7 +409,10 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
|
||||
assert.match(chat, /completeActivityTraceStep/);
|
||||
// Former lock: `activityStatus` contained `activityTrace` — the rectification-only trace panel.
|
||||
// The trace is now projected onto the shared timeline rows (BUG-476); the panel is a fallback.
|
||||
assert.match(chat, /rectificationTimelineRows/);
|
||||
// 原值: chat 源码直接调用 rectificationTimelineRows。
|
||||
// 新值: 调用点在行组件内。
|
||||
// 原因: BUG-725 容器不再每帧派生时间轴行。
|
||||
assert.match(messageEntry, /rectificationTimelineRows/);
|
||||
assert.doesNotMatch(activityStatus, /activityTrace/);
|
||||
assert.match(activityStatus, /className="message-thinking"/);
|
||||
assert.match(activityStatus, />思考</);
|
||||
@@ -409,7 +424,10 @@ test("completed Agent replies restore feedback, copy and safe in-place regenerat
|
||||
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
|
||||
assert.match(messageActions, new RegExp(`aria-label="${label}"`));
|
||||
}
|
||||
assert.match(chat, /<ChatMessageActions/);
|
||||
// 原值: `<ChatMessageActions` 写在容器 messages.map 里。
|
||||
// 新值: 动作条随行组件抽出;容器仍切换 feedback。
|
||||
// 原因: BUG-725 动作回调走 actionsRef,组件本身仍要渲染动作条。
|
||||
assert.match(messageEntry, /<ChatMessageActions/);
|
||||
assert.match(chat, /toggleRectificationFeedback/);
|
||||
assert.match(chat, /navigator\.clipboard\.writeText\(copyTextForMessage\(message\.text, message\.question\)\)/);
|
||||
assert.match(chat, /\/turns\/\$\{encodeURIComponent\(message\.turnId\)\}\/regenerate/);
|
||||
@@ -419,11 +437,12 @@ test("completed Agent replies restore feedback, copy and safe in-place regenerat
|
||||
chat.indexOf("{messages.map((message) => {"),
|
||||
chat.indexOf("{savedTime &&"),
|
||||
);
|
||||
const replyIndex = messageRender.indexOf("<ChatMessageRow");
|
||||
const actionsIndex = messageRender.indexOf("<ChatMessageActions");
|
||||
const replyIndex = messageEntry.indexOf("<ChatMessageRow");
|
||||
const actionsIndex = messageEntry.indexOf("<ChatMessageActions");
|
||||
assert.ok(replyIndex >= 0 && actionsIndex >= 0);
|
||||
assert.ok(replyIndex < actionsIndex);
|
||||
assert.doesNotMatch(messageRender, /<CompletedActivityReceipt/);
|
||||
assert.doesNotMatch(messageEntry, /<CompletedActivityReceipt/);
|
||||
|
||||
assert.doesNotMatch(chat, /send\("message",\s*(?:oldText|previousText)/);
|
||||
assert.doesNotMatch(regenerateRoute, /consultation-billing|reserveConsultation|billing\./);
|
||||
@@ -486,7 +505,10 @@ test("the natal house table is a live board beside the chat, not a message", ()
|
||||
board.indexOf("className=\"rectification-snapshot\""),
|
||||
board.indexOf("{scoringMinutes.length > 0 || displayMinutes.length > 0"),
|
||||
);
|
||||
const rowIndex = messageLoop.indexOf("<ChatMessageRow");
|
||||
// 原值: `<ChatMessageRow` 在容器 map 循环里。
|
||||
// 新值: 循环渲染 `RectificationMessageEntry`,ChatMessageRow 在行组件内。
|
||||
// 原因: BUG-725。
|
||||
const rowIndex = messageLoop.indexOf("<RectificationMessageEntry");
|
||||
const snapshotIndex = snapshot.indexOf("className=\"rectification-snapshot\"");
|
||||
const houseIndex = snapshot.indexOf("<RectificationHouseTableView");
|
||||
assert.ok(rowIndex >= 0);
|
||||
@@ -582,12 +604,18 @@ test("rectification composer can stop a live agent run", () => {
|
||||
// 新值:isAbortError(caught)
|
||||
// 原因:选择题与采用路径共用同一中止判定(BUG-552)
|
||||
assert.match(chat, /isAbortError\(caught\)/);
|
||||
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
|
||||
// 原值: 容器 map 写 `showActivity={displayedMessage.state !== "settled"}`。
|
||||
// 新值: 同一表达式在行组件内。
|
||||
// 原因: BUG-725。
|
||||
assert.match(messageEntry, /showActivity=\{displayedMessage\.state !== "settled"\}/);
|
||||
});
|
||||
|
||||
test("aborting a live run marks the bubble stopped without an alert", () => {
|
||||
assert.match(chat, /failed: false,\s*stopped: true/);
|
||||
assert.match(chat, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
|
||||
// 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。
|
||||
// 新值: 同一表达式在行组件内。
|
||||
// 原因: BUG-725。
|
||||
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
|
||||
assert.doesNotMatch(chat, /role="alert"[\s\S]{0,80}RECTIFICATION_STOPPED_NOTICE/);
|
||||
const choiceFetch = chat.slice(chat.indexOf("const submitStructuredChoice"), chat.indexOf("const acceptCandidate"));
|
||||
assert.match(choiceFetch, /runAbort\.current = abortController/);
|
||||
@@ -609,9 +637,13 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
chat.indexOf("{messages.map((message) => {"),
|
||||
chat.indexOf("{savedTime &&"),
|
||||
);
|
||||
const actionsIndex = messageLoop.indexOf("<ChatMessageActions");
|
||||
// 原值: ChatMessageActions 与 RangeDelivery 都在容器 map 里,且 actions 在卡之前。
|
||||
// 新值: 动作条在行组件内;交付卡仍在容器 map、排在行组件之后。
|
||||
// 原因: BUG-725 行组件抽出,交付卡不属于逐消息 Markdown 边界。
|
||||
const entryIndex = messageLoop.indexOf("<RectificationMessageEntry");
|
||||
const cardsIndex = messageLoop.indexOf("<RectificationRangeDelivery");
|
||||
assert.ok(actionsIndex >= 0 && cardsIndex > actionsIndex);
|
||||
assert.ok(entryIndex >= 0 && cardsIndex > entryIndex);
|
||||
assert.match(messageEntry, /<ChatMessageActions/);
|
||||
// 旧:showSelectionCards 只看 selectionAllowed && canAdopt && !showLiveChoiceCard && !busy,
|
||||
// 锚在 latestSettledAssistant。采集题没有 A–D 卡,所以 collect_evidence 会出采用卡;
|
||||
// 采用后 busy 把卡藏掉,新消息又把卡带走。
|
||||
@@ -644,11 +676,18 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
// 新值: 还要有 candidateResult 才渲染行选卡
|
||||
// 原因: BUG-595 决策 1
|
||||
assert.match(messageLoop, /showSelectionCards && candidateResult && message\.renderKey === selectionCardMessageKey/);
|
||||
// 原值: 嵌入卡 token(RectificationChoiceCard / variant / choiceNonce / answer_option)在容器 map。
|
||||
// 新值: 嵌入卡在行组件;容器 map 仍有 persisted 题卡的 RectificationChoiceCard。
|
||||
// 原因: BUG-725。
|
||||
assert.match(messageEntry, /<RectificationChoiceCard/);
|
||||
assert.match(messageEntry, /variant="embedded"/);
|
||||
assert.match(messageEntry, /choiceNonce/);
|
||||
assert.match(messageEntry, /question\.answer_option/);
|
||||
assert.match(messageLoop, /<RectificationChoiceCard/);
|
||||
assert.match(messageLoop, /variant="embedded"/);
|
||||
assert.match(messageLoop, /choiceNonce/);
|
||||
assert.match(messageLoop, /question\.answer_option/);
|
||||
assert.doesNotMatch(messageLoop, /rectification-question-slot/);
|
||||
assert.doesNotMatch(messageEntry, /rectification-question-slot/);
|
||||
assert.doesNotMatch(choiceCardComponent, /setSelectedKey\(""\)/);
|
||||
assert.match(choiceCardComponent, /selectedKey/);
|
||||
assert.match(choiceCardComponent, /is-answered/);
|
||||
@@ -664,8 +703,12 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
assert.doesNotMatch(chat, /send\("message", choiceCard\?\.stop_message/);
|
||||
assert.doesNotMatch(chat, /choiceCardUserMessage/);
|
||||
assert.match(caseProjection, /choice_card: choiceCardFromCaseDossier/);
|
||||
assert.match(chat, /choiceCard\s+&& choiceCard\.focus_id === question\.focus_id/);
|
||||
assert.match(chat, /disabled=\{!liveQuestion\}/);
|
||||
// 原值: 容器 map 用 `choiceCard && choiceCard.focus_id === question.focus_id` 和 `disabled={!liveQuestion}`。
|
||||
// 新值: 行组件用 liveChoiceCard / liveQuestion;容器的 live 卡判定仍比对 latestLiveQuestion。
|
||||
// 原因: BUG-725。
|
||||
assert.match(messageEntry, /liveChoiceCard\s+&& liveChoiceCard\.focus_id === question\.focus_id/);
|
||||
assert.match(messageEntry, /disabled=\{!liveQuestion\}/);
|
||||
assert.match(chat, /choiceCard\s+&& choiceCard\.focus_id === latestLiveQuestion\.focus_id/);
|
||||
assert.match(caseProjection, /current_question: projectCurrentQuestion/);
|
||||
assert.match(caseProjection, /overlayPublicDecision/);
|
||||
assert.match(caseProjection, /const fields = publicDecisionFields\(decision\)/);
|
||||
|
||||
@@ -107,6 +107,10 @@ test("composer wrap no longer hosts 先这样 or the adopt status bar", () => {
|
||||
assert.doesNotMatch(wrap, /rectification-collect-stop/);
|
||||
assert.doesNotMatch(wrap, /rectification-composer-meta/);
|
||||
assert.doesNotMatch(wrap, /rectification-adopt-status/);
|
||||
assert.match(chat, /CHOICE_STOP_LABEL/);
|
||||
// 原值: 容器源码含 CHOICE_STOP_LABEL(嵌入卡 stop 文案)。
|
||||
// 新值: 常量在行组件的 choiceCardFromQuestion 里;composer-wrap 仍不得托管。
|
||||
// 原因: BUG-725。
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
assert.match(messageEntry, /CHOICE_STOP_LABEL/);
|
||||
assert.match(chat, /postAdoptVerifyDoneCopy/);
|
||||
});
|
||||
|
||||
@@ -636,7 +636,11 @@ test("collect spoken stop stays on choice cards; range line is status only", ()
|
||||
);
|
||||
const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\""));
|
||||
assert.doesNotMatch(wrap, /rectification-collect-stop/);
|
||||
assert.match(chat, /CHOICE_STOP_LABEL/);
|
||||
// 原值: 容器源码含 CHOICE_STOP_LABEL。
|
||||
// 新值: 常量在行组件;容器仍有 submitStop,composer-wrap 仍不得托管。
|
||||
// 原因: BUG-725。
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
assert.match(messageEntry, /CHOICE_STOP_LABEL/);
|
||||
assert.match(chat, /kind === "collect_spoken"/);
|
||||
assert.match(chat, /function submitStop/);
|
||||
assert.match(chat, /RectificationReadonlyRange/);
|
||||
|
||||
@@ -135,9 +135,13 @@ test("copy text is body, blank line, stem, then option lines", () => {
|
||||
|
||||
test("one assistant article owns the stem and options; the slot class is gone", () => {
|
||||
assert.match(row, /afterAnswer/);
|
||||
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
|
||||
// 原值: 容器源码含 `afterAnswer={afterAnswer}`。
|
||||
// 新值: 行组件内部构造 afterAnswer;题干类名与 embedded 变体仍在对话面。
|
||||
// 原因: BUG-725。
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
assert.match(messageEntry, /afterAnswer=\{afterAnswer\}/);
|
||||
assert.match(chat, /rectification-message-question/);
|
||||
assert.match(chat, /variant="embedded"/);
|
||||
assert.match(messageEntry, /variant="embedded"/);
|
||||
assert.match(chat, /function markQuestionAnswered/);
|
||||
assert.match(chat, /"typed"/);
|
||||
assert.doesNotMatch(chat, /rectification-question-slot/);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createElement, createRef } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessageFeedback } from "../src/components/chat-message-actions.tsx";
|
||||
import {
|
||||
RectificationMessageEntry,
|
||||
UnsplitRectificationMessageList,
|
||||
type RectificationMessageActions,
|
||||
type RectificationMessageEntryProps,
|
||||
type RenderMessage,
|
||||
} from "../src/components/rectification-message-entry.tsx";
|
||||
import {
|
||||
disableHomeStreamingRenderProbe,
|
||||
enableHomeStreamingRenderProbe,
|
||||
homeStreamingRenderProbeSnapshot,
|
||||
resetHomeStreamingRenderProbe,
|
||||
} from "../src/lib/home-streaming-render-probe.ts";
|
||||
|
||||
const chatSource = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const entrySource = readFileSync(
|
||||
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const rowSource = readFileSync(
|
||||
new URL("../src/components/chat-message-row.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function settled(role: "user" | "assistant", text: string, key: string): RenderMessage {
|
||||
return {
|
||||
role,
|
||||
text,
|
||||
renderKey: key,
|
||||
state: "settled",
|
||||
};
|
||||
}
|
||||
|
||||
function liveAssistant(text: string): RenderMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
text,
|
||||
renderKey: "live-assistant",
|
||||
state: "streaming",
|
||||
};
|
||||
}
|
||||
|
||||
function emptyActions(): RectificationMessageEntryProps["actionsRef"] {
|
||||
const actionsRef = createRef<RectificationMessageActions>() as RectificationMessageEntryProps["actionsRef"];
|
||||
actionsRef.current = {
|
||||
submitChoice() {},
|
||||
submitStop() {},
|
||||
copyMessage() {},
|
||||
regenerateMessage() {},
|
||||
onFeedback() {},
|
||||
};
|
||||
return actionsRef;
|
||||
}
|
||||
|
||||
function sharedProps(): Omit<
|
||||
RectificationMessageEntryProps,
|
||||
"message" | "regenerating" | "canRegenerate" | "copied" | "feedback"
|
||||
> {
|
||||
return {
|
||||
busy: true,
|
||||
readonly: false,
|
||||
currentQuestionFocusId: null,
|
||||
interactive: false,
|
||||
liveChoiceCard: null,
|
||||
choiceNonce: 0,
|
||||
savedTime: null,
|
||||
actionsRef: emptyActions(),
|
||||
};
|
||||
}
|
||||
|
||||
function entryProps(
|
||||
message: RenderMessage,
|
||||
extras: Partial<Pick<RectificationMessageEntryProps, "regenerating" | "canRegenerate" | "copied" | "feedback">> = {},
|
||||
): RectificationMessageEntryProps {
|
||||
return {
|
||||
...sharedProps(),
|
||||
message,
|
||||
regenerating: extras.regenerating ?? false,
|
||||
canRegenerate: extras.canRegenerate ?? false,
|
||||
copied: extras.copied ?? false,
|
||||
feedback: extras.feedback,
|
||||
};
|
||||
}
|
||||
|
||||
test("the chat container no longer derives per-row timeline, varga, or choice cards", () => {
|
||||
assert.doesNotMatch(chatSource, /rectificationTimelineRows\s*\(/);
|
||||
assert.doesNotMatch(chatSource, /vargaSentenceFromMethods\s*\(/);
|
||||
assert.doesNotMatch(chatSource, /choiceCardFromQuestion\s*\(/);
|
||||
assert.match(entrySource, /rectificationTimelineRows\s*\(/);
|
||||
assert.match(entrySource, /vargaSentenceFromMethods\s*\(/);
|
||||
assert.match(entrySource, /choiceCardFromQuestion\s*\(/);
|
||||
});
|
||||
|
||||
test("the extracted row is memoised and takes no function props except actionsRef", () => {
|
||||
assert.match(entrySource, /export const RectificationMessageEntry = memo\(function RectificationMessageEntry/);
|
||||
const propsBlock = entrySource.slice(
|
||||
entrySource.indexOf("export type RectificationMessageEntryProps"),
|
||||
entrySource.indexOf("export function choiceCardFromQuestion"),
|
||||
);
|
||||
assert.match(propsBlock, /actionsRef: MutableRefObject<RectificationMessageActions>/);
|
||||
assert.doesNotMatch(propsBlock, /=>/);
|
||||
assert.doesNotMatch(propsBlock, /on[A-Z]\w+\?:/);
|
||||
assert.match(rowSource, /export const ChatMessageRow = memo\(function ChatMessageRow/);
|
||||
});
|
||||
|
||||
test("the split architecture renders settled rectification rows once while streaming tokens", () => {
|
||||
const history: RenderMessage[] = [
|
||||
settled("user", "第一件经历", "u1"),
|
||||
settled("assistant", "记下了第一件。", "a1"),
|
||||
settled("user", "第二件经历", "u2"),
|
||||
settled("assistant", "记下了第二件。", "a2"),
|
||||
];
|
||||
const tokens = ["甲", "甲乙", "甲乙丙", "甲乙丙丁", "甲乙丙丁戊"];
|
||||
const shared = sharedProps();
|
||||
const feedbackByKey: Readonly<Record<string, ChatMessageFeedback | undefined>> = {};
|
||||
|
||||
resetHomeStreamingRenderProbe();
|
||||
enableHomeStreamingRenderProbe();
|
||||
for (const message of history) {
|
||||
renderToString(createElement(RectificationMessageEntry, entryProps(message)));
|
||||
}
|
||||
for (const streamingText of tokens) {
|
||||
renderToString(createElement(RectificationMessageEntry, entryProps(liveAssistant(streamingText))));
|
||||
}
|
||||
const split = homeStreamingRenderProbeSnapshot();
|
||||
disableHomeStreamingRenderProbe();
|
||||
|
||||
resetHomeStreamingRenderProbe();
|
||||
enableHomeStreamingRenderProbe();
|
||||
for (const streamingText of tokens) {
|
||||
renderToString(createElement(UnsplitRectificationMessageList, {
|
||||
...shared,
|
||||
messages: [...history, liveAssistant(streamingText)],
|
||||
latestRegeneratableKey: "a2",
|
||||
copiedMessageKey: null,
|
||||
feedbackByKey,
|
||||
regeneratingMessageKey: null,
|
||||
}));
|
||||
}
|
||||
const unsplit = homeStreamingRenderProbeSnapshot();
|
||||
disableHomeStreamingRenderProbe();
|
||||
|
||||
assert.equal(split.settledRowRenders, history.length);
|
||||
assert.ok(split.streamingRowRenders <= tokens.length);
|
||||
assert.ok(split.streamingRowRenders >= 1);
|
||||
assert.equal(unsplit.unsplitListRenders, tokens.length);
|
||||
// Unsplit counts every row on every frame, including the live one.
|
||||
assert.equal(unsplit.settledRowRenders, (history.length + 1) * tokens.length);
|
||||
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
|
||||
});
|
||||
@@ -126,9 +126,13 @@ test("cases current_question remains the submit contract, not a visual slot", ()
|
||||
assert.doesNotMatch(wrap, /rectification-collect-reply/);
|
||||
assert.doesNotMatch(wrap, /send\("message", "没有"\)/);
|
||||
assert.doesNotMatch(wrap, /rectification-step-state/);
|
||||
const afterAnswer = chat.slice(
|
||||
chat.indexOf("const afterAnswer = question"),
|
||||
chat.indexOf("key={message.renderKey}"),
|
||||
// 原值: 切片容器 map 里的 `const afterAnswer = question`。
|
||||
// 新值: afterAnswer 在行组件内构造。
|
||||
// 原因: BUG-725。
|
||||
const messageEntryForReplies = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
const afterAnswer = messageEntryForReplies.slice(
|
||||
messageEntryForReplies.indexOf("const afterAnswer = question"),
|
||||
messageEntryForReplies.indexOf("return ("),
|
||||
);
|
||||
assert.doesNotMatch(afterAnswer, /CollectSpokenReplies/);
|
||||
assert.doesNotMatch(afterAnswer, /shouldShowCollectSpokenReplies/);
|
||||
@@ -155,7 +159,11 @@ test("collect_spoken stem lives on turn.question inside the same assistant artic
|
||||
assert.match(chat, /const collectSpokenPrompt =/);
|
||||
assert.match(chat, /parseTurnQuestion\(turn\.question\)/);
|
||||
assert.match(chat, /function markQuestionAnswered/);
|
||||
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
|
||||
// 原值: 容器把 afterAnswer 作为 ChatMessageRow prop。
|
||||
// 新值: 行组件内部构造 afterAnswer 再传给 ChatMessageRow。
|
||||
// 原因: BUG-725。
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
assert.match(messageEntry, /afterAnswer=\{afterAnswer\}/);
|
||||
assert.match(chat, /rectification-message-question/);
|
||||
assert.match(row, /afterAnswer/);
|
||||
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
|
||||
@@ -212,18 +220,26 @@ test("missing current_question is explicit only for resumable cases", () => {
|
||||
|
||||
test("choice options render inside the same assistant message, not a sibling slot", () => {
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
const render = chat.slice(
|
||||
chat.indexOf("{messages.map((message) => {") ,
|
||||
chat.indexOf("{savedTime &&"),
|
||||
);
|
||||
assert.match(chat, /showLiveChoiceCard/);
|
||||
// 原值: 嵌入卡、afterAnswer、onSelect={submitChoice} 都在容器 map 里。
|
||||
// 新值: 嵌入卡与 afterAnswer 在行组件内,经 actionsRef 提交;容器的 persisted 题卡仍直接绑 submitChoice。
|
||||
// 原因: BUG-725 已结算行记忆化,回调不得当 props。
|
||||
assert.match(messageEntry, /<RectificationChoiceCard/);
|
||||
assert.match(messageEntry, /afterAnswer/);
|
||||
assert.match(messageEntry, /actionsRef\.current\.submitChoice/);
|
||||
assert.match(messageEntry, /actionsRef\.current\.submitStop/);
|
||||
assert.match(render, /<RectificationChoiceCard/);
|
||||
assert.match(render, /onSelect=\{submitChoice\}/);
|
||||
assert.match(render, /onStop=\{submitStop\}/);
|
||||
assert.match(render, /afterAnswer/);
|
||||
assert.doesNotMatch(render, /rectification-question-slot/);
|
||||
assert.doesNotMatch(render, /liveSpoken/);
|
||||
assert.doesNotMatch(render, /showLiveSpokenCollect/);
|
||||
assert.doesNotMatch(messageEntry, /rectification-question-slot/);
|
||||
});
|
||||
|
||||
test("openQuestionFromPersistedFocus returns collect_spoken without making collect choice-ready", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { homeSurface as page } from "./home-surface.ts";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
const chat = read("../src/components/rectification-agentic-chat.tsx");
|
||||
const messageEntry = read("../src/components/rectification-message-entry.tsx");
|
||||
const hook = read("../src/hooks/use-rectification-surface.ts");
|
||||
const sessions = read("../src/hooks/use-session-management.ts");
|
||||
const bootstrap = read("../src/lib/home-bootstrap.ts");
|
||||
@@ -126,7 +127,10 @@ test("stopping keeps what streamed and says so; a 402 explains itself before lea
|
||||
// 新值:气泡 stopped + 灰字 RECTIFICATION_STOPPED_NOTICE,error-message 只留给真正失败
|
||||
// 原因:停止是用户动作,不能穿报错衣服(BUG-552)
|
||||
assert.match(chat, /failed: false,\s*stopped: true/);
|
||||
assert.match(chat, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
|
||||
// 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。
|
||||
// 新值: 同一表达式在行组件内。
|
||||
// 原因: BUG-725。
|
||||
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
|
||||
assert.doesNotMatch(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
|
||||
assert.match(chat, /inputDisabled=\{readonly\}/);
|
||||
assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*onOpenBilling\?\.\(\{ source: "rectification" \}\);/);
|
||||
|
||||
@@ -174,5 +174,9 @@ test("a choice question without a GET card is the repair path, not collect waiti
|
||||
assert.match(chat, /interviewChoiceCardUnavailable/);
|
||||
assert.match(chat, /questionSource === "unavailable" \|\| deadChoice/);
|
||||
assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\/repair-exit/);
|
||||
assert.match(chat, /unansweredDeadChoice/);
|
||||
// 原值: 容器 map 内有 `unansweredDeadChoice`。
|
||||
// 新值: 同一判定在行组件内。
|
||||
// 原因: BUG-725。
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
assert.match(messageEntry, /unansweredDeadChoice/);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PUBLIC_RECTIFICATION_METHODS } from "../src/lib/rectification-agentic/v
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||||
const chatSource = read("src/components/rectification-agentic-chat.tsx");
|
||||
const entrySource = read("src/components/rectification-message-entry.tsx");
|
||||
const rowSource = read("src/components/chat-message-row.tsx");
|
||||
const activitySource = read("src/components/agent-activity-status.tsx");
|
||||
const packageJson = read("package.json");
|
||||
@@ -156,8 +157,12 @@ test("after attempt.reset the timeline is empty, and a settled reply without a r
|
||||
});
|
||||
|
||||
test("both chat surfaces render one timeline, one live marker, and no second receipt block", () => {
|
||||
assert.match(chatSource, /rectificationTimelineRows\(\{/);
|
||||
assert.match(chatSource, /timeline: rectificationTimelineRows/);
|
||||
// 原值: 容器源码直接 `timeline: rectificationTimelineRows(...)`。
|
||||
// 新值: 同一调用在行组件内;容器不得再每帧派生。
|
||||
// 原因: BUG-725 已结算行记忆化。
|
||||
assert.match(entrySource, /rectificationTimelineRows\(\{/);
|
||||
assert.match(entrySource, /timeline: rectificationTimelineRows/);
|
||||
assert.doesNotMatch(chatSource, /rectificationTimelineRows\s*\(/);
|
||||
assert.doesNotMatch(chatSource, /rectification-activity-failure|<CompletedActivityReceipt/);
|
||||
assert.doesNotMatch(chatSource, /label: "正在组织回答…",\s*\}\),\s*\}\s*:\s*message;/);
|
||||
assert.match(chatSource, /rememberLiveActivity\(RECTIFICATION_ANALYZING_LIVE_LABEL, null\)/);
|
||||
|
||||
@@ -349,6 +349,11 @@ test("embedded choice cards hide the duplicate legend; standalone cards keep the
|
||||
assert.match(card, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
|
||||
assert.match(card, /variant === "embedded"/);
|
||||
assert.match(chat, /rectification-message-question__prompt/);
|
||||
// 原值: 容器源码含 variant="embedded"(消息内嵌卡)。
|
||||
// 新值: 消息内嵌卡在行组件;容器 persisted 题卡仍是 embedded。
|
||||
// 原因: BUG-725。
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
assert.match(messageEntry, /variant="embedded"/);
|
||||
assert.match(chat, /variant="embedded"/);
|
||||
// 原值: className="rectification-choice-why"(引擎作题简报)
|
||||
// 新值: className="rectification-choice-why-user"(折叠「为什么问这题」)
|
||||
|
||||
@@ -423,7 +423,11 @@ test("agent instructions do not assert that the next question is already on scre
|
||||
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
|
||||
assert.match(chat, /await loadCaseSnapshot\(\)/);
|
||||
assert.match(chat, /mergeTurnQuestions/);
|
||||
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
|
||||
// 原值: 容器源码含 `afterAnswer={afterAnswer}`。
|
||||
// 新值: 行组件内部构造 afterAnswer。
|
||||
// 原因: BUG-725。
|
||||
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
|
||||
assert.match(messageEntry, /afterAnswer=\{afterAnswer\}/);
|
||||
assert.doesNotMatch(chat, /isLatestMessage/);
|
||||
});
|
||||
|
||||
|
||||
@@ -107,13 +107,15 @@ def _active_vimshottari(
|
||||
birth_date: str,
|
||||
moon_longitude: float,
|
||||
event_at: datetime,
|
||||
timeline: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str, str, str]:
|
||||
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
|
||||
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
|
||||
birth_date,
|
||||
nakshatra,
|
||||
progress,
|
||||
)
|
||||
if timeline is None:
|
||||
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
|
||||
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
|
||||
birth_date,
|
||||
nakshatra,
|
||||
progress,
|
||||
)
|
||||
_, major = dasha_analyzer.find_current(timeline, event_at)
|
||||
minor = dasha_analyzer.find_current_sub(
|
||||
dasha_analyzer.build_antardasha(major),
|
||||
@@ -131,11 +133,13 @@ def _active_narayana(
|
||||
planet_longitudes: dict[str, float],
|
||||
birth_at: datetime,
|
||||
event_at: datetime,
|
||||
periods: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[int | None, int | None]:
|
||||
periods = narayana_dasha.calc_narayana_mahadasha(
|
||||
ascendant_index,
|
||||
planet_longitudes,
|
||||
)
|
||||
if periods is None:
|
||||
periods = narayana_dasha.calc_narayana_mahadasha(
|
||||
ascendant_index,
|
||||
planet_longitudes,
|
||||
)
|
||||
age = max((event_at - birth_at).total_seconds() / (365.2425 * 86_400), 0.0)
|
||||
active = narayana_dasha.get_current_narayana_dasha(periods, age)
|
||||
major = active.get("md") or {}
|
||||
@@ -280,23 +284,45 @@ def _score_event(
|
||||
}
|
||||
|
||||
|
||||
def _transit_chart_cache_key(
|
||||
request: RectificationEventRequest,
|
||||
event_at: datetime,
|
||||
) -> tuple[Any, ...]:
|
||||
return (
|
||||
event_at.date().isoformat(),
|
||||
float(request["lat"]),
|
||||
float(request["lon"]),
|
||||
float(request["tz"]),
|
||||
request.get("ayanamsa", AYANAMSA),
|
||||
request.get("node_mode", NODE_MODE),
|
||||
)
|
||||
|
||||
|
||||
def _controlled_transit_rules(
|
||||
request: RectificationEventRequest,
|
||||
event: LifeEvent,
|
||||
natal_ascendant_index: int,
|
||||
target_houses: tuple[int, ...],
|
||||
transit_chart_cache: dict[tuple[Any, ...], dict[str, Any]] | None = None,
|
||||
) -> list[str]:
|
||||
"""Use only Jupiter/Saturn and only day/month dated events as a weak check."""
|
||||
if event["precision"] == "year":
|
||||
return []
|
||||
event_at = _event_datetime(event)
|
||||
transit_chart = domain_calculation_service.compute_chart({
|
||||
payload = {
|
||||
"year": event_at.year, "month": event_at.month, "day": event_at.day,
|
||||
"hour": 12, "minute": 0, "lat": request["lat"], "lon": request["lon"],
|
||||
"tz": request["tz"],
|
||||
"ayanamsa": request.get("ayanamsa", AYANAMSA),
|
||||
"node_mode": request.get("node_mode", NODE_MODE),
|
||||
})
|
||||
}
|
||||
cache_key = _transit_chart_cache_key(request, event_at)
|
||||
if transit_chart_cache is not None and cache_key in transit_chart_cache:
|
||||
transit_chart = transit_chart_cache[cache_key]
|
||||
else:
|
||||
transit_chart = domain_calculation_service.compute_chart(payload)
|
||||
if transit_chart_cache is not None:
|
||||
transit_chart_cache[cache_key] = transit_chart
|
||||
rules: list[str] = []
|
||||
for planet in ("Jupiter", "Saturn"):
|
||||
item = transit_chart.get("planets", {}).get(planet) or {}
|
||||
@@ -305,9 +331,16 @@ def _controlled_transit_rules(
|
||||
return rules
|
||||
|
||||
|
||||
def _ashtakavarga_auxiliary(natal_chart: dict, ascendant_index: int, target_houses: tuple[int, ...]) -> tuple[list[str], float]:
|
||||
def _ashtakavarga_auxiliary(
|
||||
natal_chart: dict,
|
||||
ascendant_index: int,
|
||||
target_houses: tuple[int, ...],
|
||||
ashtakavarga_result: dict[str, Any] | None = None,
|
||||
) -> tuple[list[str], float]:
|
||||
"""Return a bounded SAV consistency adjustment, never a standalone trigger."""
|
||||
result = ashtakavarga.calc_ashtakavarga(natal_chart.get("planets", {}), ascendant_index)
|
||||
result = ashtakavarga_result
|
||||
if result is None:
|
||||
result = ashtakavarga.calc_ashtakavarga(natal_chart.get("planets", {}), ascendant_index)
|
||||
if not result.get("all_bav_valid") or not (result.get("sav") or {}).get("valid"):
|
||||
return [], 0.0
|
||||
house_scores = result.get("house_scores_full") or {}
|
||||
@@ -323,17 +356,24 @@ def _ashtakavarga_auxiliary(natal_chart: dict, ascendant_index: int, target_hous
|
||||
return [], 0.0
|
||||
|
||||
|
||||
def _shadbala_verified_components_auxiliary(natal_chart: dict, birth_hour: float, dasha_lords: tuple[str, str, str]) -> tuple[list[str], float]:
|
||||
def _shadbala_verified_components_auxiliary(
|
||||
natal_chart: dict,
|
||||
birth_hour: float,
|
||||
dasha_lords: tuple[str, str, str],
|
||||
shadbala_result: dict[str, Any] | None = None,
|
||||
) -> tuple[list[str], float]:
|
||||
"""Use only Sthana/Drik/Naisargika, whose oracle comparison is already matched."""
|
||||
planets = natal_chart.get("planets", {})
|
||||
sun = planets.get("Sun") or {}
|
||||
moon = planets.get("Moon") or {}
|
||||
if not isinstance(sun.get("lon"), (int, float)) or not isinstance(moon.get("lon"), (int, float)):
|
||||
return [], 0.0
|
||||
result = shadbala.calc_shadbala(
|
||||
planets, str(natal_chart["ascendant"].get("sign") or "Aries"), birth_hour,
|
||||
float(sun["lon"]), float(moon["lon"]),
|
||||
)
|
||||
result = shadbala_result
|
||||
if result is None:
|
||||
result = shadbala.calc_shadbala(
|
||||
planets, str(natal_chart["ascendant"].get("sign") or "Aries"), birth_hour,
|
||||
float(sun["lon"]), float(moon["lon"]),
|
||||
)
|
||||
values = {
|
||||
planet: float((row.get("sthana_bala") or {}).get("total", 0)) + float(row.get("drik_bala", 0)) + float(row.get("naisargika_bala", 0))
|
||||
for planet, row in (result.get("planets") or {}).items()
|
||||
@@ -490,6 +530,26 @@ def build_candidate_static_context(
|
||||
except (KeyError, TypeError, ValueError):
|
||||
blocked_layers.append("Shadbala")
|
||||
|
||||
vimshottari_timeline = None
|
||||
try:
|
||||
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(planet_longitudes["Moon"])
|
||||
vimshottari_timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
|
||||
candidate_at.date().isoformat(),
|
||||
nakshatra,
|
||||
progress,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
vimshottari_timeline = None
|
||||
|
||||
narayana_periods = None
|
||||
try:
|
||||
narayana_periods = narayana_dasha.calc_narayana_mahadasha(
|
||||
ascendant_index,
|
||||
planet_longitudes,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
narayana_periods = None
|
||||
|
||||
birth_info = chart.get("birth_info") if isinstance(chart.get("birth_info"), dict) else {}
|
||||
kp_snapshot = observe_kp_cusps(
|
||||
birth_info.get("julian_day"),
|
||||
@@ -538,6 +598,10 @@ def build_candidate_static_context(
|
||||
"arudha_padas": arudha_padas,
|
||||
"varga_charts": varga_charts,
|
||||
"feature": feature_payload,
|
||||
"ashtakavarga_result": ashtakavarga_result,
|
||||
"shadbala_result": shadbala_result,
|
||||
"vimshottari_timeline": vimshottari_timeline,
|
||||
"narayana_periods": narayana_periods,
|
||||
}
|
||||
|
||||
|
||||
@@ -572,7 +636,12 @@ def _candidate_row(
|
||||
missing_layers.extend(prefixes)
|
||||
continue
|
||||
try:
|
||||
vimshottari = _active_vimshottari(candidate_at.date().isoformat(), moon_longitude, event_at)
|
||||
vimshottari = _active_vimshottari(
|
||||
candidate_at.date().isoformat(),
|
||||
moon_longitude,
|
||||
event_at,
|
||||
context.get("vimshottari_timeline"),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
missing_layers.append("Vimshottari_MD_AD_PD")
|
||||
continue
|
||||
@@ -582,6 +651,7 @@ def _candidate_row(
|
||||
planet_longitudes,
|
||||
candidate_at,
|
||||
event_at,
|
||||
context.get("narayana_periods"),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
missing_layers.append("Narayana_MD_AD")
|
||||
@@ -598,16 +668,30 @@ def _candidate_row(
|
||||
narayana=narayana,
|
||||
arudha_padas=arudha_padas,
|
||||
))
|
||||
transit_rules = _controlled_transit_rules(request, event, ascendant_index, DOMAIN_CONFIG[event["domain"]][1])
|
||||
transit_rules = _controlled_transit_rules(
|
||||
request,
|
||||
event,
|
||||
ascendant_index,
|
||||
DOMAIN_CONFIG[event["domain"]][1],
|
||||
context.get("_transit_chart_cache"),
|
||||
)
|
||||
if transit_rules:
|
||||
evidence[-1]["rule_ids"].extend(transit_rules)
|
||||
evidence[-1]["points"] = round(evidence[-1]["points"] + 0.25 * len(transit_rules) * precision_weight(event["precision"]), 4)
|
||||
av_rules, av_points = _ashtakavarga_auxiliary(chart, ascendant_index, DOMAIN_CONFIG[event["domain"]][1])
|
||||
av_rules, av_points = _ashtakavarga_auxiliary(
|
||||
chart,
|
||||
ascendant_index,
|
||||
DOMAIN_CONFIG[event["domain"]][1],
|
||||
context.get("ashtakavarga_result"),
|
||||
)
|
||||
if av_rules:
|
||||
evidence[-1]["rule_ids"].extend(av_rules)
|
||||
evidence[-1]["points"] = round(evidence[-1]["points"] + av_points * precision_weight(event["precision"]), 4)
|
||||
shadbala_rules, shadbala_points = _shadbala_verified_components_auxiliary(
|
||||
chart, candidate_at.hour + candidate_at.minute / 60, vimshottari,
|
||||
chart,
|
||||
candidate_at.hour + candidate_at.minute / 60,
|
||||
vimshottari,
|
||||
context.get("shadbala_result"),
|
||||
)
|
||||
if shadbala_rules:
|
||||
evidence[-1]["rule_ids"].extend(shadbala_rules)
|
||||
@@ -719,4 +803,8 @@ def compute_event_candidate_rows(
|
||||
) -> list[CandidateScoreRow]:
|
||||
"""Return every computed minute row while reusing one static chart scan per candidate."""
|
||||
contexts = list(static_contexts) if static_contexts is not None else compute_candidate_static_contexts(request, candidates=candidates)
|
||||
return [_candidate_row(request, context) for context in contexts]
|
||||
transit_chart_cache: dict[tuple[Any, ...], dict[str, Any]] = {}
|
||||
return [
|
||||
_candidate_row(request, {**context, "_transit_chart_cache": transit_chart_cache})
|
||||
for context in contexts
|
||||
]
|
||||
|
||||
@@ -697,7 +697,11 @@ def build_refinement_packet(
|
||||
"unique_minute_claim": False,
|
||||
"confirmation_allowed": False,
|
||||
}
|
||||
from scripts.rectification.candidate_contrast import PROBE_PHASE_HOLDOUT_VALIDATION, event_year
|
||||
from scripts.rectification.candidate_contrast import (
|
||||
PROBE_PHASE_HOLDOUT_VALIDATION,
|
||||
event_year,
|
||||
opportunity_from_probe,
|
||||
)
|
||||
from scripts.rectification.case_holdout import reserved_holdout_events
|
||||
from scripts.rectification.event_probes import (
|
||||
candidate_contrast_opportunities,
|
||||
@@ -729,13 +733,16 @@ def build_refinement_packet(
|
||||
dropped = list(bundle["dropped"])
|
||||
clarification = event_clarification_probes(request)
|
||||
collection = evidence_collection_probes(request)
|
||||
opportunities = candidate_contrast_opportunities(
|
||||
request,
|
||||
built,
|
||||
scan=scan,
|
||||
candidate_times=grid_times,
|
||||
representative_time=representative_time,
|
||||
)
|
||||
if probe_times == grid_times:
|
||||
opportunities = [opportunity_from_probe(probe) for probe in probes]
|
||||
else:
|
||||
opportunities = candidate_contrast_opportunities(
|
||||
request,
|
||||
built,
|
||||
scan=scan,
|
||||
candidate_times=grid_times,
|
||||
representative_time=representative_time,
|
||||
)
|
||||
reserved = reserved_holdout_events(request.get("events") or [])
|
||||
holdout = [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ import json
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import date, timedelta
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from scripts.active_rectification_event_engine import compute_candidate_static_contexts, compute_event_candidate_rows
|
||||
@@ -126,11 +125,6 @@ def _canonical(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]:
|
||||
return tuple(compute_event_candidate_rows(json.loads(serialized)))
|
||||
|
||||
|
||||
_SUPPORT_RULES = (
|
||||
"functional_benefic_auxiliary",
|
||||
"arudha_auxiliary",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
"""Memoization for candidate-minute invariants in the rectification engine.
|
||||
|
||||
Golden payload in tests/golden/rectification_engine_memoization_v1.json was
|
||||
produced from origin/staging @ a8d29d1b before any memoization landed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from scripts.active_rectification_event_engine import (
|
||||
_candidate_datetimes,
|
||||
_controlled_transit_rules,
|
||||
_shadbala_verified_components_auxiliary,
|
||||
build_candidate_static_context,
|
||||
compute_event_candidate_rows,
|
||||
)
|
||||
from scripts.rectification.api_service import score_candidates
|
||||
from scripts.rectification.candidate_contrast import opportunity_from_probe
|
||||
from scripts.rectification.contracts import normalize_rectification_request
|
||||
from scripts.rectification.refinement_packet import build_refinement_packet
|
||||
from scripts.rectification.scoring_service import sample_event_dates, scoreable_request
|
||||
import scripts.active_rectification_event_engine as event_engine
|
||||
import scripts.rectification.event_probes as event_probes
|
||||
import scripts.rectification.scoring_service as scoring_service
|
||||
import shadbala
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v1.json"
|
||||
FROZEN_TODAY = date(2026, 9, 16)
|
||||
TIMING_KEYS = frozenset({"column_compare_ms"})
|
||||
SOURCE_COMMIT = "a8d29d1b6cc37ff865ddec6c8bccdf9aa889ee53"
|
||||
|
||||
|
||||
def public_score_request() -> dict[str, Any]:
|
||||
"""Fictional events on the public 1990-01-01 Beijing smoke chart."""
|
||||
return {
|
||||
"birth_date": "1990-01-01",
|
||||
"start_time": "12:00",
|
||||
"end_time": "12:02",
|
||||
"lat": 39.9,
|
||||
"lon": 116.4,
|
||||
"tz": 8,
|
||||
"events": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000001",
|
||||
"domain": "education",
|
||||
"event_kind": "education_start",
|
||||
"date_start": "2008-01-01",
|
||||
"date_end": "2008-12-31",
|
||||
"precision": "year",
|
||||
"summary": "入学",
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000002",
|
||||
"domain": "career",
|
||||
"event_kind": "career_entry",
|
||||
"date_start": "2012-06-01",
|
||||
"date_end": "2012-06-30",
|
||||
"precision": "month",
|
||||
"summary": "入职",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _strip_timing(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _strip_timing(item)
|
||||
for key, item in value.items()
|
||||
if key not in TIMING_KEYS
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_strip_timing(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _normalized_request() -> dict[str, Any]:
|
||||
return normalize_rectification_request(public_score_request(), today=FROZEN_TODAY)
|
||||
|
||||
|
||||
def _golden_payload() -> dict[str, Any]:
|
||||
scored = score_candidates(_normalized_request())
|
||||
return {
|
||||
"source_commit": SOURCE_COMMIT,
|
||||
"candidate_scores": scored["candidate_scores"],
|
||||
"decision_receipt": _strip_timing(scored["decision_receipt"]),
|
||||
"candidate_feature_snapshot": scored["candidate_feature_snapshot"],
|
||||
}
|
||||
|
||||
|
||||
def write_golden(path: Path = GOLDEN_PATH) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(_golden_payload(), ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _count_calls(monkeypatch, owner: Any, name: str, *, from_engine: bool = False) -> list[int]:
|
||||
original = getattr(owner, name)
|
||||
counter = [0]
|
||||
|
||||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
if from_engine:
|
||||
caller = inspect.stack()[1].filename.replace("\\", "/")
|
||||
if not caller.endswith("/active_rectification_event_engine.py"):
|
||||
return original(*args, **kwargs)
|
||||
counter[0] += 1
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(owner, name, wrapped)
|
||||
return counter
|
||||
|
||||
|
||||
def test_score_candidates_matches_baseline_golden() -> None:
|
||||
expected = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||||
actual = json.loads(json.dumps(_golden_payload(), ensure_ascii=True))
|
||||
assert actual["candidate_scores"] == expected["candidate_scores"]
|
||||
assert actual["decision_receipt"] == expected["decision_receipt"]
|
||||
|
||||
|
||||
def test_shadbala_verified_fields_match_with_and_without_birth_minute() -> None:
|
||||
request = {
|
||||
"birth_date": "1990-01-01",
|
||||
"start_time": "12:17",
|
||||
"end_time": "12:17",
|
||||
"lat": 39.9,
|
||||
"lon": 116.4,
|
||||
"tz": 8,
|
||||
"events": [],
|
||||
}
|
||||
candidate_at = datetime(1990, 1, 1, 12, 17)
|
||||
context = build_candidate_static_context(request, candidate_at)
|
||||
chart = context["chart"]
|
||||
planets = chart.get("planets", {})
|
||||
sun = float(planets["Sun"]["lon"])
|
||||
moon = float(planets["Moon"]["lon"])
|
||||
sign = str(chart["ascendant"].get("sign"))
|
||||
birth_hour = candidate_at.hour + candidate_at.minute / 60
|
||||
with_minute = shadbala.calc_shadbala(
|
||||
planets,
|
||||
sign,
|
||||
birth_hour,
|
||||
sun,
|
||||
moon,
|
||||
birth_minute=float(candidate_at.minute),
|
||||
)
|
||||
without_minute = shadbala.calc_shadbala(planets, sign, birth_hour, sun, moon)
|
||||
fields_with = {
|
||||
planet: (
|
||||
float((row.get("sthana_bala") or {}).get("total", 0)),
|
||||
float(row.get("drik_bala", 0)),
|
||||
float(row.get("naisargika_bala", 0)),
|
||||
)
|
||||
for planet, row in (with_minute.get("planets") or {}).items()
|
||||
}
|
||||
fields_without = {
|
||||
planet: (
|
||||
float((row.get("sthana_bala") or {}).get("total", 0)),
|
||||
float(row.get("drik_bala", 0)),
|
||||
float(row.get("naisargika_bala", 0)),
|
||||
)
|
||||
for planet, row in (without_minute.get("planets") or {}).items()
|
||||
}
|
||||
assert fields_with == fields_without
|
||||
reused_rules, reused_points = _shadbala_verified_components_auxiliary(
|
||||
chart, birth_hour, ("Sun", "Moon", "Mars"), shadbala_result=with_minute,
|
||||
)
|
||||
fresh_rules, fresh_points = _shadbala_verified_components_auxiliary(
|
||||
chart, birth_hour, ("Sun", "Moon", "Mars"),
|
||||
)
|
||||
assert reused_rules == fresh_rules
|
||||
assert reused_points == fresh_points
|
||||
|
||||
|
||||
def test_shadbala_and_ashtakavarga_run_once_per_candidate(monkeypatch) -> None:
|
||||
shadbala_calls = _count_calls(monkeypatch, event_engine.shadbala, "calc_shadbala")
|
||||
ashtakavarga_calls = _count_calls(monkeypatch, event_engine.ashtakavarga, "calc_ashtakavarga")
|
||||
request = _normalized_request()
|
||||
scored = score_candidates(request)
|
||||
candidate_count = len(scored["candidate_scores"])
|
||||
assert candidate_count >= 2
|
||||
assert len(request["events"]) >= 2
|
||||
year_samples = sample_event_dates(request["events"][0])
|
||||
assert request["events"][0]["precision"] == "year"
|
||||
assert len(year_samples) >= 2
|
||||
assert shadbala_calls[0] == candidate_count
|
||||
assert ashtakavarga_calls[0] == candidate_count
|
||||
|
||||
|
||||
def test_dasha_timelines_run_once_per_candidate(monkeypatch) -> None:
|
||||
# Year-only events skip dasha-transition proximity, which also calls these
|
||||
# two functions from event_probes. The scoring path itself must stay 1×/minute.
|
||||
body = public_score_request()
|
||||
body["events"][1]["precision"] = "year"
|
||||
body["events"][1]["date_start"] = "2012-01-01"
|
||||
body["events"][1]["date_end"] = "2012-12-31"
|
||||
request = normalize_rectification_request(body, today=FROZEN_TODAY)
|
||||
vim_calls = _count_calls(
|
||||
monkeypatch, event_engine.dasha_analyzer, "build_dasha_timeline", from_engine=True,
|
||||
)
|
||||
narayana_calls = _count_calls(
|
||||
monkeypatch, event_engine.narayana_dasha, "calc_narayana_mahadasha", from_engine=True,
|
||||
)
|
||||
scored = score_candidates(request)
|
||||
candidate_count = len(scored["candidate_scores"])
|
||||
assert len(request["events"]) >= 2
|
||||
assert vim_calls[0] == candidate_count
|
||||
assert narayana_calls[0] == candidate_count
|
||||
|
||||
|
||||
def test_transit_charts_match_unique_event_dates_not_candidates(monkeypatch) -> None:
|
||||
transit_calls = [0]
|
||||
original = event_engine.domain_calculation_service.compute_chart
|
||||
|
||||
def wrapped(payload: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
|
||||
# Natal candidates in this fixture also sit at 12:00; transit charts use the event year.
|
||||
if payload.get("hour") == 12 and payload.get("minute") == 0 and payload.get("year") != 1990:
|
||||
transit_calls[0] += 1
|
||||
return original(payload, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(event_engine.domain_calculation_service, "compute_chart", wrapped)
|
||||
request = _normalized_request()
|
||||
scored = score_candidates(request)
|
||||
candidate_count = len(scored["candidate_scores"])
|
||||
expected_dates = {
|
||||
sampled
|
||||
for event in scoreable_request(request)["events"]
|
||||
for sampled in sample_event_dates(event)
|
||||
}
|
||||
assert candidate_count >= 2
|
||||
assert len(expected_dates) >= 2
|
||||
assert transit_calls[0] == len(expected_dates)
|
||||
assert transit_calls[0] != candidate_count * len(expected_dates)
|
||||
|
||||
|
||||
def test_year_precision_transits_still_short_circuit() -> None:
|
||||
request = {
|
||||
"birth_date": "1990-01-01",
|
||||
"start_time": "12:00",
|
||||
"end_time": "12:00",
|
||||
"lat": 39.9,
|
||||
"lon": 116.4,
|
||||
"tz": 8,
|
||||
"events": [{
|
||||
"id": "00000000-0000-4000-8000-000000000009",
|
||||
"domain": "career",
|
||||
"event_kind": "career_entry",
|
||||
"date": "2012",
|
||||
"precision": "year",
|
||||
"summary": "入职",
|
||||
}],
|
||||
}
|
||||
with patch.object(event_engine.domain_calculation_service, "compute_chart") as compute_chart:
|
||||
rules = _controlled_transit_rules(request, request["events"][0], 0, (10,))
|
||||
assert rules == []
|
||||
compute_chart.assert_not_called()
|
||||
|
||||
|
||||
def _probe_request_and_built() -> tuple[dict[str, Any], dict[str, Any], list[str]]:
|
||||
body = public_score_request()
|
||||
body["events"] = [
|
||||
{
|
||||
"id": f"00000000-0000-4000-8000-{index:012d}",
|
||||
"domain": domain,
|
||||
"event_kind": kind,
|
||||
"date_start": "2012-01-01",
|
||||
"date_end": "2012-12-31",
|
||||
"precision": "year",
|
||||
"summary": kind,
|
||||
}
|
||||
for index, (domain, kind) in enumerate(
|
||||
(
|
||||
("education", "education_start"),
|
||||
("career", "career_entry"),
|
||||
("relationship", "relationship_start"),
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
]
|
||||
request = normalize_rectification_request(body, today=FROZEN_TODAY)
|
||||
times = ["12:00", "12:01", "12:02"]
|
||||
event_ids = [event["id"] for event in request["events"]]
|
||||
built = {
|
||||
"candidate_times": times,
|
||||
"matrix": {
|
||||
event_id: {
|
||||
clock: {"points": 4, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]}
|
||||
for clock in times
|
||||
}
|
||||
for event_id in event_ids
|
||||
},
|
||||
"date_sensitivity": [],
|
||||
"missing_layers": [],
|
||||
"static_contexts": [],
|
||||
}
|
||||
return request, built, times
|
||||
|
||||
|
||||
def test_discriminating_probes_run_once_when_probe_times_equal_grid(monkeypatch) -> None:
|
||||
calls = _count_calls(monkeypatch, event_probes, "_discriminating_event_probe_lists")
|
||||
request, built, times = _probe_request_and_built()
|
||||
packet = build_refinement_packet(
|
||||
request,
|
||||
built,
|
||||
representative_time="12:00",
|
||||
candidate_times=times,
|
||||
)
|
||||
assert calls[0] == 1
|
||||
assert packet["candidate_contrast_opportunities"] == [
|
||||
opportunity_from_probe(probe) for probe in packet["discriminating_event_probes"]
|
||||
]
|
||||
|
||||
|
||||
def test_discriminating_probes_run_twice_on_refresh_columns(monkeypatch) -> None:
|
||||
calls = _count_calls(monkeypatch, event_probes, "_discriminating_event_probe_lists")
|
||||
request, built, times = _probe_request_and_built()
|
||||
request = {**request, "refresh_probes": True}
|
||||
packet = build_refinement_packet(
|
||||
request,
|
||||
built,
|
||||
representative_time="12:00",
|
||||
candidate_times=times,
|
||||
column_times=["12:00"],
|
||||
)
|
||||
assert times != ["12:00"]
|
||||
assert calls[0] == 2
|
||||
assert "discriminating_event_probes" in packet
|
||||
assert "candidate_contrast_opportunities" in packet
|
||||
|
||||
|
||||
def test_dead_row_cache_removed_from_scoring_service() -> None:
|
||||
token = "_cached_" + "rows"
|
||||
assert not hasattr(scoring_service, token)
|
||||
hits: list[str] = []
|
||||
for folder in ("scripts", "tests"):
|
||||
for path in (ROOT / folder).rglob("*.py"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if token in text:
|
||||
hits.append(str(path.relative_to(ROOT)).replace("\\", "/"))
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_compute_event_candidate_rows_reuses_static_context_without_mutating_it() -> None:
|
||||
request = {
|
||||
"birth_date": "1990-01-01",
|
||||
"start_time": "12:00",
|
||||
"end_time": "12:01",
|
||||
"lat": 39.9,
|
||||
"lon": 116.4,
|
||||
"tz": 8,
|
||||
"events": [{
|
||||
"id": "00000000-0000-4000-8000-000000000003",
|
||||
"domain": "career",
|
||||
"event_kind": "career_entry",
|
||||
"date": "2012-06-15",
|
||||
"precision": "day",
|
||||
"summary": "入职",
|
||||
}],
|
||||
}
|
||||
contexts = [
|
||||
build_candidate_static_context(request, candidate)
|
||||
for candidate in _candidate_datetimes(request)
|
||||
]
|
||||
original_keys = [frozenset(context) for context in contexts]
|
||||
rows = compute_event_candidate_rows(request, static_contexts=contexts)
|
||||
assert len(rows) == 2
|
||||
assert [frozenset(context) for context in contexts] == original_keys
|
||||
assert all("_transit_chart_cache" not in context for context in contexts)
|
||||
Reference in New Issue
Block a user