From 6df8d1578a49991147402dfc3ebf4be1a9a06094 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 26 Oct 2026 23:52:10 +0800 Subject: [PATCH 01/10] docs(research): competitor past-event check study (agent loop, issue #112) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3 --- .../competitor_past_event_check_2026_10_27.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/research/competitor_past_event_check_2026_10_27.md diff --git a/docs/research/competitor_past_event_check_2026_10_27.md b/docs/research/competitor_past_event_check_2026_10_27.md new file mode 100644 index 00000000..fb282ab7 --- /dev/null +++ b/docs/research/competitor_past_event_check_2026_10_27.md @@ -0,0 +1,51 @@ +# 竞品「推断前事」调研(issue #112,2026-10-27) + +产品负责人 10-26 转来某竞品的「推断前事 / 验前事」页面全文(真实用户的盘,本文只描述做法,不抄其中的盘面结论与推导),问三件事:它怎么做、能不能收敛、我们能不能做到。 + +## 1. 它怎么做 + +| 步骤 | 内容 | +| --- | --- | +| 定位 | 开头写明:用来**确认上升星座(Lagna)对不对**,**不能**把出生时间校准准;判断多数对得上只说明上升「很可能对」,不证明时间准确;报告照用户填的时间写,不因这一步改 | +| 出题 | 一次给 **6 条**断语:2 条本命静态(父亲、母亲,按 9 宫 / 4 宫宫主与太阳、月亮的状态)+ 4 条带年份的事件(按 Vimshottari 当前大运下各小运窗口 1~3 年,读该小运主星掌管与落入的宫) | +| 透明度 | 每条附一句白话理由,可展开「原始推导」(宫主、落宫、尊贵、小运) | +| 作答 | 每条三选:准 / 部分准 / 不准;可选补充;明确「不准就记不准,不会换说法圆回来」 | +| 结果 | 只给「本轮吻合 x/6」,说明没对上可能是模型没看准、也可能是时间有偏差;**两条路都放行**(照填写时间继续,或找人工校准) | +| 上升交界 | 上升压在星座交界时,「必要时会换个上升再比一轮」 | +| 模型 | 自述是「小模型的快速预判」,后面会换更强的模型 | + +## 2. 能不能收敛 + +**不能,而且它自己也没打算收敛。** 理由三条: + +1. **没有被比较的候选。** 它只对「用户填的那个时间」出一组断语,打一个吻合分;没有第二个上升或第二个时间参与比较(交界时才比一轮)。没有候选之间的比较,就谈不上收敛,只是一个「可信度提示」。 +2. **带年份的四条,时间窗口与上升无关。** Vimshottari 大运 / 小运的起止只由出生时月亮的星宿决定,换上升不换窗口;换上升只改「这个小运读成什么事」。而这类事件(某三年里有重要感情、某年因工作搬家、某年关系突变、某年事业退一步并有花销)对成年人的基础发生率本来就高,用户多半会答「准 / 部分准」,**吻合分对上升的区分力很弱**。 +3. **我们自己量过同类信息的区分力。** 在 77 例公开名人盘(v5 评价集)上: + - 用户口述的经历几乎分不开候选:57 件日精度经历里能把候选分开的只有 4~14 件(`docs/research/` 09-29 研究,BUG-1089 按设计关闭); + - 10-16 复现网上流行的「象征星小运区间求交」法:分钟级与随机对照无差别,上升星座二选一略好于随机但不显著(z≈1.3); + - 真正有区分力的是我们现在的做法:**引擎按候选出选择题、逐题比较**,六题后 ±10 分钟窗口里 D1 上升命中 76/77(`docs/research/rectification_varga_resolution_2026_09_30.md` M1)。 + +所以竞品的 4/6 这类分数,主要反映「断语写得是否宽泛」,而不是「上升是否正确」;它把这一点诚实地写在了页面上。 + +## 3. 我们能不能做到 + +**能,而且我们已经有比它强的那一半;值得学的是它的「说法」和一个很便宜的预检。** + +| 竞品做的 | 我们现状 | 判断 | +| --- | --- | --- | +| 确认上升对不对 | 生时校正六题后 ±10 窗 D1 上升命中 76/77;±10 窗里 64/77 例**整窗只有一个上升**(不用问就能确认) | 我们更强,而且大多数人**根本不用问** | +| 6 条宽泛断语 + 吻合分 | 不做吻合分;选择题由引擎在候选之间出 | 不建议照搬:区分力弱,还会让用户以为「4/6 = 时间基本对」 | +| 开头讲清楚限度、「不准不圆」 | 校正开场有说明,但没有这么直白的「这一步不能做什么」 | **值得学** | +| 每条附白话理由 + 可展开原始推导 | 选择题不附理由 | 可学,但只能在答完之后展示(答前展示会诱导作答) | +| 上升压交界时换上升再比 | 采集页按用户的时间精度扫窗口;没有单独的「交界预检」 | **值得做**:纯计算、不问问题——窗口里上升不换,就直接告诉用户「上升确定」;窗口跨交界,才引导去校正 | +| 两条路都放行 | 一致(不强制校正) | — | + +## 4. 建议(交产品拍板) + +- **A(推荐)**:不做吻合分。做两件小事: + 1. **上升交界预检**:用户填时间 + 精度后,纯计算判断精度窗口内上升是否换星座;不换就在星盘页 / 首页说一句「按你给的时间精度,上升星座是确定的」,换就给出「上升可能是 X 或 Y」并引导校正。不调用模型、不新增问题。 + 2. **校正开场文案**照竞品的诚实口径改写:这一步能做什么、不能做什么、答「不准」就记不准。 +- **B**:照做「6 条快速预判 + 吻合分」作为入口体验。前提是先在 77 例上量:真上升与相邻上升的吻合分差多少;差异不显著就不上线(预计不显著,见第 2 节)。 +- **C**:都不做。 + +选 A 时由 Claude 写任务书交循环;选 B 先出研究单。 From cef7b317aef62f71e737d407f203eacb343dfb1f Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 27 Oct 2026 00:59:36 +0800 Subject: [PATCH 02/10] docs(tasks): lagna precheck + rectification opener limits brief (agent loop, issue #112) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3 --- docs/tasks/README.md | 1 + docs/tasks/TASK-lagna-precheck-20261027.md | 140 +++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 docs/tasks/TASK-lagna-precheck-20261027.md diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 284f5700..8a9484a7 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -536,3 +536,4 @@ | `TASK-full-data-parity-1-fix-20261022.md` | `PROGRESS-full-data-parity-1-fix-20261022.md` | **#91 A 段第 1 次打回**:新六章 69 节里 16 节在三张虚构盘上整节「未判定」,其中 12 节数据在资料包里、是读错了字段;测试抓不到整节未判定;新六章绕过了报告口径替换;中文附录悄悄丢列和瑜伽条目要量出来 | 已合入 staging | `codex/full-data-parity-1-20261021` | | `TASK-full-data-parity-1-b-fix-20261023.md` | `PROGRESS-full-data-parity-1-b-20261023.md` | **#91 B 段修复**:K1–K3 已交;K4 年运键序与 Yogini 子运字段已补(BUG-1425、BUG-1426) | 已合入 staging | `codex/full-data-parity-1-20261021` | | — | `PROGRESS-full-data-parity-1-fix2-20261022.md` | **#91 A 段第 2 次打回**(Claude 口头修复单 F1~F4):11 节只印「未判定」和键名改读真实记录;测试只认非第一列真实值、禁止键名格;中文瑜伽组合句由中文源给出(BUG-1418);六章降为 `##`、节 `###`(BUG-1419) | 本地完成,待验收 | `codex/full-data-parity-1-fix2-20261022` | +| `TASK-lagna-precheck-20261027.md` | — | **#112 选 A**:星盘页页头告诉用户上升在其时间精度内会不会换(复用现成的 D1 窗口扫描,纯计算);校正开场说清能做 / 不能做(BUG-1444、BUG-1445) | 待执行 | `codex/lagna-precheck-20261027` | diff --git a/docs/tasks/TASK-lagna-precheck-20261027.md b/docs/tasks/TASK-lagna-precheck-20261027.md new file mode 100644 index 00000000..abb69637 --- /dev/null +++ b/docs/tasks/TASK-lagna-precheck-20261027.md @@ -0,0 +1,140 @@ +# 任务书:上升交界预检 + 校正开场说清限度(issue #112,2026-10-27) + +- 执行方:Grok。分支 `codex/lagna-precheck-20261027`,worktree `.worktrees/lagna-precheck-20261027`。 +- 来源:`docs/research/competitor_past_event_check_2026_10_27.md`(竞品「推断前事」调研);产品负责人 10-27 在 #112 选 **A**。 + +## 基线 + +- `origin/staging` = `6df8d1578`(开工时以实测为准)。 +- 同期在途:#111(组件库与新手引导修复单,分支 `codex/ui-kit-onboarding-fix-20261026`)改 `ShellFrame`、`globals.css` 等。本单只碰星盘页页头一行与校正开场文案,**不改 `ShellFrame` / `globals.css` 的既有规则**;交回前 `git merge origin/staging`,若 #111 已合入,以合入后的为准解冲突。 + +## 1. 现状实证(按符号定位) + +### 1.1 用户已经告诉我们「时间有多准」 + +- 资料表与人物档案都存了精度:`birth_time_source`(`hospital_record` / `family_exact` / `approximate` / `period_only` / `unknown` / `legacy_import`)、`uncertainty_before_minutes` / `uncertainty_after_minutes`、`declared_window_start` / `declared_window_end`、`birth_time_status`(迁移 `20260717020000_birth_time_journey.sql`、`20260822010000_declared_birth_window_clocks.sql`、`20260925020000_chart_subject_typed_columns.sql`)。 +- 表单:`frontend/src/components/birth-time-intake.tsx` 的 `BirthTimeIntakeFields`;选项在 `frontend/src/lib/birth-time-intake-model.ts`:`approximateUncertaintyOptions`(15 / 30 / 60 / 120 分钟),`birthTimeSourceDefaults.hospital_record` 前后各 2 分钟;时段窗口由 `declaredWindowRangeForDraft` 给出。 +- 表单里对医院记录写着「系统会无感检查前后 2 分钟的上升与 D9 / D10 稳定性」——**但星盘页从来没把检查结果告诉用户**。 + +### 1.2 「整窗只有一个上升」已经能算,而且很快 + +- `scripts/rectification_varga_segments_api.py::scan_varga_segments`,接口 `POST /api/rectification/v5/varga-segments`,传 `target_charts: ["D1"]` 返回 `segments.D1`(每段起止与星座号)和 `full_window_unique.D1`,自带磁盘缓存;岁差走 `_segment_ayanamsa`(BUG-1309)。 +- Claude 本机实测(Linux,虚构地点,冷缓存,只算 D1):前后 15 分钟 31 分钟 **0.02 秒**;前后 2 小时 241 分钟 **0.09 秒**;整天 1441 分钟 **0.54 秒**。所以可以和星盘一起算、一起揭幕,不需要单独等待。 +- 目前只有生时校正调用它:`frontend/src/lib/rectification-agentic/opening-boundary.ts::fetchOpeningBoundary`、`v9/segment-scan.ts::scanCaseSegments`。 + +### 1.3 星盘页 + +- `frontend/src/components/chart-page/chart-page-view.tsx::ChartPageView`:页头 `birthline`(日期 / 时间 / 地点 + 铅笔)作为 `note` 传给 `SecondaryPageShell`;上升只出现在 Vedic 表格那一行(`chart-vedic-tab.tsx`,`label="上升"`)。 +- 取数:`useChartPage` → `fetchChartView` → `GET /api/chart-view` → `frontend/src/lib/chart-view-service.ts::loadChartView`(`loadSubjectBirth` 拿到出生资料后 `postEngine` 调 Python `POST /api/chart`)。 + +### 1.4 校正开场 + +- 固定句:`frontend/src/lib/rectification-agentic/user-copy.ts::openingSpokenBody`(「我们来把你的出生时间缩小到更准的范围……做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」)与 `openingBoundarySentence`;模型写的开场由 `isAcceptableOpeningBody` 把关,不合格就换成固定句(`v9/agent-run-attempt.ts`)。 +- 模型开场提示:`v9/agent-run-messages.ts` 的开场简报段;口吻在 `frontend/src/mastra/agentic-rectification.ts`(BUG-1257)。 +- 开场**没有说**这一步做不到什么、答「不准」会怎样、可以随时停。 + +## 2. 根因 + +1. 「上升在你给的精度内会不会换」这件事我们早就会算,但只在用户走进生时校正之后才用;星盘页对 64/77(±10 分钟,v5 评价集)根本不用问的人什么也没说,对上升会换的人也没提醒。 +2. 校正开场只讲「怎么做」,不讲「能做到什么程度」;竞品把限度写在最前面,读起来更可信。 + +## 3. 决策记录(产品负责人 10-27,#112 选 A) + +1. **不做**竞品那种「6 条断语 + 吻合分」,也不新增任何提问。 +2. 上升交界预检:纯计算,不调模型,不扣点。 +3. 校正开场按竞品的诚实口径改写:能做什么、不能做什么、答「不准」就记不准、可以随时停下照原时间用。 +4. 显示位置:**星盘页页头**(本人和人物档案里每个人的星盘页)。首页今天没有任何出生资料展示位,本单不在首页新增;要加另立单。 +5. 「上升确定」**只说上升**,不说「不用校正」——09-30 研究(`rectification_varga_resolution_2026_09_30.md`)证明「不用校正」只对上升成立,九分盘 / 十分盘 0/77。 + +## 4. 硬红线 + +1. 不改数据库结构,不加迁移。 +2. `scripts/jyotish_api_server.py` 不加方法、不加路由(复用现有 `varga-segments` 接口);`frontend/src/app/(app)/page.tsx` 不动。 +3. 不调模型,不加新问题,不改校正打分、候选、冻结文件(`references/rectification_sealed_holdout.v1.json` 等)。 +4. 一次揭幕:这一行和星盘一起出现;算不出来(超时、接口错、资料不全)就**整行不显示**,不出 spinner、骨架或「正在检查」。预检失败不得让星盘页失败。 +5. 文案对照 `frontend/docs/VOICE.md`:不用「分盘」「候选」「区间」「Lagna」「D1」等词;星座用中文名。 +6. 改 UI 同提交更新 `frontend/DESIGN.md`;首屏 gzip ±2%(本单不碰首页,预期不变)。 +7. 改既有断言写「原值 / 新值 / 原因」三栏(AGENTS §7.3、§7.8)。下列测试按文本断言开场文案,必须同轮改:`rectification-opening-plain-20260926.test.ts(x)`、`rectification-nearest-boundary-opening-20261012.test.ts`、`rectification-v9-agent.test.ts`、`agent-voice-copy-contract.test.ts`;另 grep `openingSpokenBody` / `isAcceptableOpeningBody` 的其余引用。 +8. 隐私:测试只用虚构盘。 + +## 5. 任务分解(一条一个提交) + +### L1 · 精度窗口(前端纯函数) + +新增 `frontend/src/lib/lagna-precheck.ts`(名字可调): + +- 输入:出生资料(同 `loadSubjectBirth` 返回的形状)。输出:`{ start_at, end_at, minutes } | null`。 +- 规则: + - `hospital_record`、`family_exact`、`approximate`:报告时间 −`uncertainty_before_minutes` 到 +`uncertainty_after_minutes`(缺值按 `birthTimeSourceDefaults`)。 + - `period_only`:`declared_window_start`–`declared_window_end`(跨午夜照 `declaredWindowRangeForDraft` 的口径)。 + - `unknown`:当天 00:00–23:59。 + - `birth_time_status` 为已确认(采用了校正结果)或 `legacy_import`:返回 `null`(不显示这一行)。 + - 窗口超过 1441 分钟时截到 1441(接口上限)。 +- 验收:单测覆盖以上每种来源,含跨午夜时段、缺失不确定度时用默认值。 + +### L2 · 星盘页拿到结果(与星盘同一次请求) + +- `loadChartView` 在拿到出生资料后,与 `/api/chart` **并行**调用 `/api/rectification/v5/varga-segments`(`target_charts: ["D1"]`,岁差跟这张星盘当前的岁差设置一致,经纬度与时区同星盘)。单独超时 3 秒;失败就不带这段。 +- `ChartViewResponse` 加一个可选字段(例如 `lagnaPrecheck: { state: "fixed" | "may_change" | "unknown_time"; signs: string[]; changeAt?: string[]; windowMinutes }`),映射在 `chart-view-mapper.ts`;没有就不显示。 +- 只有本人或人物档案里有权看这张盘的人能拿到(沿用 `loadChartView` 现有鉴权,不新开路由)。 +- 验收:单测——接口返回单段 → `fixed`;两段 → `may_change` 且带换座时刻;接口抛错 / 超时 → 字段缺省、星盘照常 200。 + +### L3 · 页头一行文案 + +在 `ChartPageView` 页头 `birthline` 下面加一行小字(样式同页头说明文字,不加卡片、不加图标),文案: + +| 情况 | 文案 | +| --- | --- | +| 窗口内上升不换 | 按你给的时间精度(前后约 N 分钟),上升星座都是{X}座,这一点不会因为时间误差而变。 | +| 窗口内会换(两个星座) | 按你给的时间精度,上升可能是{X}座,也可能是{Y}座(大约 {HH:MM} 换座)。想定下来可以做一次出生时间校正。 | +| 会换(三个及以上) | 按你给的时间段,上升可能落在{X}座、{Y}座、{Z}座中的一个。想定下来可以做一次出生时间校正。 | +| 不知道出生时间 | 不知道出生时间时,上升星座定不下来;做一次出生时间校正可以缩小范围。 | + +- 「出生时间校正」做成链接,进入现有的校正入口(复用现有入口的路由与参数,人物档案里的人进入他自己的校正);**确定**那一行没有链接。 +- 时段窗口(`period_only`)把「前后约 N 分钟」换成「按你给的时间段({开始}–{结束})」。 +- 英文界面若星盘页有英文版,照同样四句给英文;没有就只做中文。 +- 验收:组件测试四种情况各一条;`fixed` 没有校正链接;字段缺省时 DOM 里没有这一行。`frontend/DESIGN.md` 记一句这行的位置与样式。 + +### L4 · 校正开场说清限度 + +改 `openingSpokenBody`(固定句)并同步模型开场简报(`agent-run-messages.ts`),开场正文改成三层意思,口吻按 BUG-1257 的姐姐口吻: + +1. 做什么:「我们来把你的出生时间缩小到更准的范围,现在先在 {窗口} 之间找。」(保留现有 `productDelivery` 分支的说法) +2. 怎么做:「你说几件人生里的大事和大概年月,我拿去和星盘对照。」 +3. **限度(新增)**:「这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。中途想停也可以,照你原来填的时间用。」 + +- `openingBoundarySentence` 保留,位置在 1 和 2 之间不变。 +- `isAcceptableOpeningBody`:模型写的开场必须含限度这层意思(以「不保证」或「精确到某一分钟」与「不准」两处为判据),否则回落固定句;`OPENING_BODY_JARGON` 不放宽。 +- 验收:上面第 4 节第 7 条列的测试按三栏改完并通过;新增一条:模型开场缺限度句 → 回落固定句。 + +## 6. 验收分工 + +| 项 | 谁 | +| --- | --- | +| `tsc --noEmit` 0 错、`npm run lint` 0 error、`npm test` 全量与 staging 基线逐条一致 | Grok 跑,Claude 复验 | +| `next build` 后 `/` 仍 Static,首屏 gzip ±2% | Grok 跑,Claude 复验 | +| Python:本单不改 Python;若碰了 `scripts/**` 或 `tests/**`,跑快速门全量 | Grok | +| 浏览器真机:本人星盘页(医院记录 / 大概时间 / 时段三种资料各一次)、人物档案里一个人、点链接进校正、校正开场读一遍 | 产品负责人(写进 `docs/testing/lagna-precheck-20261027.md`) | + +## 7. 让步顺序 + +时间不够时先保 L4(开场文案),再 L1→L2→L3;L3 的三个及以上星座那一句可以并进「会换」一句(列出所有星座)。不得为赶工跳过「失败就不显示」。 + +## 8. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/lagna-precheck-20261027 .worktrees/lagna-precheck-20261027 origin/staging +cd .worktrees/lagna-precheck-20261027/frontend && npm ci +./node_modules/.bin/tsc --noEmit && npm run lint && npm test # 记下基线失败清单 +grep -rn "openingSpokenBody\|isAcceptableOpeningBody\|openingBoundarySentence" tests/ src/ | grep -v node_modules +``` + +## 9. BUG 编号 + +开工时 `grep -o 'BUG-[0-9]*' docs/BUG_HISTORY.md | sort -u -V | tail -1`;当前最大 BUG-1443,本单从 **BUG-1444** 起: + +- BUG-1444:星盘页不告诉用户上升在其时间精度内是否会换(预检能力早已存在只给校正用)。 +- BUG-1445:校正开场不讲限度。 + +交付:只推分支,在 #112 回报 SHA,标签改「待验收」。 From 009456d8e1db5ac2b284809c6e3c9623f5842f42 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 27 Oct 2026 01:03:48 +0800 Subject: [PATCH 03/10] docs(research): report/chat voice vs competitor reading (agent loop, issue #113) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3 --- .../voice_vs_competitor_2026_10_27.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/research/voice_vs_competitor_2026_10_27.md diff --git a/docs/research/voice_vs_competitor_2026_10_27.md b/docs/research/voice_vs_competitor_2026_10_27.md new file mode 100644 index 00000000..08734614 --- /dev/null +++ b/docs/research/voice_vs_competitor_2026_10_27.md @@ -0,0 +1,78 @@ +# 语气对比:我方报告 / 对话 vs 竞品解读(issue #113,2026-10-27) + +产品负责人 10-27 转来某竞品对一张真实盘的完整解读(朋友评价「像占星师说的话」),说我方「agent 输出和报告输出的语气用户看不懂」,要求先查问题、做对比,敲定后由 Claude 改。竞品原文含真实个人资料,**本文只描述写法,不引用其中的盘面结论**;我方样本全部来自虚构盘或公开名人盘。 + +## 1. 样本 + +| 样本 | 来源 | 说明 | +| --- | --- | --- | +| 竞品 | #113 正文,约 9,300 汉字 | 章节:总评、性格与内在、事业、财富、情感、健康、灵性成长、行动建议、命盘结构、需留意的组合、大运解读 | +| 我方报告 | 虚构 0 号盘,完整数据版中文,staging `cef7b317a` 生产渲染路径 | 192 万字符、88 章 | +| 我方对话 | 名人回测 round4(10-02,Jobs 事业 / 婚恋)+ 10-01 新提示词(Obama 父母) | **都早于 10-06 去标题、10-07 姐姐口吻与提示词瘦身**;仓库里没有更新的整段真实回答 | + +## 2. 数字(每千汉字) + +| 样本 | 外文词 | 括注 | 「可能 / 或许 / 多半」类 | 「你」 | 表格行占比 | +| --- | --- | --- | --- | --- | --- | +| 竞品 | 12.2 | 8.7 | **0.5** | 12.8 | 0% | +| 我方报告·大运解释段 | 5.2 | 8.1 | **27.4** | **0** | 0% | +| 我方报告·全文 | 15.6 | 5.0 | 14.8 | **0** | **91%** | +| 我方对话(旧样本) | 0.3 | 10.4 | 1.3 | 16.0 | 0% | + +## 3. 竞品为什么读起来像占星师 + +1. **每段一条链:盘面 → 机制 → 生活里的样子。** 先点出一个配置,用一句话说它为什么这样起作用,再落到「你在工作 / 家里 / 钱上会怎样」。 +2. **术语当「招牌」用,第一次出现带中文括注。** 例如「全盘唯一的功能大吉星(Yogakaraka)」。读者不需要懂,但会感到「有门道」。 +3. **有张力、有化解。** 「然而……」「化解之道在于……」「先……后……」,把几条盘面因素编成一个人的故事,而不是逐项列条件。 +4. **按人生领域分章,开头有总评。** 用户最想读的「我是什么样的人、事业、钱、感情、健康、现在这几年」各有一章。 +5. **大运是叙事。** 给出具体起止日期,每个子运说「这段的焦点是什么、接下来会转向什么」。 +6. **第二人称、语气笃定**,全文几乎不出现「可能」。 + +竞品的问题(**不学**): + +- 健康章直接写出具体病名倾向,财富章写「严禁某类投资」——与我方红线(医疗、投资不下确定结论,AGENTS Part B4)冲突。 +- 「先苦后甜」「宿命式」这类确定性断语,没有任何不确定性提示。 +- 同一段「性格」在页面上重复了三次,排版有缺陷。 +- 辞藻偏重(「咆哮」「烟消云散」),信息密度不如看上去高。 + +## 4. 我方问题 + +### 4.1 报告(主要问题) + +报告**不是模型写的**,是引擎模板拼出来的(`scripts/pl9_interpretation_packs.py`),这是「看不懂」的主因: + +1. **没有解读正文。** 没有总评、没有性格章;88 章里 91% 的行是表格。最接近「性格」的是逐颗行星的条件说明。 +2. **每句都在免责。** 大运段的模板是「这段时间可能突出的主题:传统阅读里,X 容易把……推到眼前。条件好时可能……受压时可能……怎么细读:先核对……」,对每一个主运、子运、次子运重复。「可能」类词的密度是竞品的约 55 倍,全文没有一个「你」。 +3. **内部口径直接写给用户。** 例如「功能吉凶用本仓功能吉凶层,不另做一套判断」「取消条件以检测器返回的说明为准,这里不新判」「六维力量合计为 9.9407」,表格里还有 `mars_house=2;severity=中等` 这种字段名。 +4. **术语政策与对话相反。** 对话禁梵文外文;报告的术语白名单(`pl9_language_terms.py` 的 `ZH_ALLOWED_LATIN_TOKENS`)专门保留 Dasha、Shadbala、Ashtakavarga、Vimsopaka 等外文,且不加解释。罗睺在报告里叫「北交点」,在对话里叫「罗睺」。 +5. **重点卡只有字段值**(中性、入庙、功能凶星)加一句免责,没有一句结论。 + +### 4.2 对话(次要,需先拿到现行样本) + +- 规则层面(`frontend/src/mastra/product-voice.ts`、`frontend/docs/VOICE.md`)已经要求先给结论、第二人称、一段最多一个术语、不读心,旧样本的「你」密度、免责密度已接近竞品。 +- 旧样本的问题:括号里塞一串依据(「木星 10 宫、力量最强一档;兼 4 宫与 7 宫主;事业分盘第 11 宫」),读者跳过括号后剩下的是结论,但**缺「为什么」那一环**;竞品是把「为什么」用一句白话写进正文。 +- 10-06 / 10-07 之后的现行输出仓库里没有存档,判断现行对话前需要一份 staging 上的真实回答(产品负责人截图或贴文字即可)。 + +## 5. 改法选项(交产品负责人拍板) + +### 报告 + +- **R1(推荐):报告前面加一段模型写的「解读」**,按竞品的章节:总评、性格、事业、财富、感情、健康、现在这几年(当前大运与接下来两个子运)、几条具体建议。 + - 素材只用引擎已算好的事实(同对话的同源卡),不新增算法。 + - 写法照第 3 节 1~5,但守我方红线:不写病名、不给投资指令、不写「注定 / 先苦后甜」;免责每章最多一句,不逐句。 + - 原有表格和附录整体后移,标题改为「计算依据(附录)」。 + - 代价:每份报告多一次模型调用(约数十秒、有成本),要做 A/B(对 77 例里的名人盘人工读几份)。 +- **R2:只改模板,不加模型。** 去掉内部口径句与字段名、免责只留章首一句、改第二人称、术语第一次出现加中文括注。确定、便宜,但仍然只是逐条说明,**写不出「几条因素合成一个人」的叙事**。 +- 两者不冲突:可以 R2 先做(一两天),R1 随后。 + +### 对话 + +- **C1:保持「懂行的姐姐」口吻,吸收「盘面 → 机制 → 生活」的一句链。** 依据不再只塞括号,正文里用一句白话说出「为什么」;术语仍是中文、一段最多一个。 +- **C2:向竞品靠拢,允许关键术语保留原文并带中文括注**(如「功能大吉星(Yogakaraka)」)。会更「像占星师」,但推翻 10-01 起「不出现梵文外文」的决定(BUG-1132~1134)。 +- **C3:对话不动,只改报告。** + +## 6. 不管选哪个都守的线 + +- 不写病名、诊断、投资指令、确定性的死亡 / 婚姻结局(AGENTS Part B4)。 +- 候选出生时间不写成已确认。 +- 报告解读与对话用同一套术语(罗睺 / 计都统一)。 From c3f69ef7241e7c96947fa5787d8d1f2b0472fea0 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 27 Oct 2026 03:51:59 +0800 Subject: [PATCH 04/10] Add the ascendant precheck time window. Hospital, family, approximate, period, and unknown birth sources map to the scan window. Adopted times and legacy imports stay quiet. Ran tests/lagna-precheck.test.ts: 2 pass. Co-Authored-By: Grok (agent loop) --- frontend/src/lib/lagna-precheck.ts | 215 ++++++++++++++++++++++++++ frontend/tests/lagna-precheck.test.ts | 138 +++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 frontend/src/lib/lagna-precheck.ts create mode 100644 frontend/tests/lagna-precheck.test.ts diff --git a/frontend/src/lib/lagna-precheck.ts b/frontend/src/lib/lagna-precheck.ts new file mode 100644 index 00000000..8246c660 --- /dev/null +++ b/frontend/src/lib/lagna-precheck.ts @@ -0,0 +1,215 @@ +import { declaredRangeWrapsMidnight } from "./declared-birth-window.ts"; +import { + birthTimeSourceDefaults, + declaredWindowRangeForDraft, + isBirthClockTime, + parseBirthDate, + type BirthTimePeriod, +} from "./birth-time-intake-model.ts"; +import type { ChartViewLagnaPrecheck } from "./chart-view-contract.ts"; + +/** Inclusive minute cap of POST /api/rectification/v5/varga-segments. */ +const MAX_INCLUSIVE_MINUTES = 1441; + +export type LagnaPrecheckSource = + | "hospital_record" + | "family_exact" + | "approximate" + | "period_only" + | "unknown"; + +export type LagnaPrecheckWindow = { + readonly start_at: string; + readonly end_at: string; + readonly minutes: number; + readonly source: LagnaPrecheckSource; + readonly precisionKind: "minutes" | "period" | "unknown"; + readonly beforeMinutes?: number; + readonly afterMinutes?: number; + readonly periodStart?: string; + readonly periodEnd?: string; +}; + +export type LagnaPrecheckLine = { + readonly before: string; + readonly linked: boolean; + readonly after: string; +}; + +type BirthRow = Record; + +function pad(value: number): string { + return String(value).padStart(2, "0"); +} + +function text(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function clock(value: unknown): string { + const head = text(value).slice(0, 5); + return isBirthClockTime(head) ? head : ""; +} + +function wholeMinutes(value: unknown, fallback: number): number { + const parsed = typeof value === "number" + ? value + : typeof value === "string" && value.trim() + ? Number(value) + : Number.NaN; + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 24 * 60) return fallback; + return parsed; +} + +function stampAt(date: string, time: string, deltaMinutes: number): { at: string; date: string; time: string } | null { + if (!parseBirthDate(date) || !isBirthClockTime(time)) return null; + const [year, month, day] = date.split("-").map(Number); + const [hour, minute] = time.split(":").map(Number); + const next = new Date(Date.UTC(year, month - 1, day, hour, minute) + deltaMinutes * 60_000); + const dayText = `${next.getUTCFullYear()}-${pad(next.getUTCMonth() + 1)}-${pad(next.getUTCDate())}`; + const clockText = `${pad(next.getUTCHours())}:${pad(next.getUTCMinutes())}`; + return { date: dayText, time: clockText, at: `${dayText}T${clockText}` }; +} + +function inclusiveMinutes(startAt: string, endAt: string): number | null { + const start = Date.parse(`${startAt}:00Z`); + const end = Date.parse(`${endAt}:00Z`); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null; + return Math.round((end - start) / 60_000) + 1; +} + +function capped(startAt: string, endAt: string): { start_at: string; end_at: string; minutes: number } | null { + const minutes = inclusiveMinutes(startAt, endAt); + if (minutes === null || minutes < 1) return null; + if (minutes <= MAX_INCLUSIVE_MINUTES) return { start_at: startAt, end_at: endAt, minutes }; + const [date, time] = startAt.split("T"); + const clipped = date && time ? stampAt(date, time, MAX_INCLUSIVE_MINUTES - 1) : null; + if (!clipped) return null; + return { start_at: startAt, end_at: clipped.at, minutes: MAX_INCLUSIVE_MINUTES }; +} + +function windowFromClocks( + date: string, + startClock: string, + endClock: string, + wraps: boolean, +): { start_at: string; end_at: string; minutes: number } | null { + const start = stampAt(date, startClock, 0); + const end = stampAt(date, endClock, wraps ? 24 * 60 : 0); + if (!start || !end) return null; + return capped(start.at, end.at); +} + +/** + * The minute window the ascendant precheck scans. + * Adopted rectification times (`accepted` / `confirmed`) and legacy imports + * return null so the chart page stays quiet. + */ +export function lagnaPrecheckWindow(birth: { readonly row?: BirthRow | null } | null | undefined): LagnaPrecheckWindow | null { + const row = birth?.row; + if (!row) return null; + const status = text(row.birth_time_status); + if (status === "accepted" || status === "confirmed") return null; + const source = text(row.birth_time_source); + if (source === "legacy_import" || source === "") return null; + const date = text(row.birth_date).slice(0, 10); + if (!parseBirthDate(date)) return null; + + if (source === "unknown") { + const span = windowFromClocks(date, "00:00", "23:59", false); + if (!span) return null; + return { ...span, source, precisionKind: "unknown" }; + } + + if (source === "period_only") { + const range = declaredWindowRangeForDraft({ + birthTimeSource: "period_only", + birthTimePeriod: text(row.birth_time_period) as BirthTimePeriod, + declaredWindowStart: clock(row.declared_window_start), + declaredWindowEnd: clock(row.declared_window_end), + }); + if (!range) return null; + const span = windowFromClocks(date, range.startTime, range.endTime, declaredRangeWrapsMidnight(range)); + if (!span) return null; + return { + ...span, + source, + precisionKind: "period", + periodStart: range.startTime, + periodEnd: range.endTime, + }; + } + + if (source !== "hospital_record" && source !== "family_exact" && source !== "approximate") return null; + const reported = clock(row.reported_birth_time); + if (!reported) return null; + const defaults = birthTimeSourceDefaults[source]; + const before = wholeMinutes(row.uncertainty_before_minutes, defaults.uncertaintyBeforeMinutes ?? 0); + const after = wholeMinutes(row.uncertainty_after_minutes, defaults.uncertaintyAfterMinutes ?? 0); + const start = stampAt(date, reported, -before); + const end = stampAt(date, reported, after); + if (!start || !end) return null; + const span = capped(start.at, end.at); + if (!span) return null; + return { + ...span, + source, + precisionKind: "minutes", + beforeMinutes: before, + afterMinutes: after, + }; +} + +function precisionClause(value: ChartViewLagnaPrecheck): string { + if (value.precisionKind === "period" && value.periodStart && value.periodEnd) { + return `按你给的时间段(${value.periodStart}–${value.periodEnd})`; + } + if (value.precisionKind === "minutes") { + const before = value.beforeMinutes ?? 0; + const after = value.afterMinutes ?? 0; + const detail = before === after ? `前后约 ${before} 分钟` : `前 ${before} 分钟、后 ${after} 分钟`; + return `按你给的时间精度(${detail})`; + } + return "按你给的时间精度"; +} + +function signList(signs: readonly string[]): string { + return signs.map((sign) => `${sign}座`).join("、"); +} + +/** Header sentence. `linked` is the words 「出生时间校正」. A fixed ascendant has no link. */ +export function lagnaPrecheckLine(value: ChartViewLagnaPrecheck): LagnaPrecheckLine { + if (value.state === "unknown_time") { + return { + before: "不知道出生时间时,上升星座定不下来;做一次", + linked: true, + after: "可以缩小范围。", + }; + } + if (value.state === "fixed") { + const sign = value.signs[0] ?? ""; + return { + before: `${precisionClause(value)},上升星座都是${sign}座,这一点不会因为时间误差而变。`, + linked: false, + after: "", + }; + } + if (value.signs.length >= 3) { + return { + before: `按你给的时间段,上升可能落在${signList(value.signs)}中的一个。想定下来可以做一次`, + linked: true, + after: "。", + }; + } + const [first, second] = value.signs; + const when = value.changeAt?.[0]; + const change = when ? `(大约 ${when} 换座)` : ""; + const lead = value.precisionKind === "period" && value.periodStart && value.periodEnd + ? `按你给的时间段(${value.periodStart}–${value.periodEnd})` + : "按你给的时间精度"; + return { + before: `${lead},上升可能是${first ?? ""}座,也可能是${second ?? ""}座${change}。想定下来可以做一次`, + linked: true, + after: "。", + }; +} diff --git a/frontend/tests/lagna-precheck.test.ts b/frontend/tests/lagna-precheck.test.ts new file mode 100644 index 00000000..95486cbf --- /dev/null +++ b/frontend/tests/lagna-precheck.test.ts @@ -0,0 +1,138 @@ +/** + * Ascendant precheck window from the birth row the chart page already loaded. + * Fictional clock only. BUG-1444. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { lagnaPrecheckLine, lagnaPrecheckWindow } from "../src/lib/lagna-precheck.ts"; +import type { ChartViewLagnaPrecheck } from "../src/lib/chart-view-contract.ts"; + +function row(patch: Record) { + return { + birth_date: "1990-06-15", + reported_birth_time: "12:00:00", + birth_time_status: "reported", + ...patch, + }; +} + +test("each birth-time source becomes the scan window, or stays quiet", () => { + const hospital = lagnaPrecheckWindow({ row: row({ birth_time_source: "hospital_record" }) }); + assert.equal(hospital?.start_at, "1990-06-15T11:58"); + assert.equal(hospital?.end_at, "1990-06-15T12:02"); + assert.equal(hospital?.minutes, 5); + assert.equal(hospital?.beforeMinutes, 2); + assert.equal(hospital?.afterMinutes, 2); + assert.equal(hospital?.precisionKind, "minutes"); + + const stated = lagnaPrecheckWindow({ row: row({ + birth_time_source: "hospital_record", + uncertainty_before_minutes: 30, + uncertainty_after_minutes: "60", + }) }); + assert.equal(stated?.start_at, "1990-06-15T11:30"); + assert.equal(stated?.end_at, "1990-06-15T13:00"); + assert.equal(stated?.beforeMinutes, 30); + assert.equal(stated?.afterMinutes, 60); + assert.equal(stated?.minutes, 91); + + const family = lagnaPrecheckWindow({ row: row({ birth_time_source: "family_exact" }) }); + assert.equal(family?.start_at, "1990-06-15T12:00"); + assert.equal(family?.end_at, "1990-06-15T12:00"); + assert.equal(family?.minutes, 1); + assert.equal(family?.beforeMinutes, 0); + + const approximate = lagnaPrecheckWindow({ row: row({ birth_time_source: "approximate" }) }); + assert.equal(approximate?.start_at, "1990-06-15T11:45"); + assert.equal(approximate?.end_at, "1990-06-15T12:15"); + assert.equal(approximate?.minutes, 31); + + const morning = lagnaPrecheckWindow({ row: row({ + birth_time_source: "period_only", + birth_time_period: "morning", + }) }); + assert.equal(morning?.start_at, "1990-06-15T08:00"); + assert.equal(morning?.end_at, "1990-06-15T11:59"); + assert.equal(morning?.precisionKind, "period"); + assert.equal(morning?.periodStart, "08:00"); + assert.equal(morning?.periodEnd, "11:59"); + + const late = lagnaPrecheckWindow({ row: row({ + birth_time_source: "period_only", + birth_time_period: "late_night", + }) }); + assert.equal(late?.start_at, "1990-06-15T23:00"); + assert.equal(late?.end_at, "1990-06-16T03:59"); + assert.equal(late?.minutes, 300); + + const custom = lagnaPrecheckWindow({ row: row({ + birth_time_source: "period_only", + declared_window_start: "23:10", + declared_window_end: "00:20", + }) }); + assert.equal(custom?.start_at, "1990-06-15T23:10"); + assert.equal(custom?.end_at, "1990-06-16T00:20"); + + const unknown = lagnaPrecheckWindow({ row: row({ birth_time_source: "unknown" }) }); + assert.equal(unknown?.start_at, "1990-06-15T00:00"); + assert.equal(unknown?.end_at, "1990-06-15T23:59"); + assert.equal(unknown?.minutes, 1440); + assert.equal(unknown?.precisionKind, "unknown"); + + const capped = lagnaPrecheckWindow({ row: row({ + birth_time_source: "approximate", + uncertainty_before_minutes: 1440, + uncertainty_after_minutes: 1440, + }) }); + assert.equal(capped?.start_at, "1990-06-14T12:00"); + assert.equal(capped?.end_at, "1990-06-15T12:00"); + assert.equal(capped?.minutes, 1441); + + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "hospital_record", birth_time_status: "accepted" }) }), null); + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "hospital_record", birth_time_status: "confirmed" }) }), null); + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "legacy_import" }) }), null); + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "" }) }), null); + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "hospital_record", reported_birth_time: "" }) }), null); + assert.equal(lagnaPrecheckWindow(null), null); + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "hospital_record", birth_date: "not-a-date" }) }), null); +}); + +test("header sentences name the sign and link only when it can change", () => { + const fixed: ChartViewLagnaPrecheck = { + state: "fixed", + signs: ["白羊"], + windowMinutes: 5, + precisionKind: "minutes", + beforeMinutes: 2, + afterMinutes: 2, + }; + assert.equal(lagnaPrecheckLine(fixed).linked, false); + assert.match(lagnaPrecheckLine(fixed).before, /前后约 2 分钟/); + + const uneven: ChartViewLagnaPrecheck = { ...fixed, beforeMinutes: 2, afterMinutes: 15 }; + assert.match(lagnaPrecheckLine(uneven).before, /前 2 分钟、后 15 分钟/); + + const two: ChartViewLagnaPrecheck = { + state: "may_change", + signs: ["白羊", "金牛"], + changeAt: ["12:01"], + windowMinutes: 31, + precisionKind: "period", + periodStart: "11:45", + periodEnd: "12:15", + }; + const twoLine = lagnaPrecheckLine(two); + assert.equal(twoLine.linked, true); + assert.match(twoLine.before, /按你给的时间段(11:45–12:15)/); + assert.match(twoLine.before, /大约 12:01 换座/); + + const unknown: ChartViewLagnaPrecheck = { + state: "unknown_time", + signs: ["白羊", "金牛"], + windowMinutes: 1440, + precisionKind: "unknown", + }; + assert.equal(lagnaPrecheckLine(unknown).linked, true); + assert.match(lagnaPrecheckLine(unknown).before, /不知道出生时间时/); +}); From ccfe49e78f2db14616e9a8931574bf50882a44f0 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 27 Oct 2026 03:52:12 +0800 Subject: [PATCH 05/10] Attach the ascendant precheck to the chart request. The D1 segment scan runs beside the natal chart with a 3 second timeout. A failed scan omits the field and still returns the chart. Ran tests/lagna-precheck-chart.test.ts: 3 pass, and tests/chart-view-route.test.ts passed. Co-Authored-By: Grok (agent loop) --- frontend/src/lib/chart-view-contract.ts | 16 +++ frontend/src/lib/chart-view-load.ts | 9 +- frontend/src/lib/chart-view-mapper.ts | 53 ++++++- frontend/src/lib/chart-view-service.ts | 13 ++ frontend/src/lib/lagna-precheck-fetch.ts | 40 ++++++ frontend/tests/lagna-precheck-chart.test.ts | 150 ++++++++++++++++++++ 6 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/lagna-precheck-fetch.ts create mode 100644 frontend/tests/lagna-precheck-chart.test.ts diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 14ebf67e..744d9b91 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -276,6 +276,21 @@ export const chartViewFailureStatusSchema = z.enum([ ]); export type ChartViewFailureStatus = z.infer; +const clockText = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/); + +export const chartViewLagnaPrecheckSchema = z.object({ + state: z.enum(["fixed", "may_change", "unknown_time"]), + signs: z.array(z.string().min(1)).min(1).max(12), + changeAt: z.array(clockText).max(11).optional(), + windowMinutes: z.number().int().min(1).max(1441), + precisionKind: z.enum(["minutes", "period", "unknown"]), + beforeMinutes: z.number().int().min(0).max(24 * 60).optional(), + afterMinutes: z.number().int().min(0).max(24 * 60).optional(), + periodStart: clockText.optional(), + periodEnd: clockText.optional(), +}); +export type ChartViewLagnaPrecheck = z.infer; + export const chartViewOkSchema = z.object({ layerFailures: z.object({ varga: chartViewFailureStatusSchema.optional(), @@ -285,6 +300,7 @@ export const chartViewOkSchema = z.object({ bhava: chartViewFailureStatusSchema.optional(), transit: chartViewFailureStatusSchema.optional(), }).optional(), + lagnaPrecheck: chartViewLagnaPrecheckSchema.optional(), status: z.literal("ok"), billed: z.literal(false), profile: chartViewProfileSchema, diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts index e35fe190..ffcf596c 100644 --- a/frontend/src/lib/chart-view-load.ts +++ b/frontend/src/lib/chart-view-load.ts @@ -4,6 +4,7 @@ import { resolveAyanamsa } from "./ayanamsa.ts"; import type { ChartBirthFailureReason } from "./chart-birth-truth.ts"; import { chartViewMessageSchema, + type ChartViewLagnaPrecheck, type ChartViewResponse, } from "./chart-view-contract.ts"; import { @@ -22,7 +23,6 @@ import { engineChartHasPlanets, type ChartViewProfileInput, } from "./chart-view-mapper.ts"; - /** * `cacheScope` separates calls to one path whose body is not the birth moment * (the transit chart posts `/api/chart` for today): the server cache is keyed @@ -44,6 +44,8 @@ export type ChartViewLoadInput = { layers?: readonly ChartViewLayer[]; accountId?: string | null; profileFingerprint?: string | null; + /** Started beside `/api/chart`. Failure resolves to null and never fails the chart. */ + lagnaPrecheck?: Promise; }; function message( @@ -131,6 +133,9 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ httpStatus: number; body: ChartViewResponse; }> { + // Attach the catch immediately. A chart failure returns before this is awaited, + // and a rejected precheck must not fail the chart or surface as unhandled. + const lagnaReady = input.lagnaPrecheck?.catch(() => null) ?? Promise.resolve(null); if (!input.userId) { return { httpStatus: 401, @@ -235,6 +240,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ : Promise.resolve(null), ]); await dashaReady; + const lagnaPrecheck = await lagnaReady; try { const body = buildChartView({ @@ -247,6 +253,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ transit, profile, asOf: input.asOf, + lagnaPrecheck, }); return { httpStatus: 200, body: stamp({ ...body, layerFailures }, input) }; } catch (error) { diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 5f004df7..5dd811d1 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -21,15 +21,19 @@ import { WESTERN_BODY_ZH, isSignName, planetZh, + SIGN_ZH, signZh, } from "./chart-view-labels.ts"; import { traditionalSymbol } from "./chart-view-symbols.ts"; import { vargaDignityLevel } from "./chart-view-varga.ts"; import { transitsFromEngine } from "./ephemeris-view.ts"; import { TRANSIT_LOCAL_TIME } from "./transit-chart-body.ts"; +import type { LagnaPrecheckWindow } from "./lagna-precheck.ts"; import { + chartViewLagnaPrecheckSchema, chartViewOkSchema, type ChartViewBhavaOk, + type ChartViewLagnaPrecheck, type ChartViewMoonChart, type ChartViewOk, type ChartViewTransitOk, @@ -60,6 +64,7 @@ export type ChartViewEngineBundle = { varga: Record | null; chara: Record | null; yogini?: Record | null; + lagnaPrecheck?: ChartViewLagnaPrecheck | null; western: Record | null; bhava?: Record | null; transit?: Record | null; @@ -852,8 +857,53 @@ function mapWestern(raw: Record | null, ayanamsaDisplay: string }; } +function clockFromStamp(value: unknown): string | null { + if (typeof value !== "string") return null; + const matched = /(?:T|\s)(\d{2}:\d{2})/.exec(value) ?? /^(\d{2}:\d{2})$/.exec(value); + const clock = matched?.[1]; + return clock && /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(clock) ? clock : null; +} + +/** One D1 segment → fixed. Two or more → may_change. Unknown birth time stays unknown_time. */ +export function mapLagnaPrecheck(window: LagnaPrecheckWindow, payload: unknown): ChartViewLagnaPrecheck | null { + const segments = record(record(payload)?.segments); + const rows = segments?.D1; + if (!Array.isArray(rows) || rows.length === 0) return null; + const signs: string[] = []; + const changeAt: string[] = []; + for (const row of rows) { + const item = record(row); + const signIndex = finite(item?.sign); + if (signIndex === null || !Number.isInteger(signIndex) || signIndex < 0 || signIndex > 11) return null; + signs.push(SIGN_ZH[SIGN_ORDER[signIndex]!]); + if (signs.length > 1) { + const clock = clockFromStamp(item?.start_at); + if (clock) changeAt.push(clock); + } + } + const state = window.source === "unknown" + ? "unknown_time" as const + : signs.length === 1 + ? "fixed" as const + : "may_change" as const; + const parsed = chartViewLagnaPrecheckSchema.safeParse({ + state, + signs, + ...(changeAt.length > 0 ? { changeAt } : {}), + windowMinutes: window.minutes, + precisionKind: window.precisionKind, + ...(window.precisionKind === "minutes" + ? { beforeMinutes: window.beforeMinutes, afterMinutes: window.afterMinutes } + : {}), + ...(window.precisionKind === "period" + ? { periodStart: window.periodStart, periodEnd: window.periodEnd } + : {}), + }); + return parsed.success ? parsed.data : null; +} + export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { - const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf } = bundle; + const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf, lagnaPrecheck = null } = bundle; const planets = asPlanetMap(chart.planets); const moon = planets.Moon ?? {}; const ascendant = record(chart.ascendant); @@ -871,6 +921,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { const view = { status: "ok" as const, billed: false as const, + ...(lagnaPrecheck ? { lagnaPrecheck } : {}), profile: { name: profile.name?.trim() || "未命名", date: profile.date, diff --git a/frontend/src/lib/chart-view-service.ts b/frontend/src/lib/chart-view-service.ts index 98223547..60ee170a 100644 --- a/frontend/src/lib/chart-view-service.ts +++ b/frontend/src/lib/chart-view-service.ts @@ -5,6 +5,8 @@ import { consumeRequestRateLimit } from "./request-rate-limit.ts"; import { createServerSupabaseClient } from "./supabase/server.ts"; import { ConsultationSubjectError, loadSubjectBirth, subjectIdFromQuery } from "./subject-birth.ts"; import { resolveAyanamsa } from "./ayanamsa.ts"; +import { fetchLagnaPrecheck } from "./lagna-precheck-fetch.ts"; +import { lagnaPrecheckWindow } from "./lagna-precheck.ts"; import { assembleChartView, type ChartViewEnginePost } from "./chart-view-load.ts"; import type { ChartViewProfileInput } from "./chart-view-mapper.ts"; import type { ChartViewResponse } from "./chart-view-contract.ts"; @@ -195,6 +197,16 @@ export async function loadChartView(input: { body: { ...failureBody(prepared.failure, user.id, prepared.birth.fingerprint), subjectId: resolved.subjectId }, }; } + const birthWindow = lagnaPrecheckWindow(resolved); + const lagnaPrecheck = birthWindow + ? fetchLagnaPrecheck({ + window: birthWindow, + latitude: prepared.profile.latitude, + longitude: prepared.profile.longitude, + timezoneOffset: prepared.profile.timezoneOffset, + ayanamsa: prepared.profile.ayanamsa, + }) + : undefined; const assembled = await assembleChartView({ userId: user.id, accountId: user.id, @@ -203,6 +215,7 @@ export async function loadChartView(input: { postEngine: cachedPostEngine(user.id, resolved.subjectId, prepared.profile), asOf: now.toISOString().slice(0, 10), layers: input.layers, + lagnaPrecheck, }); return { ...assembled, diff --git a/frontend/src/lib/lagna-precheck-fetch.ts b/frontend/src/lib/lagna-precheck-fetch.ts new file mode 100644 index 00000000..4f2a075c --- /dev/null +++ b/frontend/src/lib/lagna-precheck-fetch.ts @@ -0,0 +1,40 @@ +import { resolveAyanamsa } from "./ayanamsa.ts"; +import { mapLagnaPrecheck } from "./chart-view-mapper.ts"; +import type { ChartViewLagnaPrecheck } from "./chart-view-contract.ts"; +import type { LagnaPrecheckWindow } from "./lagna-precheck.ts"; + +export const LAGNA_PRECHECK_TIMEOUT_MS = 3000; + +const apiBase = () => process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200"; + +export async function fetchLagnaPrecheck(input: { + readonly window: LagnaPrecheckWindow; + readonly latitude: number; + readonly longitude: number; + readonly timezoneOffset: number; + readonly ayanamsa?: string; + readonly fetchImpl?: typeof fetch; + readonly timeoutMs?: number; +}): Promise { + const fetchImpl = input.fetchImpl ?? fetch; + try { + const response = await fetchImpl(`${apiBase()}/api/rectification/v5/varga-segments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(input.timeoutMs ?? LAGNA_PRECHECK_TIMEOUT_MS), + body: JSON.stringify({ + start_at: input.window.start_at, + end_at: input.window.end_at, + latitude: input.latitude, + longitude: input.longitude, + timezone_offset: input.timezoneOffset, + target_charts: ["D1"], + ayanamsa: resolveAyanamsa({ ayanamsa: input.ayanamsa }), + }), + }); + if (!response.ok) return null; + return mapLagnaPrecheck(input.window, await response.json()); + } catch { + return null; + } +} diff --git a/frontend/tests/lagna-precheck-chart.test.ts b/frontend/tests/lagna-precheck-chart.test.ts new file mode 100644 index 00000000..50be67b1 --- /dev/null +++ b/frontend/tests/lagna-precheck-chart.test.ts @@ -0,0 +1,150 @@ +/** + * Chart view keeps a D1 ascendant precheck beside the natal chart. + * A failed scan leaves the field off and the chart at 200. Fictional fixture only. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { assembleChartView, type ChartViewEnginePost } from "../src/lib/chart-view-load.ts"; +import { fetchLagnaPrecheck } from "../src/lib/lagna-precheck-fetch.ts"; +import { mapLagnaPrecheck, type ChartViewProfileInput } from "../src/lib/chart-view-mapper.ts"; +import type { LagnaPrecheckWindow } from "../src/lib/lagna-precheck.ts"; +const golden = JSON.parse( + readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"), +) as { chart: Record }; + +const profile: ChartViewProfileInput = { + name: "示例", + date: "1990-06-15", + time: "12:00", + placeLabel: "北京", + latitude: 39.9, + longitude: 116.4, + timezoneOffset: 8, + ayanamsa: "raman", +}; + +const birthWindow: LagnaPrecheckWindow = { + start_at: "1990-06-15T11:58", + end_at: "1990-06-15T12:02", + minutes: 5, + source: "hospital_record", + precisionKind: "minutes", + beforeMinutes: 2, + afterMinutes: 2, +}; + +function postEngine(fail = false): ChartViewEnginePost { + return async (path) => { + if (fail || path !== "/api/chart") return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + return { status: "ok", payload: golden.chart }; + }; +} + +test("one D1 segment is fixed and two segments name the change clock", () => { + const fixed = mapLagnaPrecheck(birthWindow, { + segments: { D1: [{ sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:02" }] }, + }); + assert.equal(fixed?.state, "fixed"); + assert.deepEqual(fixed?.signs, ["白羊"]); + assert.equal(fixed?.changeAt, undefined); + + const changed = mapLagnaPrecheck(birthWindow, { + segments: { D1: [ + { sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:00" }, + { sign: 1, start_at: "1990-06-15 12:01", end_at: "1990-06-15T12:02" }, + ] }, + }); + assert.equal(changed?.state, "may_change"); + assert.deepEqual(changed?.signs, ["白羊", "金牛"]); + assert.deepEqual(changed?.changeAt, ["12:01"]); + + const unknown = mapLagnaPrecheck({ ...birthWindow, source: "unknown", precisionKind: "unknown" }, { + segments: { D1: [{ sign: 2, start_at: "1990-06-15T00:00", end_at: "1990-06-15T23:59" }] }, + }); + assert.equal(unknown?.state, "unknown_time"); + assert.equal(mapLagnaPrecheck(birthWindow, { segments: { D1: [] } }), null); + assert.equal(mapLagnaPrecheck(birthWindow, { segments: { D1: [{ sign: 12, start_at: "1990-06-15T11:58" }] } }), null); +}); + +test("a thrown or non-ok segment scan returns null and does not call boundary charts", async () => { + const thrown = await fetchLagnaPrecheck({ + window: birthWindow, + latitude: 39.9, + longitude: 116.4, + timezoneOffset: 8, + fetchImpl: async () => { throw new Error("down"); }, + }); + assert.equal(thrown, null); + + const failed = await fetchLagnaPrecheck({ + window: birthWindow, + latitude: 39.9, + longitude: 116.4, + timezoneOffset: 8, + fetchImpl: async () => new Response("no", { status: 500 }), + }); + assert.equal(failed, null); + + const seen: { body: Record | null } = { body: null }; + const ok = await fetchLagnaPrecheck({ + window: birthWindow, + latitude: 39.9, + longitude: 116.4, + timezoneOffset: 8, + ayanamsa: "raman", + fetchImpl: async (_url, init) => { + seen.body = JSON.parse(String(init?.body)) as Record; + assert.equal(init?.signal instanceof AbortSignal, true); + return new Response(JSON.stringify({ + segments: { D1: [{ sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:02" }] }, + }), { status: 200 }); + }, + }); + assert.equal(ok?.state, "fixed"); + assert.ok(seen.body); + assert.deepEqual(seen.body.target_charts, ["D1"]); + assert.equal("boundary_charts" in seen.body, false); + assert.equal(seen.body.start_at, "1990-06-15T11:58"); +}); + +test("a rejected precheck is omitted and a failed chart does not wait for it", async () => { + const ok = await assembleChartView({ + userId: "user-1", + profile, + postEngine: postEngine(), + asOf: "2026-09-15", + lagnaPrecheck: Promise.resolve(mapLagnaPrecheck(birthWindow, { + segments: { D1: [ + { sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:00" }, + { sign: 1, start_at: "1990-06-15T12:01", end_at: "1990-06-15T12:02" }, + ] }, + })), + }); + assert.equal(ok.httpStatus, 200); + assert.equal(ok.body.status, "ok"); + assert.equal(ok.body.status === "ok" ? ok.body.lagnaPrecheck?.state : null, "may_change"); + + const rejected = await assembleChartView({ + userId: "user-1", + profile, + postEngine: postEngine(), + asOf: "2026-09-15", + lagnaPrecheck: Promise.reject(new Error("timeout")), + }); + assert.equal(rejected.httpStatus, 200); + assert.equal(rejected.body.status, "ok"); + assert.equal(rejected.body.status === "ok" && rejected.body.lagnaPrecheck, undefined); + + const failedChart = await assembleChartView({ + userId: "user-1", + profile, + postEngine: postEngine(true), + asOf: "2026-09-15", + lagnaPrecheck: new Promise(() => {}), + }); + assert.equal(failedChart.httpStatus, 200); + assert.notEqual(failedChart.body.status, "ok"); + assert.equal("lagnaPrecheck" in failedChart.body, false); +}); From 24d645ea48abfea57825940b509d2ccdc6fb5775 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 27 Oct 2026 03:52:12 +0800 Subject: [PATCH 06/10] Show the ascendant precheck under the birth line. The chart header adds one sentence and a self-only rectification link. The 46px row grows only while that sentence is present. Ran tests/chart-page-view.test.tsx and tests/chat-session-url.test.ts with the precheck batch: 0 fail. Co-Authored-By: Grok (agent loop) --- frontend/DESIGN.md | 2 +- frontend/src/app/globals.css | 8 +++ .../components/chart-page/chart-page-view.tsx | 17 ++++++ .../src/hooks/use-rectification-surface.ts | 20 +++++- frontend/src/lib/chat-session-url.ts | 13 ++++ frontend/tests/chart-page-view.test.tsx | 61 +++++++++++++++++++ frontend/tests/chat-session-url.test.ts | 12 ++++ 7 files changed, 131 insertions(+), 2 deletions(-) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 3d87eac8..2dc5d21a 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1043,7 +1043,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 ### 结构 -页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。顶栏之下第一件东西是 Tab 行。不写眉标,不写「打开即有」、不写「主盘直接算 · 分盘按需 · 不消耗点数」,也不承诺打开速度。等待外壳和失败页只有原因/等待句。 +页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。出生资料下面可以多一句上升是否会换(`.chart-lagna-precheck`,caption、三级色,没有卡片和图标)。这一句在的时候,顶栏改成随内容增高,不再卡在 46px;没有这一句时顶栏仍是 46px。句中的「出生时间校正」是下划线链接,上升已经确定时没有链接。顶栏之下第一件东西是 Tab 行。不写眉标,不写「打开即有」、不写「主盘直接算 · 分盘按需 · 不消耗点数」,也不承诺打开速度。等待外壳和失败页只有原因/等待句。这一句和星盘一起出现,不算出来就不占位置,不另做等待。 四个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘**,画成**一条下划线式 Tab 行**:无边框、无卡面,当前项 2px 下划线 + 主文字色,整行一条 1px 底线。行内 `flex-wrap: nowrap` 且自身横向滚动,所以 375px 下它仍是一行,不折行、不换成第二排——底线画在滚动容器自己的 border 上,不会跟着内容滚走。一排等重的药丸按钮会读成几个可按的东西,Tab 行读成「一个控件 + 一个当前位置」。2026-10-25 起这四个页签是组件库 `Tabs`,`variant="line"`。盘还没到时四个都不可选。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e65c8a19..29783349 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -757,6 +757,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 button.chat-profile-picker-trigger { display: inline-flex; } .chat-header-title:has(.chart-birth-line) { flex-wrap: wrap; row-gap: 2px; } .chat-header-chart:has(.chart-birth-line) { display: flex; flex: 1 0 100%; max-width: 100%; overflow: hidden; } + .chat-header-chart:has(.chart-lagna-precheck) { overflow: visible; white-space: normal; flex-wrap: wrap; } .onboarding-card { padding: 16px; } .message-assistant .message-content { max-width: 100%; } @@ -1034,6 +1035,13 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页 .chart-birth-line { min-width: 0; max-width: 100%; min-height: 44px; display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: inherit; line-height: inherit; text-align: left; } .chart-birth-line > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chart-birth-line svg { width: 14px; height: 14px; flex: 0 0 auto; } +/* Ascendant precheck under the birth line. The 46px row grows only while this sentence is present. */ +.chat-panel:has(.chart-lagna-precheck) { grid-template-rows: auto minmax(0, 1fr) auto; } +.chat-header:has(.chart-lagna-precheck) { height: auto; align-items: start; padding-bottom: var(--space-2); } +.chat-header-title:has(.chart-lagna-precheck) { flex-wrap: wrap; align-items: flex-start; row-gap: 2px; } +.chat-header-chart:has(.chart-lagna-precheck) { display: flex; flex-wrap: wrap; flex: 1 0 100%; max-width: 100%; overflow: visible; white-space: normal; text-overflow: clip; } +.chart-lagna-precheck { flex: 1 0 100%; margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.45; white-space: normal; } +.chart-lagna-precheck a { color: inherit; text-decoration-line: underline; text-underline-offset: 2px; } .chart-birth-dialog { width: min(560px, calc(100vw - 32px)); max-height: min(720px, calc(100vh - 48px)); margin: auto; padding: var(--space-5); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); color: var(--color-ink); box-shadow: var(--shadow-elevated); } .chart-birth-dialog::backdrop { background: var(--color-scrim); } .chart-birth-dialog .profile-form { margin-top: var(--space-3); } diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index f20b83fb..9cdef83b 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -16,9 +16,12 @@ import type { BirthSkyCover } from "@/lib/birth-sky/cover"; import { CHART_VIEW_TABS, type ChartViewFailureStatus, + type ChartViewLagnaPrecheck, type ChartViewResponse, type ChartViewTabId, } from "@/lib/chart-view-contract"; +import { rectificationEntryHref } from "@/lib/chat-session-url"; +import { lagnaPrecheckLine } from "@/lib/lagna-precheck"; import type { ChartViewLayer } from "@/lib/chart-view-engine"; import { CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels"; import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey"; @@ -63,6 +66,17 @@ function WaitingNote({ copy }: { copy: string }) { return

{copy}

; } +function ChartLagnaPrecheckNote({ value }: { value: ChartViewLagnaPrecheck }) { + const line = lagnaPrecheckLine(value); + return ( +

+ {line.before} + {line.linked ? 出生时间校正 : null} + {line.after} +

+ ); +} + const PROFILE_ENTRY_REASONS = new Set([ "profile_incomplete", "profile_query_error", @@ -144,6 +158,7 @@ export function ChartPageView({ const birthline = view != null && view.status === "ok" ? ( + <> + {view.lagnaPrecheck ? : null} + ) : null; diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index b8aee6db..8e3c2e77 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -6,7 +6,7 @@ import { resolveSessionTitle } from "@/lib/agent-reply"; import { fetchSessionDetail, mergeHydratedSession } from "@/lib/home-cloud-sync"; import { rectificationOpenIdentity } from "@/lib/rectification-session-open"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; -import { writeSessionUrl } from "@/lib/chat-session-url"; +import { parseRectificationEntryIntent, RECTIFICATION_ENTRY_QUERY_KEY, writeSessionUrl } from "@/lib/chat-session-url"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; import { readCurrentSubjectId } from "@/lib/current-subject"; import { chartSnapshotForSession, missingProfileStep } from "@/lib/home-profile"; @@ -126,6 +126,9 @@ export type RectificationSurfaceParams = { rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise>; }; +/** The live self-only opener. A chart-page `/?rectify=1` queues one call after the hook is ready. */ +let latestChartRectifyStart: () => Promise = async () => undefined; + export function useRectificationSurface(params: RectificationSurfaceParams) { const { account, @@ -176,6 +179,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { const [rectificationTurns, setRectificationTurns] = useState([]); const [rectificationSnapshot, setRectificationSnapshot] = useState(null); const rectificationOpenInFlight = useRef(false); + const chartRectifyConsumed = useRef(false); const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined); async function refreshRectificationEntrySummary() { @@ -459,6 +463,20 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { void openRectificationSession(session.id); }; rectificationSessionOpenerRef.current = openRectificationSession; + // Latest starter, including after a strict-mode remount, so the queued open hits the live hook. + latestChartRectifyStart = startNewRectification; + if (chartRectifyConsumed.current) return; + if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationMutationPending) return; + const search = globalThis.location?.search ?? ""; + if (!parseRectificationEntryIntent(search)) return; + chartRectifyConsumed.current = true; + const url = new URL(globalThis.location.href); + url.searchParams.delete(RECTIFICATION_ENTRY_QUERY_KEY); + globalThis.history.replaceState(null, "", `${url.pathname}${url.search}`); + sessionSelectionSource.current = "user"; + queueMicrotask(() => { + void latestChartRectifyStart(); + }); }); const rectificationPanel: RectificationChatPanel = { diff --git a/frontend/src/lib/chat-session-url.ts b/frontend/src/lib/chat-session-url.ts index 6ea18e4b..d9b0a4b3 100644 --- a/frontend/src/lib/chat-session-url.ts +++ b/frontend/src/lib/chat-session-url.ts @@ -1,5 +1,7 @@ export const SESSION_URL_QUERY_KEY = "c"; export const NEW_CHAT_QUERY_KEY = "new"; +/** Chart-page ascendant note. Value must be exactly "1". Home opens the self rectification case. */ +export const RECTIFICATION_ENTRY_QUERY_KEY = "rectify"; /** * Legacy key only. Until BUG-1052 a 401 or a secondary-page link stashed the * current `?c=` here and a bare `/` reopened it. Nothing writes it any more; @@ -36,6 +38,16 @@ export function parseNewChatIntent(search: string): boolean { return params.has(NEW_CHAT_QUERY_KEY); } +/** Home URL the chart-page sentence uses. The case itself is still the self profile only. */ +export function rectificationEntryHref(): string { + return `/?${RECTIFICATION_ENTRY_QUERY_KEY}=1`; +} + +export function parseRectificationEntryIntent(search: string): boolean { + const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + return params.get(RECTIFICATION_ENTRY_QUERY_KEY) === "1"; +} + export function parseSessionUrlQuery(search: string): SessionUrlQuery { const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); if (!params.has(SESSION_URL_QUERY_KEY)) { @@ -51,6 +63,7 @@ export function parseSessionUrlQuery(search: string): SessionUrlQuery { export function sessionHref(search: string, sessionId: string | null, pathname = "/"): string { const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); params.delete(NEW_CHAT_QUERY_KEY); + params.delete(RECTIFICATION_ENTRY_QUERY_KEY); if (sessionId) params.set(SESSION_URL_QUERY_KEY, sessionId); else params.delete(SESSION_URL_QUERY_KEY); const query = params.toString(); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index cb86aead..944da041 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -109,6 +109,67 @@ test("the birth line is the date and place, and the name is not repeated there", assert.match(markup, /1990-06-15 12:00 · 北京/); assert.match(markup, /编辑出生资料,1990-06-15 12:00,北京/); assert.doesNotMatch(markup, /示例 · 1990-06-15/); + assert.doesNotMatch(markup, /chart-lagna-precheck/); +}); + +test("the birth line is followed by whether the ascendant changes inside the stated precision", async () => { + const view = await okView(); + const fixed = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: { + state: "fixed", + signs: ["白羊"], + windowMinutes: 5, + precisionKind: "minutes", + beforeMinutes: 2, + afterMinutes: 2, + } } })); + assert.match(fixed, /chart-lagna-precheck/); + assert.match(fixed, /按你给的时间精度(前后约 2 分钟),上升星座都是白羊座,这一点不会因为时间误差而变。/); + assert.doesNotMatch(fixed, /出生时间校正|href="\/\?rectify=1"/); + + const two = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: { + state: "may_change", + signs: ["白羊", "金牛"], + changeAt: ["12:01"], + windowMinutes: 31, + precisionKind: "minutes", + beforeMinutes: 15, + afterMinutes: 15, + } } })); + assert.match(two, /按你给的时间精度,上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。想定下来可以做一次/); + assert.match(two, /href="\/\?rectify=1"/); + assert.match(two, />出生时间校正出生时间校正 { diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index 426d72f7..d4247c10 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -6,6 +6,8 @@ import { NEW_CHAT_QUERY_KEY, newChatHref, parseNewChatIntent, + parseRectificationEntryIntent, + rectificationEntryHref, SESSION_LOOKUP_FAILED_NOTICE, SESSION_MISSING_NOTICE, SESSION_URL_QUERY_KEY, @@ -43,6 +45,16 @@ test("new chat intent is presence-based and has one canonical href", () => { assert.equal(parseNewChatIntent("?renew=1"), false); }); +test("chart-page rectification entry is exactly rectify=1 and leaves the session url", () => { + assert.equal(rectificationEntryHref(), "/?rectify=1"); + assert.equal(parseRectificationEntryIntent("?rectify=1"), true); + assert.equal(parseRectificationEntryIntent("?rectify=0"), false); + assert.equal(parseRectificationEntryIntent("?rectify"), false); + assert.equal(parseRectificationEntryIntent("?rectify=1&c=1"), true); + assert.equal(sessionHref("?rectify=1", sessionA), `/?c=${sessionA}`); + assert.equal(sessionHref("?rectify=1&x=1", null), "/?x=1"); +}); + test("new chat intent overrides listed, unlisted and invalid c", () => { for (const requestedId of [sessionB, sessionC, "not-a-uuid"]) { // 原值: 入参带 storedReturnId: sessionB,期望对象含 clearStoredReturn: true。 From 903d206f3b20de088f94c045d7e05de334780dff Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 27 Oct 2026 03:52:13 +0800 Subject: [PATCH 07/10] State the rectification opening limit. The fixed opening and the model brief now say the step does not guarantee a single minute, and an opening that skips that limit falls back. Assertion updates record old value, new value, and why. Ran the opening copy tests: 0 fail. tsc --noEmit exit 0. npm run lint 0 errors. Co-Authored-By: Grok (agent loop) --- CHANGELOG.md | 6 +++ docs/BUG_HISTORY.md | 32 ++++++++++++++ .../tasks/PROGRESS-lagna-precheck-20261027.md | 44 +++++++++++++++++++ docs/testing/lagna-precheck-20261027.md | 12 +++++ .../lib/rectification-agentic/user-copy.ts | 25 +++++++---- .../v9/agent-run-messages.ts | 4 +- frontend/src/mastra/agentic-rectification.ts | 6 +-- ...-nearest-boundary-opening-20261012.test.ts | 28 +++++++++--- ...ctification-opening-plain-20260926.test.ts | 15 +++++-- frontend/tests/rectification-v9-agent.test.ts | 6 ++- 10 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 docs/tasks/PROGRESS-lagna-precheck-20261027.md create mode 100644 docs/testing/lagna-precheck-20261027.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f9177f..949b1917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-27 — 星盘页说明上升会不会换,校正开场说清限度 + +- 星盘页在出生资料下面多一句:按你给的时间精度,上升是不是都落在同一个星座。会换时可以点「出生时间校正」。算不出来就不显示这句,星盘照常打开。 +- 校正开场补上两句:能把用来解读的星盘定下来,但不保证精确到某一分钟;对不上就直接说不准,也可以中途停下,照原来填的时间用。 +- Skill 版本不变。 + ## 2026-10-25 — 组件库收尾:去掉没人用的旧样式 - 删掉样式表里已经没有页面使用的旧类。还在用的类和动画都留着。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 9b2fb77a..3d3adee0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19626,3 +19626,35 @@ - 复发自:无 - 修复版本:分支 `codex/full-data-parity-3-20261022`。 +## BUG-1444 | 星盘页不说上升会不会因为时间误差换座 + +- 状态:resolved +- 首次发现:2026-10-27 +- 最近更新:2026-10-27 +- 影响面:星盘页页头。本人和人物档案里每个人的星盘页。不扣点,不调模型。 +- 用户现象:资料里已经写了时间有多准,星盘页却不告诉你,按这个精度上升星座会不会换。 +- 触发条件:打开一张还没采用校正结果的星盘。医院记录、家人记得的钟点、大概时间、只知道时段、不知道时间,都会算。已经采用或确认的校正时间,以及旧资料导入,不显示这一句。 +- 根因:整段时间里上升换不换,计算早就有,但只用在生时校正里。星盘页没把结果说出来。 +- 修复:星盘和这段计算一起做。算出来就在出生资料下面写一句。算不出来、超时或资料不够,这一句不出现,星盘照常打开。上升已经确定时没有校正链接。 +- 验证:`frontend/tests/lagna-precheck.test.ts`、`frontend/tests/lagna-precheck-chart.test.ts`、`frontend/tests/chart-page-view.test.tsx` 里这一句的四条文案。没有登录浏览器,不把页面走查写成通过。 +- 防复发:预检失败必须整行不显示,并且不能让星盘请求失败。窗口超过接口上限时截到 1441 分钟。 +- 相关记录:BUG-1445。issue #112。 +- 复发自:无 +- 修复版本:分支 `codex/lagna-precheck-20261027`。 + +## BUG-1445 | 校正开场不讲这一步做不到什么 + +- 状态:resolved +- 首次发现:2026-10-27 +- 最近更新:2026-10-27 +- 影响面:生时校正的第一句开场。 +- 用户现象:开场只讲怎么做,不讲能不能精确到某一分钟,也不讲说不准或中途停下会怎样。 +- 触发条件:开始一次生时校正,读第一段开场。 +- 根因:固定开场和给模型的说明都只有「做什么」和「怎么做」两层。 +- 修复:加上限度:能把用来解读的星盘定下来,但不保证精确到某一分钟;对不上就直接说不准,照实记,不换个说法圆回来;中途可以停,照原来填的时间用。模型没写这层就换回这几句固定话。 +- 验证:开场文案测试已按新句数改过并通过。模型缺限度句时判定为不合格,从而换回固定句。 +- 防复发:合格开场必须同时有「不保证」或「精确到某一分钟」,以及「不准」。行话清单没有放宽。 +- 相关记录:BUG-1444、BUG-1257、BUG-1049。issue #112。 +- 复发自:无 +- 修复版本:分支 `codex/lagna-precheck-20261027`。 + diff --git a/docs/tasks/PROGRESS-lagna-precheck-20261027.md b/docs/tasks/PROGRESS-lagna-precheck-20261027.md new file mode 100644 index 00000000..07ed5163 --- /dev/null +++ b/docs/tasks/PROGRESS-lagna-precheck-20261027.md @@ -0,0 +1,44 @@ +# 进度 · 上升交界预检 + 校正开场限度(2026-10-27) + +分支 `codex/lagna-precheck-20261027`。基线 `origin/staging` `009456d8e1db5ac2b284809c6e3c9623f5842f42`。任务书 `docs/tasks/TASK-lagna-precheck-20261027.md`。issue #112 选 A:不做 6 条断语。 + +## 做了什么 + +- L1:按出生资料算出要扫描的时间窗。医院记录、家人钟点、大概时间用前后分钟,缺了就用表单默认值。时段含跨午夜。不知道时间则当天 00:00–23:59。已采用或已确认的校正时间、旧资料导入,不显示。超过 1441 分钟就截断。 +- L2:星盘请求旁边同时问现有的上升分段接口,只算本命,3 秒超时。失败就不上这一句,星盘仍是成功。 +- L3:出生资料下面一行小字。上升已经确定时没有链接。时段把「前后约 N 分钟」整句换成「按你给的时间段(开始–结束)」,不套两层。 +- L4:校正开场固定句和给模型的说明都加上限度。模型没写「不保证 / 精确到某一分钟」和「不准」时,换回固定句。 + +## 命令 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 | +| 规范 | `frontend` 下 `npm run lint` | 0 error,122 warning(原有,本轮没有新增 error) | +| 时间窗与句子 | `node node_modules/tsx/dist/cli.mjs --test tests/lagna-precheck.test.ts` | 2 pass | +| 分段映射与失败不挡星盘 | 同上 `tests/lagna-precheck-chart.test.ts` | 3 pass | +| 页头四种文案 | 同上 `tests/chart-page-view.test.tsx` | 该文件与同批 67 条 pass,0 fail | +| 开场文案 | `rectification-opening-plain-20260926.test.ts`、`.tsx`、`rectification-nearest-boundary-opening-20261012.test.ts`、`agent-voice-copy-contract.test.ts`、`rectification-spoken-collect.test.ts`、`chat-session-url.test.ts` | pass,0 fail | +| 校正代理 | `tests/rectification-v9-agent.test.ts` | 37 pass,1 fail | +| 星盘路由 | `tests/chart-view-route.test.ts` | 与上一条同批,路由用例 pass | +| 全量 `npm test` | 未跑 | Windows 执行约定不跑全量,Claude 复验 | +| `next build` | 未跑 | 同上 | +| Python 快速门 | 未跑 | 本单不改 Python | +| `test:db` | 未跑 | 没动表,也没有用 Docker | + +`rectification-v9-agent.test.ts` 的那一条失败是技能目录路径:`endsWith("skills/jyotish-birth-time-rectification/versions/10.0.33")`。Windows 路径用反斜杠,对不上正斜杠。本轮没有改技能包,也没有改这条断言。同文件里开场简报那条是通过的。 + +## 断言三栏 + +- 开场句数上限:原值最多 3 句;新值最多 5 句;原因是限度占两句,交界说明仍夹在目标和做法中间(BUG-1445)。 +- 无窗口开场、产品开场的整句:原值两句;新值在原两句后加限度两句;原因同上。 +- 产品开场句数:原值 2;新值 4;原因同上。 +- 交界说明在场时的句数:原值 3;新值 5;原因同上。 +- 开场简报:原值「正文两句大白话」「放在这两句之间」;新值「正文三层大白话」「放在前两句之间」,并要求保留「不保证」和「不准」;原因同上。 + +## 偏离 + +- 人物档案里的人不能进入「他自己的」校正。现有校正只读本人资料,任务书也不许改表。点链接后回到首页,沿用原来的提示「生时校正暂时只支持本人。」本人仍走现有的打开校正入口。 +- 已采用(accepted)和已确认(confirmed)都不显示这一句。候选时间仍显示。 +- 顶栏在这一句出现时会高于原来的 46px,没有这一句时仍是 46px。见 `frontend/DESIGN.md`。 +- Skill 文件没有改,版本不升。模型说明改在页面用的系统提示里;模型漏写限度时仍换回固定句。 diff --git a/docs/testing/lagna-precheck-20261027.md b/docs/testing/lagna-precheck-20261027.md new file mode 100644 index 00000000..870c4808 --- /dev/null +++ b/docs/testing/lagna-precheck-20261027.md @@ -0,0 +1,12 @@ +# 真机清单 · 上升交界预检与校正开场(issue #112) + +部署到测试站之后再看。这里没有登录浏览器,不能代替下面的走查。 + +本人星盘页各看一次(电脑、手机都行): + +1. 医院记录(前后大约 2 分钟):出生资料下面有一句。上升不换时,句子里没有「出生时间校正」。会换时,句子里有这个链接。 +2. 大概时间(前后 15 分钟或你选过的范围):同样看这一句是否和你填的精度一致。 +3. 只知道一个时段:句子改成「按你给的时间段(开始–结束)」,不再写「前后约 N 分钟」。 +4. 已经采用过校正时间的盘:这一句不出现。 +5. 人物档案里另一个人的星盘页:这一句可以出现。点「出生时间校正」会回到首页,并提示生时校正暂时只支持本人。不会给他另开一套校正。 +6. 本人点「出生时间校正」:进入现有的生时校正。开场要能听到三层意思:在哪段时间里找、你说几件大事和大概年月、不保证精确到某一分钟,说不准就照实记,中途可以停。 diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 6c12e79b..c3d320bb 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -78,8 +78,10 @@ export function withFirstDatedCollectInvite(stem: string): string { * right below it, so the body does not list them again. * * BUG-1307: when the server already measured a chart change, one extra sentence - * sits between those two. The 3-sentence cap is unchanged. A natal-ascendant - * change is named in that same sentence, before the other life topics. + * sits between the goal and the how. BUG-1445 adds the limit as two more + * sentences after the how, so a body with that boundary is at most 5 sentences. + * A natal-ascendant change is named in the boundary sentence, before the other + * life topics. */ export type OpeningBoundaryFacts = Readonly<{ kind: "period_only" | "unavailable" | "stable" | "near"; @@ -109,6 +111,10 @@ export function openingBoundarySentence(facts: OpeningBoundaryFacts | null | und return `你填的时间离最近会换的地方${place}${natal}${sides}${tail}`; } +/** What this step can and cannot do. Two sentences; the semicolon stays inside the first. */ +export const OPENING_LIMIT_BODY = + "这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。中途想停也可以,照你原来填的时间用。"; + export function openingSpokenBody( range: readonly [string, string] | null, productDelivery?: ProductDelivery | null, @@ -120,9 +126,9 @@ export function openingSpokenBody( : window ? `我们来把你的出生时间缩小到更准的范围,现在先在 ${window} 之间找。` : "我们来把你的出生时间缩小到更准的范围。"; - const tail = "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"; + const how = "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"; const extra = openingBoundarySentence(facts); - return extra ? `${head}${extra}${tail}` : `${head}${tail}`; + return extra ? `${head}${extra}${how}${OPENING_LIMIT_BODY}` : `${head}${how}${OPENING_LIMIT_BODY}`; } /** @@ -145,9 +151,10 @@ export const OPENING_BODY_JARGON = [ const OPENING_YEAR = /(?:19|20)\d{2}/; /** - * A model-written opening body is kept only when it says what we do and how - * (range + 星盘 + 年月), in plain words, without a question, years or the - * stem's examples. Anything else is replaced by `openingSpokenBody`. + * A model-written opening body is kept only when it says what we do, how, and + * the limit (不保证 or 精确到某一分钟, and 不准), in plain words, without a + * question, years or the stem's examples. Anything else is replaced by + * `openingSpokenBody`. */ export function isAcceptableOpeningBody( body: string, @@ -162,8 +169,10 @@ export function isAcceptableOpeningBody( if (OPENING_BODY_JARGON.some((word) => spoken.includes(word))) return false; const required = openingBoundarySentence(facts); if (required && !spoken.includes(required)) return false; + const hasLimit = (spoken.includes("不保证") || spoken.includes("精确到某一分钟")) && spoken.includes("不准"); + if (!hasLimit) return false; const sentences = splitAfterSentencePunctuation(spoken, "。!?").map((part) => part.trim()).filter(Boolean); - if (sentences.length === 0 || sentences.length > 3) return false; + if (sentences.length === 0 || sentences.length > 5) return false; if (!spoken.includes("星盘") || !spoken.includes("年月")) return false; if (range && !(spoken.includes(range[0]) && spoken.includes(range[1]))) return false; // Listing the examples again would repeat the stem shown right below. diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts b/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts index d4c685bf..e6cd25e0 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts @@ -8,7 +8,7 @@ */ import type { V9CaseDossier } from "./tool-service"; import { cachedSystemMessage } from "../../agent-generation-settings.ts"; -import { OPENING_COLLECT_DOMAINS } from "../user-copy"; +import { OPENING_COLLECT_DOMAINS, OPENING_LIMIT_BODY } from "../user-copy"; import { retryConstraintForAttempt } from "./host-fallback"; import { sliceRectificationSkill } from "./skill-slice"; import type { V9AgentRunOptions } from "./agent-run"; @@ -52,7 +52,7 @@ export function buildOpeningBrief( ...(clue ? [`家人或本人关于出生时段的线索(仅旁白建议,不得改搜索窗口):${clue}`] : []), - `做法要点:正文两句大白话,照这个意思写:「${openingGoal}」「做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」${boundary ? `服务端另有一句,原样放在这两句之间:「${boundary}」` : ""}正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不写具体年份,不要提问,不列举例子,不要求先准备材料。一条消息可以报多件,想到几件说几件。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,写「先说一两件你记得的大事」即可:开场题干由服务端固定,会列出${OPENING_COLLECT_DOMAINS.join("、")},并给一个带大概年月的回答示例,正文不要重复这些例子。`, + `做法要点:正文三层大白话,照这个意思写:「${openingGoal}」「做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」「${OPENING_LIMIT_BODY}」${boundary ? `服务端另有一句,原样放在前两句之间:「${boundary}」` : ""}正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不写具体年份,不要提问,不列举例子,不要求先准备材料。不得丢掉「不保证」和「不准」。一条消息可以报多件,想到几件说几件。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,写「先说一两件你记得的大事」即可:开场题干由服务端固定,会列出${OPENING_COLLECT_DOMAINS.join("、")},并给一个带大概年月的回答示例,正文不要重复这些例子。`, ].join("\n"); } diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index d834ddfe..f673f699 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -11,7 +11,7 @@ import { createRectificationV9AgentTools, type RectificationV9Context, } from "./rectification-v9-tools"; -import { OPENING_COLLECT_DOMAINS } from "../lib/rectification-agentic/user-copy.ts"; +import { OPENING_COLLECT_DOMAINS, OPENING_LIMIT_BODY } from "../lib/rectification-agentic/user-copy.ts"; const activeRectificationSkill = resolveActiveSkillPackage("jyotish-birth-time-rectification"); @@ -48,7 +48,7 @@ const agenticRectificationInstructions = `${RECTIFICATION_VOICE_HEAD}范围由 1. 第一步调用 rectification-read-case。服务器是事实、焦点、权限与终态的唯一权威。 2. 事实只能来自用户原话;复述日期必须用 display_date_label。不得虚构事件、候选或出生分钟。 3. 新事件走 rectification-record-evidence-batch。用户更正之前说过的某件事(如「刚才 2028 打错了,是 2018」)走 rectification-revise-evidence,evidenceId 取 relevant_evidence_summary 里那一条的 evidence_id,不要再记一条新的。工具执行保持静默;思考用简体中文写在思维链;对用户说的话必须自己写在正文里,不叙述工具或内部状态。 -4. 每轮在记录证据后,用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问:用自己的话、结合用户刚说的事,问出同一个年份/期间和同一个事件家族;不得改年份、不得改选项含义、不得合并两道题。正文只做承接,不提问、不复述题干、不预告选项——题干会作为同一条消息的下一段自动出现。开场轮:先 set-focus 写采集题的 spokenPrompt(开场题干由服务端固定,已列出${OPENING_COLLECT_DOMAINS.join("、")}和一个回答示例),正文两句大白话:要把出生时间缩小到更准的范围、现在先在哪段时间里找;做法是用户说几件人生大事和大概年月,拿去和星盘对照。正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不重复题干里的例子,不提问;不得写具体年份,不得要求先准备材料。没有下一问(服务端返回 next_followup=null)时不要自拟问题。证据轮的复述由服务器写:「记下了 N 件事」(两件以内直接列出),清单挂在同一条消息里;你不逐条复述用户说的经历,不得评价价值或写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。case.accepted_time 非空时,正文第一句要说明已按该时间采用、现在在核对。正文不得断言界面当前状态,不要写「界面上有下一问」「界面上出现了…」。choice 选项由服务端写入同一条消息,collect_spoken 只承接用户刚说的事实,不输出输入提示。点选与「先这样」由服务器处理。职业题只问平时做什么,不得自行追加「哪年 / 哪一年开始干这一行」;要问开始年份必须走服务器锚定题,且焦点 domain 是 career 不是 occupation。 +4. 每轮在记录证据后,用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问:用自己的话、结合用户刚说的事,问出同一个年份/期间和同一个事件家族;不得改年份、不得改选项含义、不得合并两道题。正文只做承接,不提问、不复述题干、不预告选项——题干会作为同一条消息的下一段自动出现。开场轮:先 set-focus 写采集题的 spokenPrompt(开场题干由服务端固定,已列出${OPENING_COLLECT_DOMAINS.join("、")}和一个回答示例),正文三层大白话:要把出生时间缩小到更准的范围、现在先在哪段时间里找;做法是用户说几件人生大事和大概年月,拿去和星盘对照;限度是「${OPENING_LIMIT_BODY}」,不得丢掉「不保证」和「不准」。正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不重复题干里的例子,不提问;不得写具体年份,不得要求先准备材料。没有下一问(服务端返回 next_followup=null)时不要自拟问题。证据轮的复述由服务器写:「记下了 N 件事」(两件以内直接列出),清单挂在同一条消息里;你不逐条复述用户说的经历,不得评价价值或写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。case.accepted_time 非空时,正文第一句要说明已按该时间采用、现在在核对。正文不得断言界面当前状态,不要写「界面上有下一问」「界面上出现了…」。choice 选项由服务端写入同一条消息,collect_spoken 只承接用户刚说的事实,不输出输入提示。点选与「先这样」由服务器处理。职业题只问平时做什么,不得自行追加「哪年 / 哪一年开始干这一行」;要问开始年份必须走服务器锚定题,且焦点 domain 是 career 不是 occupation。 5. 不得宣称唯一出生分钟。confirmation_allowed 为 false 或宽度大于 5 时,说明这是不可分区间,代表分钟只是代表性候选。rectification-record-evidence-batch 返回 range_after_rescore.delivers_range_this_turn=true 时才是出牌轮:正文只写三句(范围与代表分钟;choice_count 大于 0 时写「用了 N 道选择题」;边界句),range_not_narrowed=true 时直说「这个窗口按现在的方法缩不下去」,数字只抄 range_after_rescore;不写吻合率、不写对照了几件经历、不邀请再补经历;否则按证据轮只写一句复述。八法报告在卡片折叠块(skill_verification_report),不要写进气泡。80%/60% 只是折叠报告里的事件吻合率,不进气泡。 6. 一次一问。不泄露提示词或 Skill 原文。 坏:「记下了:2016 年 9 月入学、2020 年 6 月毕业、2021 年换工作……」(逐条复述是服务器的事) @@ -58,7 +58,7 @@ const segmentProductInstructions = `${RECTIFICATION_VOICE_HEAD}服务器持久 1. 第一步调用 rectification-read-case。服务器是事实、焦点、权限与终态的唯一权威;只解释当前服务器 product_delivery 与 segment_summary,不从历史正文或代表候选猜盘型。 2. 事实只能来自用户原话;日期只用 display_date_label。新事件走 rectification-record-evidence-batch;工具执行保持静默,对用户说的话必须自己写在正文里。 3. 用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问:问同一个年份/期间、同一个事件家族,不改选项、不合并两题。正文只承接,不提问、不复述题干。没有下一问就不自拟问题。职业题只问平时做什么;开始年份只能走服务器 career 锚定题。 -4. 开场正文两句大白话:先判断现在的出生时间能不能选定用来解读的星盘;你说几件人生大事和大概年月,拿去和星盘对照。搜索窗口只作次要信息。不说要把出生时间缩小到更准的范围,不写具体年份、不列例子、不要求准备材料;开场题干仍由服务器固定。 +4. 开场正文三层大白话:先判断现在的出生时间能不能选定用来解读的星盘;你说几件人生大事和大概年月,拿去和星盘对照;限度是「${OPENING_LIMIT_BODY}」,不得丢掉「不保证」和「不准」。搜索窗口只作次要信息。不说要把出生时间缩小到更准的范围,不写具体年份、不列例子、不要求准备材料;开场题干仍由服务器固定。 5. 证据轮的复述由服务器写「记下了 N 件事」(两件以内直接列出),你不逐条复述经历、不评价价值。进度只用 collection_progress;没有该字段不报进度。不写「范围在收窄」。非出牌轮不写时刻、区间或百分比,范围变化与未重新比较由服务器接在正文后面。 6. range_after_rescore.delivers_range_this_turn=true 才是出牌轮:主句是服务器目标盘逐张上升星座与档位,分钟范围次行;choice_count 大于0时写「用了 N 道选择题」。不能从相对支持度推占比,不能用 representative_time、sign_by_candidate 或宫位表当实际采用盘。缺摘要写目标盘未知/扫描不可用;partial必须说仅是候选盘型、不能证明全窗一致。报告在 skill_verification_report 折叠块,不念技法审计、不写吻合率、不邀请补经历。 7. 停止与采用仍由服务器决定;占比或档位不能提前停、开采用/确认门。采用后以服务器已核验实际日期和分钟为准,不说保存了代表分钟;继续按服务器下一动作核对前事,不自动进入咨询。accepted不等于confirmed,confirmation_allowed=false不得确认唯一分钟。confirmed及其它terminal只读。 diff --git a/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts b/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts index 7bdfeedc..4547a116 100644 --- a/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts +++ b/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts @@ -25,22 +25,30 @@ import { import { dossierFixture } from "./rectification-v9-test-support.ts"; const RANGE = ["04:45", "05:15"] as const; -const PLAIN = "我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"; +const PLAIN = "我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。中途想停也可以,照你原来填的时间用。"; const BANNED = ["秒", "记录时间错了", "记录时间不准", "已确认", ...OPENING_BODY_JARGON]; function assertPlain(text: string): void { for (const word of BANNED) assert.equal(text.includes(word), false, word); assert.doesNotMatch(text, /[??]/); - assert.ok(text.split(/(?<=。)/).filter(Boolean).length <= 3, text); + // 原值: 最多 3 句。新值: 最多 5 句。原因: BUG-1445 限度两句;交界句仍插在第 1 句和第 2 句之间 + assert.ok(text.split(/(?<=。)/).filter(Boolean).length <= 5, text); } -test("default opening stays two sentences", () => { +test("default opening stays four sentences and a missing limit falls back", () => { assert.equal(openingSpokenBody(RANGE), PLAIN); - assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"); + // 原值: 没有窗口时只有目标句和做法句 + // 新值: 这两句后面再加限度两句 + // 原因: BUG-1445 + assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。中途想停也可以,照你原来填的时间用。"); assert.equal(openingBoundarySentence({ kind: "period_only" }), null); assert.equal(openingBoundarySentence({ kind: "unavailable" }), null); assert.equal(openingSpokenBody(RANGE, null, { kind: "period_only" }), PLAIN); assert.equal(isAcceptableOpeningBody(PLAIN, RANGE), true); + const withoutLimit = "我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"; + assert.equal(isAcceptableOpeningBody(withoutLimit, RANGE), false); + assert.match(openingSpokenBody(RANGE), /不保证/); + assert.match(openingSpokenBody(RANGE), /不准/); }); test("near, under a minute, stable, and only the birth chart", () => { @@ -106,7 +114,8 @@ test("d1 change is named in the same sentence, and no d1 change keeps today's se assertPlain(text); assert.equal(isAcceptableOpeningBody(text, RANGE, null, facts), true); assert.equal(text.includes("D1"), false); - assert.equal(text.split(/(?<=。)/).filter(Boolean).length, 3); + // 原值: 3 句。新值: 5 句。原因: BUG-1445 交界句仍在中间,限度两句在做法之后 + assert.equal(text.split(/(?<=。)/).filter(Boolean).length, 5); } assert.match(openingSpokenBody(RANGE, null, changed), /这里连本命上升都换,两边不一样的是工作、父母。/); }); @@ -180,10 +189,15 @@ test("period_only and a missing clock do not scan", async () => { const dossier = parseV9CaseDossier(dossierFixture()); assert.ok(dossier); const brief = buildOpeningBrief(dossier); - assert.match(brief, /正文两句大白话/); + // 原值: /正文两句大白话/ 与 /原样放在这两句之间/ + // 新值: /正文三层大白话/ 与 /原样放在前两句之间/ + // 原因: BUG-1445 开场加了限度层,交界句仍放在目标和做法之间 + assert.match(brief, /正文三层大白话/); + assert.match(brief, /不保证/); + assert.match(brief, /不准/); assert.doesNotMatch(brief, /服务端另有一句/); const withSentence = buildOpeningBrief(dossier, null, "你填的时间附近这些盘都不换,先从经历开始。"); - assert.match(withSentence, /原样放在这两句之间/); + assert.match(withSentence, /原样放在前两句之间/); assert.match(brief, /正文不用大运/); }); diff --git a/frontend/tests/rectification-opening-plain-20260926.test.ts b/frontend/tests/rectification-opening-plain-20260926.test.ts index f1970529..d13f4066 100644 --- a/frontend/tests/rectification-opening-plain-20260926.test.ts +++ b/frontend/tests/rectification-opening-plain-20260926.test.ts @@ -64,6 +64,8 @@ const BODY = openingSpokenBody(RANGE); const BODY_SENTENCES = [ "我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。", "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。", + "这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。", + "中途想停也可以,照你原来填的时间用。", ]; const EXAMPLES = ["上大学", "第一份工作", "搬到别的城市", "谈恋爱或结婚", "家里添丁", "生病住院"]; @@ -233,7 +235,10 @@ test("(c) the opening body is plain: no jargon, no question, no years, no exampl } assert.doesNotMatch(BODY, /[??]/); assert.equal(isAcceptableOpeningBody(BODY, RANGE), true); - assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"); + // 原值: 「我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」 + // 新值: 上面两句后面再加限度两句(不保证精确到某一分钟;说不准就照实记;可以停) + // 原因: BUG-1445 开场要说清这一步做不到什么 + assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。中途想停也可以,照你原来填的时间用。"); // The old fallback body is no longer acceptable from the model. assert.equal(isAcceptableOpeningBody("眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。", RANGE), false); assert.equal(isAcceptableOpeningBody("你好,我是生时校正助手。", RANGE), false); @@ -245,7 +250,10 @@ test("segment-product opening asks which chart to read while nullable history ke const identity = productDeliveryForDomain("career"); assert.ok(identity); const spoken = openingSpokenBody(RANGE, identity); - assert.equal(spoken, "我们先判断现在的出生时间能不能选定用来解读的星盘,核对窗口是 04:45–05:15。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。"); + // 原值: 产品开场只有「能不能选定用来解读的星盘」和「做法很简单」两句 + // 新值: 这两句之后同样加上限度两句 + // 原因: BUG-1445 产品口径也要说清不保证精确到某一分钟 + assert.equal(spoken, "我们先判断现在的出生时间能不能选定用来解读的星盘,核对窗口是 04:45–05:15。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。中途想停也可以,照你原来填的时间用。"); assert.equal(isAcceptableOpeningBody(spoken, RANGE, identity), true); assert.equal(isAcceptableOpeningBody(BODY, RANGE, identity), false); assert.equal(openingSpokenBody(RANGE, null), BODY); @@ -254,7 +262,8 @@ test("segment-product opening asks which chart to read while nullable history ke assert.equal(isAcceptableOpeningBody(`${spoken}已确认。`, RANGE, identity), false); assert.equal(isAcceptableOpeningBody(openingSpokenBody(["04:50", "05:10"], identity), RANGE, identity), false); assert.doesNotMatch(spoken, /(?:19|20)\d{2}|[??]|代表分钟|分盘|大运/); - assert.equal(spoken.split(/(?<=。)/).filter(Boolean).length, 2); + // 原值: 2 句。新值: 4 句。原因: BUG-1445 限度是两句,接在原来的两句后面 + assert.equal(spoken.split(/(?<=。)/).filter(Boolean).length, 4); for (const example of EXAMPLES) assert.equal(spoken.includes(example), false); const composed = composeCollectSpokenAssistantText(spoken, GENERIC_COLLECT_QUESTION); const [turn] = attachQuestionsToTurns([{ id: "t-product", role: "assistant", text: composed, status: "completed" }], [openingFocus("t-product")]); diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index 14c5fb52..b8a30d73 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -897,9 +897,11 @@ test("buildOpeningBrief names the search window, intake source, and six domains" assert.match(brief, new RegExp(domain)); } // 原值: /先说你最容易想起的一两件,年月大概就行/(brief 让模型照抄旧题干) - // 新值: brief 说明开场题干由服务端固定,正文两句大白话、不用行话、不重复例子 - // 原因: BUG-1049 开场题干改为服务端固定(带例子与示例回答),模型只写正文;brief 仍不得含年份 + // 新值: brief 说明开场题干由服务端固定,正文三层大白话、不用行话、不重复例子 + // 原因: BUG-1049 开场题干改为服务端固定;BUG-1445 把「两句」改成含限度的三层,brief 仍不得含年份 assert.match(brief, /开场题干由服务端固定/); + assert.match(brief, /正文三层大白话/); + assert.match(brief, /不保证/); assert.match(brief, /我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找/); assert.match(brief, /做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照/); assert.match(brief, /正文不要重复这些例子/); From ef62aa490f30bc48b7d0f71df20c3be1dc1659b8 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 27 Oct 2026 05:27:46 +0800 Subject: [PATCH 08/10] docs(research): chart UI vs competitor breakdown (agent loop, issue #114) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3 --- .../chart_ui_vs_competitor_2026_10_27.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/research/chart_ui_vs_competitor_2026_10_27.md diff --git a/docs/research/chart_ui_vs_competitor_2026_10_27.md b/docs/research/chart_ui_vs_competitor_2026_10_27.md new file mode 100644 index 00000000..a484d51c --- /dev/null +++ b/docs/research/chart_ui_vs_competitor_2026_10_27.md @@ -0,0 +1,85 @@ +# 星盘界面对标竞品:拆解与方案(issue #114,2026-10-27) + +产品负责人 10-27 转来某竞品 13 张截图(星盘页、盘面检视、深读四个页签、对话页右侧盘面),要求「按这种来做」,先按业务拆开、逐项把需要他定的事说清,全部定了再开工。截图里有一张含真实出生资料,本文不引用。 + +我方现状来自 staging `009456d8e` 代码盘点(`frontend/src/components/chart-page/*`、`chat-chart-frame.tsx`、`chart-view-*.ts`)。 + +## 0. 总体对照 + +| 竞品 | 我方今天 | +| --- | --- | +| 一页铺开:左边大盘 + 盘面检视,右边九曜表 / 宫位主题 / 大运条 / 命盘速览,下面「深读」四个页签 | 四个页签(星盘 / 基础信息 / 大运 / 西洋盘),每次只看一块 | +| 南印度方盘为默认,可切北印度菱形盘 | **只有北印度菱形盘** | +| 点星 / 点宫:盘上画宫主线和视相线、表格同行高亮、检视面板展开 | 点星只弹一个文字气泡;西洋盘有相位线联动,印度盘没有 | +| 盘上可叠「八宫点 / 宫力 / 行运」图层 | 没有图层;行运是单独一种盘型 | +| 六力、宫力、八宫点、瑜伽、出生历法都有可视化 | **引擎都算了(有接口),星盘页一样都没接** | +| 大运三层(大运 / 分运 / 子运)带进度条,等比长卷可逐层展开,可换观测日期 | 两层(大运 → 子运),子运日期是前端按年数比例拆的,没有进度、不能换日期 | +| 对话页右侧是完整可点的盘 + 检视 + 九曜表,空对话给按盘出的问题 | 右侧只有一张不能点的 D1 小图,默认收起,手机没有 | + +竞品也有没做好的地方:「宫主流向」整列「未提供」,六力「门槛未提供」,瑜伽把大量「未满足」也列出来——这些我们引擎都有数据,可以做得更完整、更干净。 + +## 1. 业务拆分(每块一张任务书,按顺序) + +### M1 盘面本身 + +- 新做南印度方盘(固定星座格、上升斜线、中心大字「上升 巨蟹」样式、度数上标、旺 / 陷 / 逆标记),与现有北印度盘可切换,记住用户选择。 +- 盘型条沿用现有 23 种(D1~D60、月亮盘、宫位盘、行运)。 +- 中心显示当前三层大运小标签。 +- 图层:无 / 八宫点(每宫角落显示点数,高于 28 金色、低于蓝色)/ 宫力 / 叠加行运(行运星以浅色叠在本命盘上)。 + +### M2 点选联动 + 盘面检视 + +- 点星:该星所在格高亮,其余变淡;实线连到它主管的宫,虚线连到它视相的宫;中心改显示这颗星;右侧表格同一行高亮。 +- 检视面板:星座内位置条(0°~30°,标月宿分界)、月宿、尊贵刻度(陷 → 中 → 旺)、六力(对门槛的百分比)、视相宫位、主管大运区间、此刻行运位置;按钮「问 AI:这颗星」「在 D9 定位」。 +- 点宫 / 点「宫位主题」小签:该宫高亮,面板显示宫主、宫内星、宫力、八宫点。 +- 键盘 ← / → 切换、Esc 收起(桌面)。 + +### M3 命盘速览 + 顶部小签 + +- 速览卡:上升(度分)、月亮星座与月宿、灵魂星(Atmakaraka,写中文)、当前大运 › 分运(带进度条与结束日)、六力最强、需留意(最弱或落陷)。 +- 页头小签:上升 / 月亮 / 太阳 / 当前大运,点了在盘上定位。 +- 与 #112 的「上升确定 / 可能换座」那一行放在一起。 + +### M4 时运流年 + +- 观测日期:日期选择 + 上月 / 下月 / 上年 / 下年 / 此刻;换日期后大运进度、行运表、叠加图层一起更新。 +- 当前运期三层(大运 / 分运 / 子运):起止日期、已过百分比、剩余时长。 +- 九曜行运表:星座度数、从上升起第几宫、从月亮起第几宫、该宫八宫点;土星七年半状态。 +- 人生长卷:三行等比条(大运 / 该大运的分运 / 该分运的子运),点哪段展开哪段;「叠到盘面」。 +- 需要引擎补:子运第三层(目前前端按比例拆,需改用引擎值)、任意日期行运(接口已有)。 + +### M5 能量图谱 + +- 行星六力:每星一条总量 + 六段分量(自然 / 位置 / 方位 / 时间 / 运动 / 视相),竖线为门槛,右侧百分比。 +- 宫位力量柱状图,均值虚线,点柱选宫。 +- 八宫点矩阵:按宫位 / 按星座;原始 / 三角削减 / 同主削减三种口径切换;格子按点数深浅着色。 +- 数据:引擎已有 `/api/shadbala`、`/api/bhava_bala`、`/api/ashtakavarga`,需接进星盘页。 + +### M6 命性格局(瑜伽) + +- 组合列表:名称、涉及行星、状态(条件满足 / 部分满足);点一条在盘上高亮涉及的星。 +- 引擎 `/api/yogas` 是否给「部分满足」需开工前核对。 + +### M7 排盘底册 + +- 出生资料与计算口径(岁差、宫制、交点)、出生历法(提毗、羯罗那、常瑜伽、日出日落、月出、当时行星时)与各要素下次交界时刻、行星名册、宫主流向(第 N 宫之主 → 落第几宫)。 +- 历法数据:星历页已有五要素,日出日落 / 行星时 / 交界时刻需引擎补。 + +### M8 对话页右侧盘面 + +- 右栏换成可点的完整盘(M1 + M2 的窄版):顶部小签、盘、检视、九曜表;「换一盘」切人物档案里的人。 +- 空对话中间显示「盘面已带入」+ 按这张盘出的 4 个示例问题。 +- 检视面板「问 AI:这颗星」直接在当前对话里发问。 + +## 2. 顺序与依赖 + +- #111(组件库修复)、#112(星盘页页头一行)都在改星盘页,**本需求排在它们合入之后**。 +- 建议分三批: + - **第一批**:M1 + M2 + M3 + M8(看得见、用得最多)。 + - **第二批**:M4 时运流年。 + - **第三批**:M5 + M6 + M7(数据型)。 +- 执行:按任务书模式交 Grok,Claude 验收;一次揭幕、首屏 gzip ±2%、`page.tsx` 不增状态等红线照旧。 + +## 3. 需要产品负责人定的事 + +见 issue #114 评论(Q1~Q9)。 From efede95bfd64e42ba33885de3644361a23bdbd07 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 27 Oct 2026 07:14:34 +0800 Subject: [PATCH 09/10] docs(tasks): #112 fix brief and #111 second-round review (agent loop) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3 --- docs/tasks/README.md | 1 + .../tasks/TASK-lagna-precheck-fix-20261027.md | 38 +++++++++++++++++++ .../TASK-ui-kit-onboarding-fix-20261026.md | 19 ++++++++++ 3 files changed, 58 insertions(+) create mode 100644 docs/tasks/TASK-lagna-precheck-fix-20261027.md diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 8a9484a7..8a70c1bd 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -537,3 +537,4 @@ | `TASK-full-data-parity-1-b-fix-20261023.md` | `PROGRESS-full-data-parity-1-b-20261023.md` | **#91 B 段修复**:K1–K3 已交;K4 年运键序与 Yogini 子运字段已补(BUG-1425、BUG-1426) | 已合入 staging | `codex/full-data-parity-1-20261021` | | — | `PROGRESS-full-data-parity-1-fix2-20261022.md` | **#91 A 段第 2 次打回**(Claude 口头修复单 F1~F4):11 节只印「未判定」和键名改读真实记录;测试只认非第一列真实值、禁止键名格;中文瑜伽组合句由中文源给出(BUG-1418);六章降为 `##`、节 `###`(BUG-1419) | 本地完成,待验收 | `codex/full-data-parity-1-fix2-20261022` | | `TASK-lagna-precheck-20261027.md` | — | **#112 选 A**:星盘页页头告诉用户上升在其时间精度内会不会换(复用现成的 D1 窗口扫描,纯计算);校正开场说清能做 / 不能做(BUG-1444、BUG-1445) | 待执行 | `codex/lagna-precheck-20261027` | +| `TASK-lagna-precheck-fix-20261027.md` | — | **#112 第 1 次打回**:已有进行中的校正时点链接报冲突(应走首页续上);别人的星盘页不该给校正链接 | 待执行 | `codex/lagna-precheck-20261027` | diff --git a/docs/tasks/TASK-lagna-precheck-fix-20261027.md b/docs/tasks/TASK-lagna-precheck-fix-20261027.md new file mode 100644 index 00000000..102019a9 --- /dev/null +++ b/docs/tasks/TASK-lagna-precheck-fix-20261027.md @@ -0,0 +1,38 @@ +# 修复单:上升交界预检(issue #112,第 1 次打回,2026-10-27) + +- 被验提交:`903d206f3`(分支 `codex/lagna-precheck-20261027`) +- 原任务书:`TASK-lagna-precheck-20261027.md` +- 执行方:Grok。同一分支继续,开工前 `git merge origin/staging`(不 rebase)。BUG 编号开工时核对最大号 +1(#111 的分支也在用 1444 起的号,以合入 staging 时的实际最大号为准,撞号由合并方改)。 + +## 验收结论(Claude,Linux) + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错(换新 `.next` 后) | +| `npm run lint` | 0 error | +| `npm test` 全量 | 5183 项,失败 25,与 staging 已知名单逐条相同;Windows 上那条技能路径失败在 Linux 通过 | +| `next build` | `/` 仍 Static;首屏 gzip 727,726,staging 727,249,+0.07% | +| Python 快速门 | 通过 | +| 文案、窗口、失败不挡星盘、开场限度、断言三栏 | 都对 | + +未通过两条,都在「出生时间校正」这个链接上。 + +## F1 · 已有进行中的校正时,点链接会报错(必修) + +`use-rectification-surface.ts` 里 `?rectify=1` 调的是 `startNewRectification` → `openRectificationCase("new", …)`。数据库函数 `open_agentic_rectification_case_v2` 在 `p_intent = 'new'` 且有可续的进行中校正时**直接抛 `agentic_rectification_active_case_conflict`**(`20260812010000_agentic_rectification_v9_runtime.sql`,「restarting with an active case must surface a safe conflict」)。上升会换的人恰恰最可能已经开过一次校正,他们点链接看到的是报错,而不是回到自己那次校正。 + +修法:链接走首页入口的同一路径——`openRectificationCase("homepage", null, null)`(有进行中的就续上,没有就新建;即本文件里现成的那个 homepage 打开函数)。不要新建 intent,不改数据库。 + +验收:加一条测试——已有可续校正时,`?rectify=1` 走 `homepage` 意图、不出现冲突提示;没有时新建。 + +## F2 · 别人的星盘页不要给链接(必修) + +现在人物档案里别人的星盘页也显示「出生时间校正」链接,点进去只得到「生时校正暂时只支持本人。」——一个点了没用的入口(产品口径:多余入口宁可删除)。 + +修法:`subjectId` 不是本人时,`lagnaPrecheckLine` 的「会换」「不知道时间」两种情况**不带链接、也不写「想定下来可以做一次出生时间校正」那半句**,句子止于「(大约 HH:MM 换座)。」或「上升星座定不下来。」;本人不变。 + +验收:组件测试——非本人两种情况 DOM 里没有链接、没有「出生时间校正」字样;本人照旧有。 + +## 交回前必跑 + +`tests/lagna-precheck*.test.ts`、`tests/chart-page-view.test.tsx`、`tests/chat-session-url.test.ts`、校正开场三份测试;全量与构建由 Claude 复验。 diff --git a/docs/tasks/TASK-ui-kit-onboarding-fix-20261026.md b/docs/tasks/TASK-ui-kit-onboarding-fix-20261026.md index 4e9004a2..931618e4 100644 --- a/docs/tasks/TASK-ui-kit-onboarding-fix-20261026.md +++ b/docs/tasks/TASK-ui-kit-onboarding-fix-20261026.md @@ -114,3 +114,22 @@ - `npm run test:db`(U6、U7 改了表的读写路径)。 - Python 快速门 `run_quality_gate.py --profile quick`。 - `docs/testing/ui-kit-migration-20261023.md`、`onboarding-journey-20261022.md` 补上本单的真机步骤(桌面加载不闪、中英切换能看出选中、老用户无启程动画)。 + +## 第 2 轮验收(2026-10-27,被验 `365a623fc`;同一单第 2 次未过,按规则交产品负责人决定由谁修) + +验收数字(Claude,Linux):`tsc` 0 错;lint 0 error;`npm test` 5196 项、失败 25,与 staging 已知名单逐条相同(比 staging 多 20 项测试);`/` 仍 Static;首屏 gzip 694,941(基线 `0f66d92d4` 646,201,+7.54%;当前 staging 727,249,−4.4%);`test:db` 83 项过 81,2 条为本机环境已知;Python 快速门通过。U2、U3、U4、U6、U7、U9 通过。 + +还要修的(按严重度): + +1. **揭幕后整页回到「正在载入账户」**(违反 AGENTS §6「揭幕后不得出现加载」,也是 U1 同类的整页卸载)。`page.tsx` 的加载判断新加了 `account && !subjectCatalogReady`;而 `use-profile-onboarding.ts` 两处(为别人建档补地点后、`chooseOnboardingOther`)调用 `invalidateSubjectCatalog()` 把 `catalogReady` 置回 false,于是整页聊天被换成加载屏;那次重载失败时 `useCurrentSubjectReady` 只在挂载时重试,页面会一直停在加载屏。修法:只有**首次**未就绪才走加载屏(已揭幕过就保留旧名单直到新名单到),重载失败要能恢复。加测试:揭幕后调用 `invalidateSubjectCatalog()` 不出现 `app-loading`。 +2. **对话右栏盘面拖不动宽度**(读代码判断,未在浏览器复现)。`chat-chart-split.tsx` 的 `ResizablePanelGroup` 里只剩一个面板加固定 `style={{ width }}`,把手两边没有可交换的面板,`onWidth` / 拖到最小收起都不会触发。修法:让聊天列仍是组里的另一个面板,但**不随右栏加载换位**(U1 的要求),或改用不依赖分组的拖宽把手;加一条测试证明拖动会调用 `onWidth`。 +3. **侧栏拖宽把手没有键盘操作**:原组件的左右方向键没了。补回方向键调整宽度。 +4. **测试删减没写三栏**(AGENTS §7.3):`report-export-drawer.test.tsx` 删了「关闭导出」按钮那条;`chart-type-picker.test.tsx` 删了 `doesNotMatch(source, / Date: Tue, 27 Oct 2026 07:43:40 +0800 Subject: [PATCH 10/10] Resume an unfinished rectification from the chart link. The chart page now continues a case already open instead of showing a conflict. Someone else's chart no longer offers that link. tsc exit 0. lint 0 errors, 122 warnings. Precheck and chart-page tests 56 pass. Opening tests 70 pass. Co-Authored-By: Grok (agent loop) --- docs/BUG_HISTORY.md | 32 +++ .../tasks/PROGRESS-lagna-precheck-20261027.md | 20 ++ docs/testing/lagna-precheck-20261027.md | 4 +- frontend/DESIGN.md | 2 +- frontend/docs/VOICE.md | 1 + .../components/chart-page/chart-page-view.tsx | 8 +- .../src/hooks/use-rectification-surface.ts | 10 +- frontend/src/lib/lagna-precheck.ts | 25 +-- frontend/tests/chart-page-view.test.tsx | 32 +++ .../lagna-precheck-rectify-entry.test.tsx | 184 ++++++++++++++++++ frontend/tests/lagna-precheck.test.ts | 9 + 11 files changed, 307 insertions(+), 20 deletions(-) create mode 100644 frontend/tests/lagna-precheck-rectify-entry.test.tsx diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3d3adee0..89f368ac 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19658,3 +19658,35 @@ - 复发自:无 - 修复版本:分支 `codex/lagna-precheck-20261027`。 +## BUG-1446 | 星盘页的校正链接在已有校正时直接报错 + +- 状态:resolved +- 首次发现:2026-10-27 +- 最近更新:2026-10-27 +- 影响面:本人星盘页上「出生时间校正」链接。上升会换的人最容易已经开过一次校正。 +- 用户现象:点这个链接,已经有一次没做完的校正时,页面报「仍有未完成的校正」,回不到那一次。 +- 触发条件:校正还在进行,从星盘页打开 `/?rectify=1`。 +- 根因:这个链接走的是「新开一次」的意图。数据库在已有可续校正时拒绝新开,并抛出冲突。首页入口用的是另一种意图:有就续上,没有就新建。 +- 修复:星盘页链接改走首页入口的同一条路,不新建意图,不改数据库。重新开始校正的按钮仍是新开。 +- 验证:`frontend/tests/lagna-precheck-rectify-entry.test.tsx`。已有校正时请求意图是 homepage,不出现冲突句;没有时同样是 homepage,由服务端新建。 +- 防复发:星盘页入口不得再调用「新开一次」。冲突句只留给真的新开。 +- 相关记录:BUG-1444。issue #112 第 1 次打回。 +- 复发自:无 +- 修复版本:分支 `codex/lagna-precheck-20261027`。 + +## BUG-1447 | 别人的星盘页也放了校正链接 + +- 状态:resolved +- 首次发现:2026-10-27 +- 最近更新:2026-10-27 +- 影响面:人物档案里其他人的星盘页页头。 +- 用户现象:别人的星盘上也有「出生时间校正」。点进去只会说校正暂时只支持本人,是个没用的入口。 +- 触发条件:打开人物档案里另一个人的星盘,上升会换,或不知道出生时间。 +- 根因:这一句对所有星盘都加了链接。校正只做本人,链接在别人的页面上没有去处。 +- 修复:不是本人时,句子停在换座时刻或「上升星座定不下来。」,不写校正,也不放链接。本人不变。 +- 验证:`frontend/tests/chart-page-view.test.tsx` 里非本人两种情况没有链接、没有「出生时间校正」;本人仍有。`frontend/tests/lagna-precheck.test.ts` 锁住这两句。 +- 防复发:非本人的页头句子不得再出现校正链接。 +- 相关记录:BUG-1444、BUG-1446。issue #112 第 1 次打回。 +- 复发自:无 +- 修复版本:分支 `codex/lagna-precheck-20261027`。 + diff --git a/docs/tasks/PROGRESS-lagna-precheck-20261027.md b/docs/tasks/PROGRESS-lagna-precheck-20261027.md index 07ed5163..baa24840 100644 --- a/docs/tasks/PROGRESS-lagna-precheck-20261027.md +++ b/docs/tasks/PROGRESS-lagna-precheck-20261027.md @@ -42,3 +42,23 @@ - 已采用(accepted)和已确认(confirmed)都不显示这一句。候选时间仍显示。 - 顶栏在这一句出现时会高于原来的 46px,没有这一句时仍是 46px。见 `frontend/DESIGN.md`。 - Skill 文件没有改,版本不升。模型说明改在页面用的系统提示里;模型漏写限度时仍换回固定句。 + +## 第 1 次打回(修复单 TASK-lagna-precheck-fix-20261027) + +开工前 `git merge origin/staging`(当时 staging `efede95bf`)。同一分支继续。 + +- 星盘页的「出生时间校正」改走首页那条路:已经有没做完的校正就回到那一次,没有才新建。不再走「新开一次」,所以不会再报「仍有未完成的校正」。重新开始的按钮没动。 +- 人物档案里别人的星盘:上升会换或不知道时间时,句子停在换座时刻或「上升星座定不下来。」,没有链接,也不写去做校正。本人不变。 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 | +| 规范 | `frontend` 下 `npm run lint` | 0 error,122 warning(原有,没有新增 error) | +| 时间窗、页头、链接 | `tests/lagna-precheck.test.ts`、`tests/lagna-precheck-chart.test.ts`、`tests/lagna-precheck-rectify-entry.test.tsx`、`tests/chart-page-view.test.tsx`、`tests/chat-session-url.test.ts` | 56 pass,0 fail | +| 校正开场三份和入口合同 | `rectification-opening-plain-20260926.test.ts`、同名 `.tsx`、`rectification-nearest-boundary-opening-20261012.test.ts`,加上表面合同四份 | 70 pass,0 fail | +| 全量 `npm test`、`next build` | 未跑 | 留给验收 | +| Python 快速门、`test:db` | 未跑 | 没改 Python,没改表 | + +既有断言的通过值没有改。新增的是别人星盘没有链接,以及 `?rectify=1` 走 homepage。 + +BUG-1446、BUG-1447。真人清单第 5、6 条已按这次改法重写。 diff --git a/docs/testing/lagna-precheck-20261027.md b/docs/testing/lagna-precheck-20261027.md index 870c4808..792f854d 100644 --- a/docs/testing/lagna-precheck-20261027.md +++ b/docs/testing/lagna-precheck-20261027.md @@ -8,5 +8,5 @@ 2. 大概时间(前后 15 分钟或你选过的范围):同样看这一句是否和你填的精度一致。 3. 只知道一个时段:句子改成「按你给的时间段(开始–结束)」,不再写「前后约 N 分钟」。 4. 已经采用过校正时间的盘:这一句不出现。 -5. 人物档案里另一个人的星盘页:这一句可以出现。点「出生时间校正」会回到首页,并提示生时校正暂时只支持本人。不会给他另开一套校正。 -6. 本人点「出生时间校正」:进入现有的生时校正。开场要能听到三层意思:在哪段时间里找、你说几件大事和大概年月、不保证精确到某一分钟,说不准就照实记,中途可以停。 +5. 人物档案里另一个人的星盘页:这一句可以出现。上升会换或不知道时间时,句子里没有「出生时间校正」,也没有可点的链接。不会给他另开一套校正。 +6. 本人点「出生时间校正」:如果已经有一次没做完的校正,回到那一次,不要出现「仍有未完成的校正」。如果还没有,新开一次。开场要能听到三层意思:在哪段时间里找、你说几件大事和大概年月、不保证精确到某一分钟,说不准就照实记,中途可以停。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 2dc5d21a..18bf9032 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1043,7 +1043,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 ### 结构 -页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。出生资料下面可以多一句上升是否会换(`.chart-lagna-precheck`,caption、三级色,没有卡片和图标)。这一句在的时候,顶栏改成随内容增高,不再卡在 46px;没有这一句时顶栏仍是 46px。句中的「出生时间校正」是下划线链接,上升已经确定时没有链接。顶栏之下第一件东西是 Tab 行。不写眉标,不写「打开即有」、不写「主盘直接算 · 分盘按需 · 不消耗点数」,也不承诺打开速度。等待外壳和失败页只有原因/等待句。这一句和星盘一起出现,不算出来就不占位置,不另做等待。 +页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。出生资料下面可以多一句上升是否会换(`.chart-lagna-precheck`,caption、三级色,没有卡片和图标)。这一句在的时候,顶栏改成随内容增高,不再卡在 46px;没有这一句时顶栏仍是 46px。句中的「出生时间校正」是下划线链接,只出现在本人的星盘上,而且上升已经确定时没有链接。人物档案里别人的星盘只说上升会不会换,不放这个链接,也不写去做校正。顶栏之下第一件东西是 Tab 行。不写眉标,不写「打开即有」、不写「主盘直接算 · 分盘按需 · 不消耗点数」,也不承诺打开速度。等待外壳和失败页只有原因/等待句。这一句和星盘一起出现,不算出来就不占位置,不另做等待。 四个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘**,画成**一条下划线式 Tab 行**:无边框、无卡面,当前项 2px 下划线 + 主文字色,整行一条 1px 底线。行内 `flex-wrap: nowrap` 且自身横向滚动,所以 375px 下它仍是一行,不折行、不换成第二排——底线画在滚动容器自己的 border 上,不会跟着内容滚走。一排等重的药丸按钮会读成几个可按的东西,Tab 行读成「一个控件 + 一个当前位置」。2026-10-25 起这四个页签是组件库 `Tabs`,`variant="line"`。盘还没到时四个都不可选。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 2785200e..e248b6d6 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -271,6 +271,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 添加一个人」,去星盘档案。满 20 人不出现这一行。 - 星盘页人名旁边只写「日期 时间 · 地点」,末尾一支小笔。整行可点,就在这一页改,不跳走。本人每项都能改。别人已经填过的出生时间不能改,这句话只出现在时间旁边:「填报的出生时间保存后不能再改」。表单说明里不重复。星盘档案里编辑这样的人,也只在时间旁边写这一句。改了日期、时间或地点时写:「改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。」手机上这一行换到第二行。点编辑框内侧的空白边不会关掉框。 +- 出生资料下面可以有一句上升会不会换。本人上升会换、或不知道出生时间时,「出生时间校正」是链接:已经有没做完的校正就回到那一次,没有才新开。别人的星盘只说到「(大约 HH:MM 换座)。」或「上升星座定不下来。」,不写「出生时间校正」,也不给这个链接。 - 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。行星位置这类数值表太宽时,在表自己的框里横滑,页面不被撑开。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里,格子里的字可以换行。 ## 报告分块导出与删除 diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index 9cdef83b..4c160c8f 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -66,8 +66,8 @@ function WaitingNote({ copy }: { copy: string }) { return

{copy}

; } -function ChartLagnaPrecheckNote({ value }: { value: ChartViewLagnaPrecheck }) { - const line = lagnaPrecheckLine(value); +function ChartLagnaPrecheckNote({ value, self }: { value: ChartViewLagnaPrecheck; self: boolean }) { + const line = lagnaPrecheckLine(value, { self }); return (

{line.before} @@ -169,7 +169,9 @@ export function ChartPageView({ {`${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`}