diff --git a/CHANGELOG.md b/CHANGELOG.md index cac04799..41cf6150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-10-15 — 星盘页上升行补上星宿,表头对齐(待合入 staging) + +- 本命盘和月亮盘的上升行现在有星宿、宿主和 pada。状态写「不适用」,不再是空横杠。分盘等表里居中的那几列,表头也居中。当前大运不再画左侧那条线,浅色底还在。已经打开过的星盘会重新计算。Skill 版本不变(BUG-1352、BUG-1353)。 + +## 2026-10-15 — 旧版校正评分轮询不再中途报错(待合入 staging) + +- 旧版生时校正在查询评分进度时,会因为多读了一步数据库结果而直接失败。现在按数据库实际返回的那一行读取。其他查询行为不变。Skill 版本不变(BUG-1351)。 + ## 2026-10-14 — 生产可以在测试环境后面只多了文档时照常发布(待合入 staging) - 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码,网址末尾多一个斜杠也拒绝。迁移演练在主机上不再依赖本机 Node;演练库删不掉会明确失败。只多了文档时,回退不再被拒绝。Skill 版本不变(BUG-1348、BUG-1349)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index fa95654e..43b73f57 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -16174,6 +16174,7 @@ - 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。 - 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。 - 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。 +- 2026-10-15 更正:上面的验证只打到引擎 `compute_chart_data`,前端 golden 的星宿三项是事后补上去的,没有经过 `/api/chart` 重建上升的那一层。接口仍把三项丢掉,星盘页继续显示「—」。见 BUG-1352。 - 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。 - 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。 - 复发自:无 @@ -34241,6 +34242,7 @@ - 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。 - 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。 - 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。 +- 2026-10-15 更正:上面的验证只打到引擎 `compute_chart_data`,前端 golden 的星宿三项是事后补上去的,没有经过 `/api/chart` 重建上升的那一层。接口仍把三项丢掉,星盘页继续显示「—」。见 BUG-1352。 - 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。 - 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。 - 复发自:无 @@ -36132,7 +36134,55 @@ - 复发自:无。 - 修复版本:分支 `codex/release-path-production-20261014`。编号接在 BUG-1349 之后。 -## BUG-1351 | 低强度推理设置写在嵌套字段里,供应商收不到 +## BUG-1351 | 自托管下旧版校正评分轮询 rpc 结果多读一步,必抛 TypeError + +- 状态:resolved +- 首次发现:2026-10-13 +- 最近更新:2026-10-15 +- 影响面:旧版生时校正(legacy-guided-v1)的评分轮询,`POST /api/birth-time-journey` 且类型为 `poll_scoring` +- 用户现象:这类存量校正在查看评分进度时直接失败,页面拿不到「已领取 / 处理中 / 已完成」。 +- 触发条件:自托管数据库(当前 staging 与生产都是这条路径)上,对 `claim_birth_time_scoring_job` 的返回值再调用 `.single()`。该函数返回的是表格里的一行,本地适配层把整表收成数组,没有 `.single`。 +- 根因:数据访问层被标成完整的 Supabase 客户端,编译器看不出适配层没有 `.single`。原有测试用内存仓库,没有走过真实适配层。 +- 修复:去掉 `.single()`。返回值如果是数组就取第一行,如果已是单行对象就直接用,再做原来的字段校验。Supabase 回滚分支不带 `.single()` 时同样返回数组,行为一致。 +- 验证:修复前两条单元测试都抛出 `supabase.rpc(...).single is not a function`。修复后这两条通过。另有一条真实迁移测试:函数返回值是数组,认领结果为已领取,且不抛错。`tsc --noEmit` 0 错。 +- 防复发:适配层 `implements` 自己的 `DataClient`。新增调用如果用了适配层没有的方法,编译直接失败。评分认领有数据库级用例。 +- 相关记录:BUG-124、BUG-130、BUG-576、BUG-926 +- 复发自:无 +- 修复版本:分支 `codex/data-layer-types-20261013`。任务书预留 BUG-1343;开工时本文件最大号已是 BUG-1350,1343 没有条目。按「从当前最大号往后」编为 BUG-1351。 + +## BUG-1352 | 星盘页上升行的星宿三项被排盘接口丢掉 + +- 状态:resolved +- 首次发现:2026-10-03 +- 最近更新:2026-10-15 +- 影响面:星盘页本命盘和月亮盘的上升行(星宿、宿主、pada) +- 用户现象:这两行的星宿、宿主、pada、状态都是「—」,像没算出来。九曜那几行是有的。 +- 触发条件:打开星盘页的本命盘或月亮盘。上升经度其实已经算好。 +- 根因:引擎给上升写了星宿三项,排盘接口在重组结果时只留了星座和度数,把这三项丢了。月亮盘用的是同一份上升。上次修复只测了引擎,前端样例又是事后补上这三项的,所以接口这一层一直没被发现。 +- 修复:接口重组上升时原样带上这三项;引擎没给时按同一套星宿算法补上。排盘缓存版本 6 改为 7,避免继续用没有这三项的旧结果。 +- 验证:虚构盘(1990-06-15 12:00 北京)走排盘处理函数,上升和 `modules.chart.ascendant` 的三项与星宿算法一致,为 Uttara Phalguni、1、Sun。本命表和月亮盘表都渲染出这三项。 +- 防复发:回归测试必须经过排盘处理函数,不能只测引擎,也不能手工往样例里补字段。 +- 相关记录:BUG-1201、BUG-1314 +- 复发自:BUG-1201(修复没有打通接口层) +- 修复版本:分支 `codex/chart-ascendant-row-20261015`。任务书写明 BUG-1351 已被另一单占用,本单从 BUG-1352 起。 + +## BUG-1353 | 星盘表居中列的表头靠左,当前大运卡片左边多一条线 + +- 状态:resolved +- 首次发现:2026-10-15 +- 最近更新:2026-10-15 +- 影响面:星盘页行星表的表头,以及大运列表里当前这一段 +- 用户现象:分盘「庙旺」的表头靠左、格子里的字居中,对不齐。当前大运卡片左边有一条竖线。 +- 触发条件:打开星盘页的分盘表或大运列表。 +- 根因:居中只加在内容格子上,表头没有。八列表的写法是冻结的,不能改标记。大运当前段用内阴影画了左侧强调线,设计说明里没写,也没有测试盯着。 +- 修复:表头与内容一起居中。本命和月亮盘的八列表不改标记,用列选择器把速度、宿主、pada 的表头居中,宫位仍靠左。分盘的庙旺和同位、行运的速度、Bhava 的换宫,表头加上与内容相同的居中标记。当前大运去掉左侧那条线,浅底和细边框留着。 +- 验证:样式合同锁定八列表对应表头居中、宫位列仍靠左,以及当前大运规则里没有 box-shadow。分盘、行运、Bhava 的渲染测试锁定表头带居中标记。 +- 防复发:上述样式合同和渲染测试。 +- 相关记录:BUG-1076、BUG-1352 +- 复发自:无 +- 修复版本:分支 `codex/chart-ascendant-row-20261015`。 + +## BUG-1354 | 低强度推理设置写在嵌套字段里,供应商收不到 - 状态:investigating - 首次发现 / 最近更新:2026-10-13 / 2026-10-15 @@ -36145,5 +36195,5 @@ - 防复发:新测试锁住「开关不设时请求体与改前一致」,并锁住新强度出现在最外层。 - 相关记录:BUG-148、BUG-1142。 - 复发自:无。 -- 修复版本:未修。分支 `codex/answer-effort-ab-20261013` 只记录。编号接在 BUG-1350 之后。 +- 修复版本:未修。分支 `codex/answer-effort-ab-20261013` 只记录。原先写在 BUG-1351,该号已被数据层占用;按本文件规则保留较早的 BUG-1351,本条改为 BUG-1354,接在 BUG-1353 之后。 diff --git a/docs/tasks/PROGRESS-answer-effort-ab-20261013.md b/docs/tasks/PROGRESS-answer-effort-ab-20261013.md index ac86d009..c2c647c8 100644 --- a/docs/tasks/PROGRESS-answer-effort-ab-20261013.md +++ b/docs/tasks/PROGRESS-answer-effort-ab-20261013.md @@ -35,7 +35,7 @@ ## 需拍板 - 默认强度。本单不改。有钥匙的验收按任务书 T3 出数之后,再决定设不设、设哪一档。部署默认写在哪里也留到那次。 -- `consultationSliceGenerationSettings` 删掉还是改成最外层。它没有调用方。本单不改。见 BUG-1351。 +- `consultationSliceGenerationSettings` 删掉还是改成最外层。它没有调用方。本单不改。见 BUG-1354(原先写成 BUG-1351,该号已被数据层占用)。 - 若供应商不接受降档,对照组 D(答题步关思考)只供参考,采用与否另说。本机没跑。 ## 环境缺口 diff --git a/docs/tasks/PROGRESS-chart-ascendant-row-20261015.md b/docs/tasks/PROGRESS-chart-ascendant-row-20261015.md new file mode 100644 index 00000000..54bf0568 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-ascendant-row-20261015.md @@ -0,0 +1,33 @@ +# PROGRESS · 星盘页上升行(issue #53 第二轮) + +- 分支:`codex/chart-ascendant-row-20261015` +- 基线:`origin/staging` `2b7112a6010acbcc0fec809b6b27e2375293dd5c` +- 编号:本文件最大号是 BUG-1350。任务书说 BUG-1351 已被 #65 的分支占用,本单用 BUG-1352(上升星宿被接口丢掉,复发自 BUG-1201)和 BUG-1353(表头错位与大运左边线)。 + +## 做了什么 + +- `/api/chart` 重组上升时带上 `nakshatra`、`nakshatra_pada`、`nakshatra_lord`。引擎已经有就原样带上;没有才按同一套星宿算法补。`cache_schema_version` 6 → 7。 +- 上升行状态从「—」改为「不适用」。本命、月亮盘共用这一行。 +- 八列表的标记没改。速度、宿主、pada 的表头用列选择器居中,宫位仍靠左。分盘的庙旺 / 同位、行运的速度、Bhava 的换宫,表头加上与内容相同的居中标记。 +- 当前大运去掉 `box-shadow`,浅底和细边框还在。 + +## 样例 + +`chart-view-golden.json` 里上升的三项本来就是 Uttara Phalguni、1、Sun,和这次接口真实返回一致,数值没有手改。`sourceCommand` 不再写「用 `_nakshatra_fields` 事后补上」,改为写明来自 `_compute_chart_sync`。 + +## 断言改动 + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `chart-varga-table.test.tsx` 上升行状态格 | `—` | `不适用` | 产品把横杠看成没数据 | +| `test_api_scratch_retention.py` 缓存版本 | `6` | `7` | 旧缓存没有星宿三项,必须换键 | +| 基线 html | 仍是改前的整张表 | 不改文件 | 测试用替换得到新表,原值留在基线里 | + +## 自测 + +- `pytest tests/test_api_chart_ascendant_nakshatra.py tests/test_report_chart_blank_columns.py tests/test_api_server_growth_contract.py tests/test_api_scratch_retention.py -q`:34 通过,退出码 0。没有新增 `JyotishAPIHandler.__new__`。 +- 前端:`chart-varga-table`、`chart-moon-transit`、`chart-bhava`、`chart-page-mobile-width-20260929`、`runtime-sources-no-test-imports`。月亮盘那条第一次用整页去配「上升」,配到了图上的标记;改成只看月亮盘表格后通过。 +- `tsc --noEmit -p .`:0 个错误,退出码 0。 +- `npm run lint`:0 个错误、123 条原有警告,退出码 0。 +- `chart-page-view` 与 `chart-page-hook`:46 通过,退出码 0。 +- 真机清单沿用任务书里已有的 `docs/testing/chart-ascendant-row-20261015.md`。 diff --git a/docs/tasks/PROGRESS-data-layer-types-20261013.md b/docs/tasks/PROGRESS-data-layer-types-20261013.md new file mode 100644 index 00000000..3fbf0f66 --- /dev/null +++ b/docs/tasks/PROGRESS-data-layer-types-20261013.md @@ -0,0 +1,92 @@ +# PROGRESS · 数据访问层 DataClient 类型(第一阶段) + +- 分支:`codex/data-layer-types-20261013` +- 基线:`origin/staging` `9274491681f6fcacbc87e3797441d2c5221de874` +- 前置:`origin/codex/node-runtime-resources-20261013` 已是 `origin/staging` 的祖先(`merge-base --is-ancestor` 退出码 0)。`local-postgres-client-core.ts` 只加了 `import type` 和两处 `implements`。 +- `frontend/src/lib/rectification-agentic/session.ts` 已不在,本单没有改它。 +- 编号:任务书预留 BUG-1343。开工时 `docs/BUG_HISTORY.md` 最大号是 BUG-1350,1343 没有条目。按台账规则从最大号往后,记为 **BUG-1351**。 + +## T1 · 编译器暴露的 2 条 + +`DataClient` 换上之后、缺陷尚未修时,`tsc --noEmit` 退出码 2,恰好 2 条: + +``` +src/app/api/chart-profiles/route.ts(59,40): error TS7006: Parameter 'row' implicitly has an 'any' type. +src/lib/birth-time-scoring-job-store.ts(103,10): error TS2339: Property 'single' does not exist on type 'Promise | null; count?: number | null | undefined; }>>'. +``` + +`grep`:`as unknown as SupabaseClient` 在 `frontend/src` 无结果。`@supabase/supabase-js` 只剩 `admin-client-core.ts` 的 `createClient` 回滚分支。 + +## T2 · 缺陷修复红转绿 + +修复前,`claimScoringJob` 对返回数组的假 `rpc` 调用 `.single()`: + +- `claimScoringJob reads the first row when rpc returns an array`:失败,`TypeError: supabase.rpc(...).single is not a function` +- `claimScoringJob also accepts the single-object rpc shape`:同样失败 + +去掉 `.single()` 并兼容数组与单对象之后,这两条通过。真实迁移用例 `claim_birth_time_scoring_job returns an array and claimScoringJob accepts it` 通过:`data` 是数组,第一行 `claim_state` 为 `claimed`,store 返回 `{ kind: "claimed", algorithmVersion }`。第一次启动测试库时迁移进程只留下笼统的 `database migration failed`(脚本会把非预期异常收成这一句),容器未到 Healthy。同一条用例在容器 Healthy 后重跑通过。 + +`chart-profiles/route.ts` 的 `row` 标为 `unknown`。 + +修完后 `tsc --noEmit` 退出码 0。`npm run lint` 退出码 0,0 error,123 warning(与改动前的存量警告数量一致,本单没有新增 warning)。 + +## T3 · 转译差分 + +类型层改动用 esbuild 0.28.1、`--format=esm`、`--tsconfig=frontend/tsconfig.json`,旧文件与新文件都按这份 tsconfig 转译。除 T2 的两个文件和新建的 `data-client.ts` 外,全部 `SAME`: + +- `src/lib/birth-time-journey-case-loader.ts` +- `src/lib/birth-time-journey-store.ts` +- `src/lib/birth-time-journey-turn-persistence.ts` +- `src/lib/db/local-postgres-client-core.ts` +- `src/lib/rectification-agentic/v9/case-service.ts` +- `src/lib/rectification-agentic/v9/product-domain.ts` +- `src/lib/rectification-agentic/v9/tool-service.ts`(只改了一句注释;转译后注释被去掉,产物相同) +- `src/lib/rectification-in-chat-server.ts` +- `src/lib/supabase/admin-client-core.ts` +- `src/lib/supabase/server.ts` +- `src/mastra/rectification-v9-tools.ts` + +任务书把旧文件写到系统临时目录再转译。esbuild 0.28.1 会沿文件所在目录寻找 `tsconfig.json`。临时目录找不到本仓库的 `tsconfig.json`(`target` 为 ES2017),类字段的转译结果会和仓库内文件不一致,即使源码只有类型或注释差别也会报 DIFF。两边都带上同一份 `--tsconfig` 后为 SAME。这不是运行时变化。 + +## T4 · 把 data 收成 unknown 的错误清单 + +临时把 `UntypedData` 改成 `unknown` 后 `tsc --noEmit` 退出码 2,共 93 条、12 个文件。随后已改回 `any`。 + +| 条数 | 文件 | +| ---: | --- | +| 24 | `src/app/api/account/route.ts` | +| 17 | `src/app/api/consult/route.ts`(其中 1 条是 `DataClient` 不能赋给 `GuardedTitleClient`,`consult/route.ts` 约 1055 行) | +| 16 | `src/app/api/consult/status/route.ts` | +| 12 | `src/app/api/payment/epay/status/route.ts` | +| 7 | `src/lib/epay/availability.ts` | +| 5 | `src/lib/rectification-agentic/v9/product-domain.ts` | +| 4 | `src/app/api/sessions/route.ts` | +| 3 | `src/lib/rectification-agentic/v9/agent-route-request.ts` | +| 2 | `src/app/api/chart-profiles/[id]/route.ts` | +| 1 | `src/app/api/account/avatar/route.ts` | +| 1 | `src/app/api/chart-profiles/route.ts` | +| 1 | `src/app/api/sessions/[id]/route.ts` | + +和任务书写作时的 122 条相比,`consult/route.ts` 少了 10 条,`chart-profiles/route.ts` 少了 1 条(本单已把 `row` 标成 `unknown`)。没有单独再出现一条 `session-title-guard.ts` 的错误,`GuardedTitleClient` 那条记在 `consult/route.ts` 里。这些留给第二阶段,本单没有改那些业务文件。 + +## test:db + +`npm run test:db`(`tsx --test --test-concurrency=1 tests/database-*.test.ts`)约 19 分钟,退出码 1。汇总:85 条,通过 72,失败 13,取消 0,跳过 0。 + +失败的 13 条与 `tools/agent-loop/windows-known-failures.txt` 逐条一致,没有多出来的失败: + +- staging backups are encrypted, atomic, private, and retain the newest three +- rejects destructive backup directory aliases and symlink components before mutation +- rejects unsafe writable backup parents before creating the target +- rejects a direct canonical sticky shared backup directory before chmod +- creates every absent backup path component privately despite a permissive caller umask +- same-second backups publish once without overwriting the completed archive +- find enumeration failures preserve existing backups and do not report completion +- refuses full disks and removes a failed-pipeline partial file +- database env validator accepts punctuated literal required secrets +- database env validator enforces an explicit staging owner uid without printing values +- database env validator accepts a private valid file without printing values +- personal report job migration is atomic, lease-bound, recoverable, and owner read-only +- personal report sections enforce owner-read RLS and service-owned durable transitions + +本单新增的 3 条(两条假客户端、一条真实迁移认领)都在通过的 72 条里。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 5b5eaa7c..d9312e03 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -213,6 +213,7 @@ | --- | --- | --- | --- | --- | | `TASK-rectification-step0-entry-20261013.md` | — | **明说要校正时第一步就能打开(agent 循环 issue #54,BUG-1306 复发)**:第 0 步只放排盘工具、规则正向句埋在禁止句里、「纠正」撞词,模型没带 explicitRequest 调工具(BUG-1317)。产品选 B:第 0 步放出校正工具 + 规则重排;推翻 BUG-923 第 0 步限制,保险用现有 runtime-contract-retry。执行方 Grok | 待领取 | `codex/rectification-step0-entry-20261013` | | `TASK-report-focus-card-20261013.md` | — | **报告开篇「重点」做成摘要卡(agent 循环 issue #55)**:10-08 加的开篇重点从未设计——三重「重点」、标题无样式、MD/AD/PD 行话、数据句堆砌(BUG-1316)。产品选 A 摘要卡 + 名字与日期 + 保留提醒句;引擎出 `jyotish-focus` 围栏,前端一个组件渲染。执行方 Grok | 待领取 | `codex/report-focus-card-20261013` | +| `TASK-chart-ascendant-row-20261015.md` | `PROGRESS-chart-ascendant-row-20261015.md` | **星盘页上升行无星宿 / 表头错位 / 大运卡左边线(issue #53 第二轮)**:`/api/chart` 重建上升时丢了星宿三字段(BUG-1352,复发自 BUG-1201);状态改「不适用」;居中列表头对齐;当前大运去掉左边线(BUG-1353) | 待验收 | `codex/chart-ascendant-row-20261015` | | `TASK-report-translation-gaps-20261013.md` | — | **个人报告中英混杂、正文图盘空白、上升行、同位列(agent 循环 issue #53)**:中文完整数据版靠短词替换英文,正文残留 218 处英文词与 14 处中英粘连,卫生检查只有黑名单(BUG-1312);完整数据版图盘节只有空标题、无围栏,BUG-1203 的回归(BUG-1313);报告本命表无上升行(BUG-1314);D9 同位列「—」读如没数据,改「是/否」(BUG-1315)。分盘核对段随 T1 自动消失。执行方 Grok | 待领取 | `codex/report-translation-gaps-20261013` | | `TASK-report-translation-gaps-fix-20261013.md` | — | **#53 第 1 次打回**:`a295afa4` 实现四项都验过,但分支未基于最新 staging,且全量 pytest 新增 1 条失败(报告 Jaimini 星位名改成「灵魂星(AK)」后 `test_chara_karaka_8_bphs_order` 未跟改)。要求 rebase + 按三栏改测试,不改 `KARAKA_CN` | 待领取 | `codex/report-translation-gaps-20261013` | | `TASK-report-full-data-edition-20261007.md` | `PROGRESS-report-full-data-edition-20261007.md` | **报告改为完整数据版、删除阅读版**:网站阅读版中文 7.6 万字符 vs 上游 `pl9_ai_density` 97 万;移植原始数据附录 / 清理器 / 时间补充,中英文;我方报告口径(BUG-1199~1229)全部保持;旧报告仍可打开;排在 sync6 之后 | 已验收(Claude 补修 BUG-1282 后合入 staging) | `codex/report-full-data-edition-20261007`(未推 staging) | @@ -483,11 +484,13 @@ | `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` | | `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` | | `TASK-api-chart-subprocess-gate-20261013.md` | — | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待领取(排队:api-disk-cache-retention + report-reference-perf) | `codex/api-chart-subprocess-gate-20261013` | +| `TASK-api-chart-subprocess-gate-fix-20261015.md` | — | **#64 第 1 次打回**:`336286e4` 内容通过(合盘 1.19→0.015 s、正文逐字节一致);Python 合同按源码检查合盘路由旧写法未跟改;A/B 脚本默认覆盖入库记录 | 待领取 | `codex/api-chart-subprocess-gate-20261013` | | `TASK-answer-effort-ab-20261013.md` | `PROGRESS-answer-effort-ab-20261013.md` | 答题步推理强度开关 + A/B(需模型 key) | 待验收 | `codex/answer-effort-ab-20261013` | +| `TASK-answer-effort-ab-fix-20261015.md` | — | **#65 第 1 次打回**:`ab7f66be` 开关逻辑通过(默认请求体不变);研究脚本从 `frontend/tests` 读文件(BUG-1140) | 待领取 | `codex/answer-effort-ab-20261013` | | `TASK-consult-route-split-20261013.md` | — | consult POST 拆分第一阶段:开流前加载 + 计费结算 | 待领取(排队:consult-prep-parallel、answer-effort-ab) | `codex/consult-route-split-20261013` | | `TASK-pl9-render-split-20261013.md` | — | render_pl9_markdown 第一阶段:搬出无依赖内部函数 | 待领取(排队:api-chart-subprocess-gate) | `codex/pl9-render-split-20261013` | | `TASK-python-hygiene-20261013.md` | — | 免费层排队开关报反、静默吞异常、功能吉凶兜底合一、测试专用脚本归位(BUG-1340) | 待领取(排队:pl9-render-split) | `codex/python-hygiene-20261013` | | `TASK-dasha-unify-20261013.md` | — | /api/dasha 改走引擎同一套实现、删占位注册表、Ashtottari 出生余额(BUG-1341、1344) | 待领取(排队:python-hygiene) | `codex/dasha-unify-20261013` | | `TASK-api-server-service-extract-20261013.md` | — | API 单体第一阶段:拆出不带 HTTP 的计算类,生产伪造 handler 归零 | 待领取(排队:dasha-unify) | `codex/api-server-service-extract-20261013` | -| `TASK-data-layer-types-20261013.md` | — | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1343) | 待领取(排队:node-runtime-resources) | `codex/data-layer-types-20261013` | +| `TASK-data-layer-types-20261013.md` | `PROGRESS-data-layer-types-20261013.md` | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1351;任务书预留的 1343 已被更大编号越过) | 待验收 | `codex/data-layer-types-20261013` | | `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` | diff --git a/docs/tasks/TASK-answer-effort-ab-fix-20261015.md b/docs/tasks/TASK-answer-effort-ab-fix-20261015.md new file mode 100644 index 00000000..c8ef3b42 --- /dev/null +++ b/docs/tasks/TASK-answer-effort-ab-fix-20261015.md @@ -0,0 +1,36 @@ +# TASK · 答题推理强度开关修复单(issue #65,第 1 次打回) + +- 日期:2026-10-15 +- 原任务书:`docs/tasks/TASK-answer-effort-ab-20261013.md` +- 被验 SHA:`ab7f66be14d93a5ccbc3433f3c44e134a65b1e21`(`codex/answer-effort-ab-20261013`) +- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史) + +## 1. 验收结论:开关逻辑通过,前端全量多出 1 条失败 + +| 检查 | 结果 | 证据 | +| --- | --- | --- | +| 开关默认关时请求体不变 | 通过 | `CONSULT_ANSWER_REASONING_EFFORT` 未设或为空时 `answerEffortOptions` 返回 `{}`;非法值只警告一次按未设处理 | +| 只在答题步加强度 | 通过 | 主循环第 0 步 `answerReady=false`;公开日历从第一步即答题 | +| 改既有断言 | 通过 | `consult-answer-clock-20261001.test.ts` 三栏齐全 | +| tsc / lint / Python 全量 | 通过 | Python 与 staging 逐条一致(一条 prashna 族测试并发下抖动,单跑通过) | +| **前端全量 `npm test`** | **未通过:新增 1 条** | `frontend/tests/runtime-sources-no-test-imports.test.ts`「files copied into the web image never import from frontend/tests」 | + +**根因**:`frontend/scripts/research/answer-effort-probe.mts` 约 186 行 `new URL("../../tests/fixtures/answer-effort-switch-20261013.json", import.meta.url)`。web 镜像只复制 `frontend/src` 与 `frontend/scripts`,`next build` 会类型检查它们——推上 staging 后镜像构建会失败(BUG-1140;#58、#62 已因同一形状各打回一次)。 + +## 2. 修法要求 + +1. 把 `answer-effort-probe.mts` 挪到 `frontend/tests/answer-effort-probe.mts`(文件名不得以 `.test.ts` / `.test.tsx` 结尾),路径改相对;同步改 PROGRESS 与 `docs/testing/answer-effort-ab-20261013.md` 里的命令。 +2. **交回前必须本机跑一次** `npx tsx --test tests/runtime-sources-no-test-imports.test.ts`,fail 与 cancelled 为 0,贴进回报。 +3. 业务代码不改;开关默认值不改。 + +## 3. 非阻断提醒(不要求本轮改,写进 PROGRESS 即可) + +开关打开时,`prepareStep` 返回的 `providerOptions` 来自 `agentGenerationSettings(model, { thinking: "enabled", ... })`。确认它不会把基础流选项里原有的 `providerOptions`(思考预算、缓存等)整体覆盖掉;若会覆盖,在 BLOCKED.md 记下,等有模型 key 的 A/B 一并核对。 + +## 4. 验收标准 + +前端全量 `npm test` 失败清单与 staging 基线逐名相同;`git diff ab7f66be..<新 SHA> -- frontend/src` 为空。 + +## 5. 让步顺序 + +无。第 1 次打回。 diff --git a/docs/tasks/TASK-api-chart-subprocess-gate-fix-20261015.md b/docs/tasks/TASK-api-chart-subprocess-gate-fix-20261015.md new file mode 100644 index 00000000..061419d3 --- /dev/null +++ b/docs/tasks/TASK-api-chart-subprocess-gate-fix-20261015.md @@ -0,0 +1,37 @@ +# TASK · 合盘与报告子进程修复单(issue #64,第 1 次打回) + +- 日期:2026-10-15 +- 原任务书:`docs/tasks/TASK-api-chart-subprocess-gate-20261013.md` +- 被验 SHA:`336286e428c7cb43cac71eae5286e112c0b500b7`(`codex/api-chart-subprocess-gate-20261013`) +- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史) +- BUG 编号:沿用本单编号 + +## 1. 验收结论:内容通过,Python 全量多出 1 条失败 + +| 检查 | 结果 | 证据 | +| --- | --- | --- | +| 合盘带 skip、两两并发 | 通过 | Claude 在 Linux 复跑 `scripts/research/api_chart_subprocess_ab.py`:串行 1.19 s / 4 个子进程 → 并发带 skip 0.015 s / 0 个,摘要逐字节一致 | +| 报告正文 | 通过 | 同脚本 `report_bytes_match: true`(本机装了 PyJHora,短路不触发,子进程数不变;生产不装,以 Grok 回报为准) | +| 进程内缓存 | 通过 | 只缓存「PyJHora 是否可导入」「外部解释器路径」「静态 oracle 文件校验(键含路径 + mtime + 大小)」,不含出生资料(BUG-721) | +| 主文件 | 通过 | 类方法数不变,行数减少 | +| 前端全量 / tsc / lint | 通过 | 失败清单与 staging 基线相同 | +| **Python 全量(与 staging 逐条比对)** | **未通过:新增 1 条** | `tests/test_supabase_user_data_contract.py::test_synastry_route_orchestrates_python_chart_and_ashtakoot`:`assert 'postPython("/api/chart", selfPayload)' in route` | + +**根因**:这条 Python 合同按源码文本检查 `frontend/src/app/api/synastry/route.ts` 里的 `postPython("/api/chart", selfPayload)`。本单把它改成了 `Promise.all` 里的 `postPython("/api/chart", { ...selfPayload, skip_vedastro_main_entry_overview: true })`,合同没跟着改(AGENTS.md §7.8;Python 测试也会读前端源码,所以前端改动要 grep `tests/` 和 `frontend/tests/` 两处)。 + +另:本机复跑时 `api_chart_subprocess_ab.py` 会**覆盖仓库里的** `docs/testing/api-chart-subprocess-gate-ab-20261013.json`。研究脚本默认不应写已入库的记录文件:改为默认写到临时目录或 `artifacts/`,只有显式 `--out <路径>` 时才写指定文件。 + +## 2. 修法要求 + +1. 改 `tests/test_supabase_user_data_contract.py` 那条断言,按 §7.3 写「原值 / 新值 / 原因」三栏,**意图不放宽**:仍要锁住「合盘路由向 Python `/api/chart` 请求本人和对方两张盘」,新断言要同时锁住带 `skip_vedastro_main_entry_overview: true`;该测试里其它 token(如 ashtakoot 调用)不动。 +2. `grep -rn "synastry/route\|postPython(\"/api/chart\"\|postPython(\"/api/varga_full\"" tests frontend/tests`,其它按源码文本断言的同样处理。 +3. `api_chart_subprocess_ab.py` 默认不覆盖入库文件(见上)。 +4. 业务代码不改。PROGRESS 末尾记一行。 + +## 3. 验收标准 + +Claude 全量 pytest 与 staging 基线逐条比对无新增;前端全量无新增;`git diff 336286e4..<新 SHA> -- frontend/src scripts/*.py` 只有第 3 条的脚本改动。 + +## 4. 让步顺序 + +无。第 1 次打回。 diff --git a/docs/tasks/TASK-chart-ascendant-row-20261015.md b/docs/tasks/TASK-chart-ascendant-row-20261015.md new file mode 100644 index 00000000..c18a5f7e --- /dev/null +++ b/docs/tasks/TASK-chart-ascendant-row-20261015.md @@ -0,0 +1,102 @@ +# TASK · 星盘页上升行无星宿、表头错位、大运卡左边线(issue #53 第二轮) + +- 日期:2026-10-15 +- 来源:issue #53,产品负责人 10-15 在 staging 星盘页反馈:D1 本命与月亮盘的上升行「星宿 / 宿主 / pada / 状态」全是「—」;分盘表「庙旺」表头与内容不对齐;大运卡左侧那条线要去掉 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/chart-ascendant-row-20261015`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:开工时核对 `docs/BUG_HISTORY.md` 最大号(写作时为 BUG-1350);**BUG-1351 已被 issue #65 的分支占用,本单从 BUG-1352 起** + +## 1. 基线 + +`origin/staging` `10d0c4bba`。 + +## 2. 事故实证与根因 + +### A · 上升行星宿 / 宿主 / pada 为空(D1 与月亮盘同因)——BUG-1201 修复从未打通 + +- 引擎正确:`scripts/jyotish_engine.py::compute_chart_data`(约 12378 行)给上升加了 `**_nakshatra_fields(asc_deg)`;`scripts/domain_calculation_service.py::compute_chart`(约 242 行)原样传出。 +- **`/api/chart` 把它们丢了**:`scripts/jyotish_api_server.py::_compute_chart_sync` 约 6660–6666 行重建 `result['ascendant']`,只留 `sign / sign_idx / degree / degree_in_sign / lon`;`result['modules']['chart']['ascendant']` 用的是同一个字典。该段来自 06-24 的 `494e3870`,BUG-1201 的 `ae18952e` 没碰它。虚构盘(1990-06-15 12:00 北京)实测 `_compute_chart_sync` 返回的上升没有任何星宿键。 +- 前端映射没问题:`frontend/src/lib/chart-view-mapper.ts::ascendantRow`(约 390–409 行)读 `nakshatra / nakshatra_pada / nakshatra_lord`,缺失时按设计回落「—」。月亮盘复用同一个 `view.vedic.ascendant`(`chart-vedic-tab.tsx` 约 351–355 行)。 +- **不是缓存问题**:服务器缓存 900 s、前端 5 分钟,`cache_schema_version` 已是 6;BUG-1201 那次把 4→5 也没用,因为接口输出根本没变。 +- **为什么没拦住**:`tests/test_report_chart_blank_columns.py` 只调 `compute_chart_data`;前端 golden `frontend/tests/fixtures/chart-view-golden.json` 取自 `domain_calculation_service.compute_chart` 而不是接口真实响应,又手工补了 `_nakshatra_fields`——违反 AGENTS.md §7.4「合同 fixture 必须来自真实引擎响应」,所以丢字段的那一层从没被测到。 + +### B · 上升行「状态」写「—」 + +`chart-vedic-tab.tsx::AscendantRow` 约 99 行写死 `—`,注释与 `frontend/DESIGN.md` 约 1181 行写「上升没有尊贵,状态格写「—」」——这是当时的有意设计,不是回归。产品负责人这次把它算作「没数据」。 + +### C · 表头与内容不对齐 + +`frontend/src/app/globals.css` 约 5619–5623 行把 `.chart-page-planet-table` 的所有 `th / td` 设为左对齐;约 5658 行只把 `td.chart-page-planet-mid` 居中;对应的 `` 没有这个类,于是表头靠左、内容居中。所有 `.chart-page-planet-table` 都有:D1 / 月亮盘的「速度、宿主、pada」,分盘的「庙旺、同位」,行运的「速度」,Bhava 的「换宫」。 + +### D · 大运卡左边线 + +`globals.css` 约 5774–5778 行 `.chart-page-dasha-period.is-current { …; box-shadow: inset 2px 0 0 var(--color-focus); }`(`--color-focus` 是陶土色),挂在当前大运上(默认展开的那一张,`chart-dasha-tab.tsx` 约 115 行)。来自 `1e3570b4`;`frontend/DESIGN.md`「大运 Tab」一节没写,也没有测试引用。 + +## 3. 决策记录 + +- 产品负责人在 issue #53 授权修复上面四项。 +- **B(按推荐先定,产品不同意回一句即改)**:上升行「状态」改写「不适用」,与分盘表上升「庙旺」格(BUG-1099)、报告上升行(BUG-1314)同一口径。**这推翻 `frontend/DESIGN.md` 约 1181 行「状态格写「—」」**,DESIGN.md 同步改。 +- **D(按推荐先定)**:只去掉左边那条线,当前大运的浅底与细边框保留,用来标「现在走的是这一段」。 +- 不推翻 BUG-1201 的设计(上升行要有星宿 / 宿主 / pada),这次是把它真正打通;记为新 BUG「复发自 BUG-1201(修复未打通接口层)」,并在 BUG-1201 记录里补一行更正其验证结论。 + +## 4. 硬红线 + +1. `scripts/jyotish_api_server.py`:类方法数与 `JyotishAPIHandler.__new__` 伪造点不得增长(`tests/test_api_server_growth_contract.py`;注释里写这个词也算);只在 `_compute_chart_sync` 里补字段。 +2. 不改引擎数值;不改 `_nakshatra_fields`、`compute_chart_data`。 +3. 合同 fixture 必须来自**接口真实响应**(§7.4);不得手工补字段。 +4. 改既有断言写「原值 / 新值 / 原因」三栏;开工先 grep(§7.8,Python 测试也会读前端源码): + `grep -rn "chart-page-planet-mid\|AscendantRow\|上升没有尊贵\|is-current\|chart-view-golden\|ascendant\b" frontend/tests tests | grep -v node_modules` + 已知:`frontend/tests/chart-varga-table.test.tsx` 约 101–103 行断言上升行末尾 `—`;`frontend/tests/fixtures/chart-d1-table-baseline.html`;`chart-page-view.test.tsx`、`chart-page-hook.test.ts`。 +5. `frontend/scripts/**`、`frontend/src/**` 不得从 `frontend/tests/**` 导入或读文件(BUG-1140,近期已 5 次打回)。交回前必须本机跑 `npx tsx --test tests/runtime-sources-no-test-imports.test.ts`。 +6. 不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/**`;不动数据库;不升级依赖。 + +## 5. 任务分解 + +### T1 · 接口带出上升的星宿字段(新 BUG,复发自 BUG-1201) + +- `_compute_chart_sync` 重建 `result['ascendant']` 时,从 `ascendant_data` 带上 `nakshatra`、`nakshatra_pada`、`nakshatra_lord`(引擎已有就原样带;没有就调 `_nakshatra_fields(lon)`);`cache_schema_version` 6 → 7。 +- 验收: + - 新回归测试**经过 `/api/chart` 处理函数**(用现有测试里已有的调用方式,不得新增 `JyotishAPIHandler.__new__`):虚构盘的响应 `ascendant` 有三个字段,且等于 `_nakshatra_fields(lon)`;`modules.chart.ascendant` 同样有。 + - 用这次的**接口真实响应**重新生成 `chart-view-golden.json` 的上升部分,`fixtureProvenance` 写明命令;删掉手工补字段的那一步。 + - 前端:D1 与月亮盘上升行渲染出星宿、宿主、pada(测试断言具体值,不是只断言「不是 —」)。 + +### T2 · 上升行状态「不适用」 + +- `AscendantRow` 状态格改「不适用」;DESIGN.md 约 1181 行同步改;`chart-varga-table.test.tsx` 与 baseline html 按三栏改。 + +### T3 · 表头对齐 + +- 给居中列对应的 `` 加 `chart-page-planet-mid`(或用列选择器),让表头与内容同为居中;涉及 D1 / 月亮盘、分盘、行运、Bhava 四类表。8 列表的 markup 若被 DESIGN.md 约 1195 行写成「冻结」,改用 CSS 选择器、不改 markup,并在 DESIGN.md 记一句。 +- 验收:渲染测试断言每张表里居中列的 `th` 与 `td` 同为居中(类名或计算后的选择器);DESIGN.md 更新。 + +### T4 · 去掉大运卡左边线 + +- 删 `.chart-page-dasha-period.is-current` 的 `box-shadow` 那一行,浅底与边框保留;DESIGN.md「大运 Tab」一节加一句「当前大运用浅底和细边框标出,不画左侧强调线」。 +- 验收:一条 CSS 合同测试断言 `.chart-page-dasha-period.is-current` 不含 `box-shadow`。 + +### T5 · 记录 + +- `docs/BUG_HISTORY.md`:新 BUG(上升星宿字段被 `/api/chart` 丢弃,复发自 BUG-1201,写清为何 golden 没拦住);BUG-1201 记录补一行更正;表头错位、左边线各一条或合为一条 UI 记录。 +- `docs/tasks/PROGRESS-chart-ascendant-row-20261015.md`、`CHANGELOG.md`、`frontend/DESIGN.md`。 +- 真机清单 `docs/testing/chart-ascendant-row-20261015.md`。 + +## 6. 让步顺序 + +T1 → T3 → T2 → T4。T1 不可让。 + +## 7. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `python -m pytest tests/test_report_chart_blank_columns.py tests/test_api_server_growth_contract.py <新测试> -q`;前端 `tsc --noEmit`、`npm run lint`、改到 / 新增的前端测试单文件,**加** `tests/runtime-sources-no-test-imports.test.ts`(fail 与 cancelled 为 0)。前端全量与 Python 快速门不跑 | +| Claude 验收 | 上述 + Python 全量与基线逐条比对 + 快速门 + 前端全量 `npm test` + `next build`(`/` 仍 Static,首屏 gzip ±2%) | +| 不需要 | `test:db` | + +## 8. 开工前置 + +```bash +git fetch origin --prune +git worktree add -b codex/chart-ascendant-row-20261015 .worktrees/chart-ascendant-row-20261015 origin/staging +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # BUG-1351 已被 #65 占用 +grep -rn "chart-page-planet-mid\|AscendantRow\|上升没有尊贵\|is-current\|chart-view-golden" frontend/tests tests +``` diff --git a/docs/testing/answer-effort-ab-20261013.md b/docs/testing/answer-effort-ab-20261013.md index 3b57a2a2..759a5f3c 100644 --- a/docs/testing/answer-effort-ab-20261013.md +++ b/docs/testing/answer-effort-ab-20261013.md @@ -13,7 +13,7 @@ | 新开关 `low` / `medium` / `high` / `max`(只在答题步) | `{ type: enabled }` | 与开关相同 | 24576 | | 续写 | `{ type: disabled }` | 无 | 16384 | -旧的低强度写在 `thinking` 里面,请求体没有 `reasoning_effort`。这证实了任务书 §2.3。记为 BUG-1351,状态 investigating。线上不用这处设置。 +旧的低强度写在 `thinking` 里面,请求体没有 `reasoning_effort`。这证实了任务书 §2.3。记为 BUG-1354,状态 investigating。原先写成 BUG-1351,该号已被数据层占用。线上不用这处设置。 新开关写在供应商选项的最外层。兼容通道把它写成 `reasoning_effort`。开关不设时,两步请求体与改前逐字节一致,样本在 `frontend/tests/fixtures/answer-effort-switch-20261013.json`。 diff --git a/docs/testing/chart-ascendant-row-20261015.md b/docs/testing/chart-ascendant-row-20261015.md new file mode 100644 index 00000000..5a8d9746 --- /dev/null +++ b/docs/testing/chart-ascendant-row-20261015.md @@ -0,0 +1,9 @@ +# 真机清单 · 星盘页上升行、表头、大运卡(issue #53 第二轮) + +部署到 staging 后,打开本人星盘页(手机和电脑各一次): + +1. D1 本命表第一行「上升」:星宿、宿主、pada 都有值;状态写「不适用」。 +2. 切到月亮盘:上升那一行同样有星宿、宿主、pada,状态「不适用」。 +3. 切到任一分盘(如 D9):「庙旺」「同位」表头和下面的内容都居中对齐。 +4. D1 表的「速度、宿主、pada」,行运表的「速度」,Bhava 表的「换宫」:表头和内容对齐。 +5. 大运 Tab:当前那段大运没有左侧那条彩色竖线,仍有浅色底能看出是「现在这一段」。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ba81ed57..74d2c6f0 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1178,7 +1178,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 | pada | 1–4,居中 | | 状态 | 尊贵 / 庙旺等,原样保留,只是挪到最后一列 | -第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「—」。数字列 `tabular-nums`,符号列与宿主列居中。 +第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「不适用」(2026-10-15,与分盘表、报告同一口径;不再写「—」)。数字列 `tabular-nums`,符号列与宿主列居中。 本轮不新增第二层页签:参照里「宫位 / 大运 / 星力 / 瑜伽」等页签的内容后续另立单。 @@ -1192,7 +1192,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 | Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) | | 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 | -说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。宫位表头与对应数据格统一左对齐、相同 padding;包括本命/月亮、分盘、Bhava、行运。宿主、pada、速度、换宫等原本居中列不随之变化。本命/月亮八列 markup 冻结,通过限定八列表头的第四列 CSS 修正宫位,不能全局覆盖 `chart-page-planet-mid`。 +说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。宫位表头与对应数据格统一左对齐、相同 padding;包括本命/月亮、分盘、Bhava、行运。宿主、pada、速度、换宫等原本居中列不随之变化:表头与内容同为居中。本命/月亮八列 markup 冻结,速度、宿主、pada 的表头用列选择器居中,不改 markup,也不把宫位那一列居中。分盘的庙旺 / 同位、行运的速度、Bhava 的换宫,表头带 `chart-page-planet-mid`。不能全局覆盖 `chart-page-planet-mid` 去改宫位列。 ### 三套坐标系 @@ -1214,7 +1214,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 时间条只画时长大于 0 的段,宽度按时长。当前段用主文字色内描边,一条竖线标 `asOf`。两端是 `YYYY.MM`。0 年段不进这条轴。段色轮换现有的浅底 token(`--color-action-soft`、`--color-success-muted`、`--color-canvas-strong`、`--color-danger-muted`、`--color-selected`、`--color-canvas-muted`),不新造一套色。 -列表用原生 `
` / ``。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `
`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。 +列表用原生 `
` / ``。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `
`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。当前大运用浅底和细边框标出,不画左侧强调线。 ### 西洋盘 Tab(2026-09-25) diff --git a/frontend/src/app/api/chart-profiles/route.ts b/frontend/src/app/api/chart-profiles/route.ts index 24eefec2..5df64576 100644 --- a/frontend/src/app/api/chart-profiles/route.ts +++ b/frontend/src/app/api/chart-profiles/route.ts @@ -56,7 +56,7 @@ export async function GET() { .order("updated_at", { ascending: false }); if (error) throw error; - const profiles = (data ?? []).map((row) => toClientProfile(row as Record)); + const profiles = (data ?? []).map((row: unknown) => toClientProfile(row as Record)); return NextResponse.json({ profiles, limit: MAX_SAVED_SUBJECTS, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 17afe73f..36fcfcb4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -5655,11 +5655,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .chart-page-planet-glyph.is-node, .ephemeris-transit-glyph.is-node { color: var(--color-planet-node); } .chart-page-planet-glyph.is-ascendant { color: var(--color-planet-ascendant); font-weight: 700; } +.chart-page-planet-table th.chart-page-planet-mid, .chart-page-planet-table td.chart-page-planet-mid { text-align: center; } -/* The natal/Moon eight-column markup stays frozen; only its house column changes. */ +/* The natal/Moon eight-column markup stays frozen; only its house column changes. + Speed, nakshatra lord and pada stay centered, including their headers. */ .chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4) { text-align: left; } +.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(3), +.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(6), +.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(7) { + text-align: center; +} /* Figures line up column-wise instead of drifting with glyph width. */ .chart-page-planet-num { font-variant-numeric: tabular-nums; } .chart-page-boundary { @@ -5774,7 +5781,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .chart-page-dasha-period.is-current { background: var(--color-selected); border-color: var(--color-border-strong); - box-shadow: inset 2px 0 0 var(--color-focus); } .chart-page-dasha-period.is-zero { color: var(--color-ink-tertiary); } .chart-page-dasha-period.is-zero span { margin-left: auto; } diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 3a768db5..596bbdb9 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -79,7 +79,7 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) { /** * The ascendant is a chart point: nakshatra, lord and pada come from the - * engine (BUG-1201); it has no dignity, so the status cell stays empty. + * engine (BUG-1201); it has no dignity, so the status cell says 不适用. * `house` is 1 except on the Moon chart, which counts from the Moon. */ function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; house?: number }) { @@ -96,7 +96,7 @@ function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; {row.nakshatraLord ? : "—"} {row.pada ?? "—"} - — + 不适用 ); } @@ -192,8 +192,8 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) { 分盘星座 宫位 本命星座 - {columns.supportsDignity && 庙旺} - {columns.supportsVargottama && 同位} + {columns.supportsDignity && 庙旺} + {columns.supportsVargottama && 同位} @@ -219,7 +219,7 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) { 行星 今天的位置 - 速度 + 速度 落本命 本命同星 @@ -257,7 +257,7 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) { 黄道经度 星座宫 Bhava 宫 - 换宫 + 换宫 diff --git a/frontend/src/lib/birth-time-journey-case-loader.ts b/frontend/src/lib/birth-time-journey-case-loader.ts index b5b32463..9621ac50 100644 --- a/frontend/src/lib/birth-time-journey-case-loader.ts +++ b/frontend/src/lib/birth-time-journey-case-loader.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { z } from "zod"; import { parseRectificationQuestionnaire, @@ -34,7 +34,7 @@ export type JourneyLoadClient = { from(table: string): { select(columns: string): JourneyLoadQuery }; }; -export function createJourneyLoadClient(supabase: SupabaseClient): JourneyLoadClient { +export function createJourneyLoadClient(supabase: DataClient): JourneyLoadClient { return { from(table) { return { diff --git a/frontend/src/lib/birth-time-journey-store.ts b/frontend/src/lib/birth-time-journey-store.ts index 1f5571c8..44370ec1 100644 --- a/frontend/src/lib/birth-time-journey-store.ts +++ b/frontend/src/lib/birth-time-journey-store.ts @@ -1,6 +1,6 @@ import "server-only"; -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import type { BirthTimeJourneyStore, } from "./birth-time-journey-service.ts"; @@ -24,7 +24,7 @@ import { createDynamicScoringJobStore } from "./birth-time-dynamic-scoring-job-s export { BirthTimeJourneyStoreError } from "./birth-time-journey-turn-persistence.ts"; export function createSupabaseBirthTimeJourneyStore( - supabase: SupabaseClient, + supabase: DataClient, now: () => Date = () => new Date(), ): BirthTimeJourneyStore { const loadClient = createJourneyLoadClient(supabase); diff --git a/frontend/src/lib/birth-time-journey-turn-persistence.ts b/frontend/src/lib/birth-time-journey-turn-persistence.ts index 3ca50096..588a105a 100644 --- a/frontend/src/lib/birth-time-journey-turn-persistence.ts +++ b/frontend/src/lib/birth-time-journey-turn-persistence.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { z } from "zod"; import type { JourneySnapshot } from "./birth-time-journey.ts"; import type { LegacyStoredRectificationCase, StoredRectificationCase } from "./birth-time-journey-service.ts"; @@ -56,7 +56,7 @@ function canonicalAskedDomains(input: readonly EvidenceDomain[]): readonly Evide } export function createJourneyTurnPersistence( - supabase: SupabaseClient, + supabase: DataClient, loadCase: (userId: string, caseId: string) => Promise, ) { return { diff --git a/frontend/src/lib/birth-time-scoring-job-store.ts b/frontend/src/lib/birth-time-scoring-job-store.ts index 8fb36d79..3d13559c 100644 --- a/frontend/src/lib/birth-time-scoring-job-store.ts +++ b/frontend/src/lib/birth-time-scoring-job-store.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { z } from "zod"; import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts"; import type { @@ -58,7 +58,7 @@ async function requireReloaded( } export function createSupabaseScoringJobStore( - supabase: SupabaseClient, + supabase: DataClient, loadCase: (userId: string, caseId: string) => Promise, ): ScoringStoreMethods { return { @@ -91,18 +91,17 @@ export function createSupabaseScoringJobStore( }, async claimScoringJob(identity) { - const { data, error } = await supabase - .rpc("claim_birth_time_scoring_job", { - p_user_id: identity.userId, - p_case_id: identity.caseId, - p_job_id: identity.jobId, - p_evidence_fingerprint: identity.evidenceFingerprint, - p_algorithm_version: identity.algorithmVersion, - p_now: identity.now, - }) - .single(); + const { data, error } = await supabase.rpc("claim_birth_time_scoring_job", { + p_user_id: identity.userId, + p_case_id: identity.caseId, + p_job_id: identity.jobId, + p_evidence_fingerprint: identity.evidenceFingerprint, + p_algorithm_version: identity.algorithmVersion, + p_now: identity.now, + }); if (error) throw mapScoringRpcError(error.message, error.code); - const parsed = claimSchema.safeParse(data); + const row = Array.isArray(data) ? data[0] ?? null : data; + const parsed = claimSchema.safeParse(row); if (!parsed.success) throw new BirthTimeJourneyStoreError("load_case"); return { kind: parsed.data.claim_state, diff --git a/frontend/src/lib/db/data-client.ts b/frontend/src/lib/db/data-client.ts new file mode 100644 index 00000000..6b65cc63 --- /dev/null +++ b/frontend/src/lib/db/data-client.ts @@ -0,0 +1,46 @@ +export type DataQueryError = Readonly<{ message: string; code?: string }>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- phase 1: equals SupabaseClient; phase 2 narrows per table +type UntypedData = any; + +export type DataQueryResult = Readonly<{ + data: UntypedData; + error: DataQueryError | null; + count?: number | null; +}>; + +export interface DataQueryBuilder extends PromiseLike { + select(columns?: string): DataQueryBuilder; + insert(value: Record | readonly Record[]): DataQueryBuilder; + upsert( + value: Record | readonly Record[], + options?: { onConflict?: string }, + ): DataQueryBuilder; + update(values: Record): DataQueryBuilder; + delete(options?: { count?: string }): DataQueryBuilder; + eq(column: string, value: unknown): DataQueryBuilder; + neq(column: string, value: unknown): DataQueryBuilder; + gt(column: string, value: unknown): DataQueryBuilder; + lte(column: string, value: unknown): DataQueryBuilder; + like(column: string, value: unknown): DataQueryBuilder; + in(column: string, value: readonly unknown[]): DataQueryBuilder; + is(column: string, value: unknown): DataQueryBuilder; + not(column: string, operator: string, value: unknown): DataQueryBuilder; + or(expression: string): DataQueryBuilder; + order(column: string, options?: { ascending?: boolean }): DataQueryBuilder; + limit(value: number): DataQueryBuilder; + abortSignal(signal: AbortSignal): DataQueryBuilder; + single(): Promise; + maybeSingle(): Promise; +} + +export interface DataClient { + readonly auth: Readonly<{ + getUser: () => Promise<{ + data: { user: { id: string; email?: string | null } | null }; + error: unknown; + }>; + }>; + from(table: string): DataQueryBuilder; + rpc(fn: string, args?: Readonly>): Promise; +} diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index a0c2b5a9..4439cb81 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient } from "pg"; +import type { DataClient, DataQueryBuilder } from "./data-client"; type LocalIdentity = Readonly<{ id: string; email: string | null }> | null; export type LocalDatabaseRole = "authenticated" | "service_role"; @@ -457,7 +458,7 @@ export function queryValue(type: string | undefined, value: unknown): unknown { return value; } -class LocalPostgresQueryBuilder implements PromiseLike { +class LocalPostgresQueryBuilder implements DataQueryBuilder { private selectedColumns: string[] | null = null; private mutation: Mutation | null = null; private readonly filters: Filter[] = []; @@ -863,7 +864,7 @@ function castType(value: string): string { return value; } -export class LocalPostgresDataClient { +export class LocalPostgresDataClient implements DataClient { readonly auth: Readonly<{ getUser: () => Promise< Readonly<{ diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts index db9daa26..37559fba 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -8,7 +8,7 @@ */ import { createHash } from "node:crypto"; import { requestCandidateIntervals, type DatedCandidateRange } from "../core/candidate-window.ts"; -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts"; import { declaredClockRange } from "../../declared-birth-window.ts"; import { @@ -43,7 +43,7 @@ import { type RectificationEntrySummary, } from "./open-request.ts"; -type AccountingClient = SupabaseClient; +type AccountingClient = DataClient; export type V9BaselineSnapshot = Readonly<{ birth_date: string; diff --git a/frontend/src/lib/rectification-agentic/v9/product-domain.ts b/frontend/src/lib/rectification-agentic/v9/product-domain.ts index 60e10495..d151cd4f 100644 --- a/frontend/src/lib/rectification-agentic/v9/product-domain.ts +++ b/frontend/src/lib/rectification-agentic/v9/product-domain.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { RectificationCaseServiceError } from "./case-service.ts"; import type { OpenRectificationCaseRequest } from "./open-request.ts"; @@ -17,7 +17,7 @@ const domainsBySessionTheme: Readonly> = { /** Only an owned persisted consultation is a product-domain source. */ export async function resolveRectificationProductDomain( - client: SupabaseClient, + client: DataClient, userId: string, request: OpenRectificationCaseRequest, ): Promise { diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 0e62ff60..92a4e5ff 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -44,7 +44,7 @@ import { decisionStateFingerprint } from "../core/decision-fingerprint"; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** - * Minimal structural RPC client. SupabaseClient satisfies this; tests can + * Minimal structural RPC client. DataClient satisfies this; tests can * pass a fake with a single rpc() method. */ export type RectificationRpcClient = { diff --git a/frontend/src/lib/rectification-in-chat-server.ts b/frontend/src/lib/rectification-in-chat-server.ts index 4ec8d784..2414a299 100644 --- a/frontend/src/lib/rectification-in-chat-server.ts +++ b/frontend/src/lib/rectification-in-chat-server.ts @@ -1,6 +1,6 @@ import "server-only"; -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { resolveFeaturePricing } from "@/lib/feature-pricing"; import { isProductEnabled } from "@/lib/product-access"; import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service"; @@ -27,7 +27,7 @@ import { /** C3: whether the offer card may be shown, decided by the server and never by the model. */ export async function decideRectificationOffer(input: { - accounting: SupabaseClient; + accounting: DataClient; userId: string; subjectRole: "self" | "other"; modelId: string; @@ -42,7 +42,7 @@ export async function decideRectificationOffer(input: { type SessionRow = { id: string; messages: unknown; session_type?: string | null }; -async function readConsultationSession(client: SupabaseClient, userId: string, sessionId: string): Promise { +async function readConsultationSession(client: DataClient, userId: string, sessionId: string): Promise { const { data, error } = await client .from("chat_sessions") .select("id,messages,session_type") @@ -54,7 +54,7 @@ async function readConsultationSession(client: SupabaseClient, userId: string, s return (row.session_type ?? "consultation") === "consultation" ? row : null; } -async function writeMessages(client: SupabaseClient, userId: string, sessionId: string, messages: unknown[]): Promise { +async function writeMessages(client: DataClient, userId: string, sessionId: string, messages: unknown[]): Promise { const { error } = await client .from("chat_sessions") .update({ messages }) @@ -71,7 +71,7 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId: * Does not touch consultation_requests. */ export async function persistOpenedDirectOffer(input: { - client: SupabaseClient; + client: DataClient; userId: string; sessionId: string; sentence: string; @@ -96,7 +96,7 @@ export async function persistOpenedDirectOffer(input: { /** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */ export async function bindRectificationSource(input: { - client: SupabaseClient; + client: DataClient; userId: string; sourceSessionId: string; caseId: string; @@ -118,7 +118,7 @@ const SOURCE_SEARCH_LIMIT = 20; * from history has none. */ export async function findRectificationSource( - client: SupabaseClient, + client: DataClient, userId: string, caseId: string, ): Promise { @@ -139,8 +139,8 @@ export async function findRectificationSource( * never from the browser. */ export async function returnToRectificationSource(input: { - client: SupabaseClient; - accounting: SupabaseClient; + client: DataClient; + accounting: DataClient; userId: string; caseId: string; now?: () => Date; diff --git a/frontend/src/lib/supabase/admin-client-core.ts b/frontend/src/lib/supabase/admin-client-core.ts index 2a462b90..d30291ba 100644 --- a/frontend/src/lib/supabase/admin-client-core.ts +++ b/frontend/src/lib/supabase/admin-client-core.ts @@ -1,11 +1,12 @@ -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { createClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client-core"; import { getSupabaseUrl, SupabaseConfigurationError, } from "./config"; -export function createAdminSupabaseClient() { +export function createAdminSupabaseClient(): DataClient { if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") { const connectionString = process.env.SERVICE_DATABASE_URL?.trim(); if (!connectionString) { @@ -18,7 +19,7 @@ export function createAdminSupabaseClient() { connectionString, null, "service_role", - ) as unknown as SupabaseClient; + ) as DataClient; } const url = getSupabaseUrl(); const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; @@ -26,9 +27,10 @@ export function createAdminSupabaseClient() { throw new SupabaseConfigurationError(["SUPABASE_SERVICE_ROLE_KEY"]); } + // 回滚分支,运行时形状是超集 return createClient(url, serviceRoleKey, { auth: { autoRefreshToken: false, persistSession: false }, - }); + }) as unknown as DataClient; } export function isAdminEmail(email: string | null | undefined) { diff --git a/frontend/src/lib/supabase/server.ts b/frontend/src/lib/supabase/server.ts index 351603bd..c9c3fc38 100644 --- a/frontend/src/lib/supabase/server.ts +++ b/frontend/src/lib/supabase/server.ts @@ -1,8 +1,8 @@ import "server-only"; import { createServerClient } from "@supabase/ssr"; -import type { SupabaseClient } from "@supabase/supabase-js"; import { cookies, headers } from "next/headers"; +import type { DataClient } from "@/lib/db/data-client"; import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client"; import { readDatabaseUrl } from "@/lib/db/config"; import { getIdentityAuthServices } from "@/modules/identity/auth"; @@ -11,7 +11,7 @@ import { getSupabasePublicConfig } from "./config"; export async function createServerSupabaseClient(options?: { readonly disableCookieCache?: boolean; -}) { +}): Promise { if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") { const requestHeaders = new Headers(await headers()); const session = await readIdentitySession( @@ -22,11 +22,12 @@ export async function createServerSupabaseClient(options?: { return createLocalPostgresDataClient( readDatabaseUrl(process.env, "APP_DATABASE_URL"), session ? { id: session.user.id, email: session.user.email } : null, - ) as unknown as SupabaseClient; + ) as DataClient; } const { url, anonKey } = getSupabasePublicConfig(); const cookieStore = await cookies(); + // 回滚分支,运行时形状是超集 return createServerClient(url, anonKey, { cookies: { getAll: () => cookieStore.getAll(), @@ -40,5 +41,5 @@ export async function createServerSupabaseClient(options?: { } }, }, - }); + }) as unknown as DataClient; } diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 41a66610..315c4aa9 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -12,7 +12,7 @@ import { windowIntervalsWidth } from "../lib/rectification-agentic/core/candidate-window.ts"; import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DataClient } from "@/lib/db/data-client"; import type { PublicRectificationMethod } from "@/lib/rectification-agentic/v9/public-receipt"; import { canonicalToolInputFingerprint, @@ -196,7 +196,7 @@ export type RectificationV9Context = Readonly<{ turnId: string; attemptId?: string; userMessage?: string | null; - accounting: SupabaseClient; + accounting: DataClient; engineBase?: string; decision?: RectificationDecision; productDelivery?: import("../lib/rectification-agentic/v9/product-delivery.ts").ProductDelivery | null; diff --git a/frontend/tests/chart-bhava.test.tsx b/frontend/tests/chart-bhava.test.tsx index c90b870d..13cf1b05 100644 --- a/frontend/tests/chart-bhava.test.tsx +++ b/frontend/tests/chart-bhava.test.tsx @@ -90,6 +90,7 @@ test("Sripati rows, cusps and the drawn houses follow the engine's Bhava houses" assert.match(markup, /
交点<\/dt>/); assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎Bhava · Sripati<\/caption>/); + assert.match(markup, /换宫<\/th>/); assert.match(markup, /宫头与宫界<\/caption>/); assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length); assert.equal((markup.match(/是<\/td>/g) ?? []).length, shifted.length); diff --git a/frontend/tests/chart-moon-transit.test.tsx b/frontend/tests/chart-moon-transit.test.tsx index 79237cb0..283b9014 100644 --- a/frontend/tests/chart-moon-transit.test.tsx +++ b/frontend/tests/chart-moon-transit.test.tsx @@ -87,6 +87,12 @@ test("the Moon chart is the D1 counted from the Moon's sign, projected without a const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "MOON", onSelectVarga: () => {} })); assert.match(markup, /月亮盘 · 以月亮所在星座([^)]+)为第 1 宫<\/caption>/); assert.deepEqual(ascendantMarkHouses(markup), [moon.ascendantHouse], "the lagna mark sits in the natal ascendant's house"); + const table = markup.slice(markup.indexOf("月亮盘")); + const ascendantRow = table.match(/[\s\S]*?上升[\s\S]*?<\/tr>/)?.[0] ?? ""; + assert.match(ascendantRow, /Uttara Phalguni<\/td>/); + assert.match(ascendantRow, /is-sun/); + assert.match(ascendantRow, />1不适用 { @@ -125,7 +131,8 @@ test("the transit layer posts today's noon with the natal ayanamsa and node mode const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {} })); assert.match(markup, /行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/); - for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`${column}`)); + for (const column of ["行星", "今天的位置", "落本命", "本命同星"]) assert.match(markup, new RegExp(`${column}`)); + assert.match(markup, /速度<\/th>/); assert.ok(markup.includes(CHART_VIEW_COPY.transitNote)); assert.match(markup, /
/); assert.match(markup, /
岁差<\/dt>/); diff --git a/frontend/tests/chart-page-mobile-width-20260929.test.ts b/frontend/tests/chart-page-mobile-width-20260929.test.ts index 24b4742b..3ea5335d 100644 --- a/frontend/tests/chart-page-mobile-width-20260929.test.ts +++ b/frontend/tests/chart-page-mobile-width-20260929.test.ts @@ -48,7 +48,21 @@ test("the Vedic waiting slot reserves the result legend band without changing th test("only the house column in frozen natal and Moon tables overrides centering", () => { assert.match(cssDeclarations(".chart-page-planet-table td.chart-page-planet-mid", globalStyles), /text-align:\s*center/); + assert.match(cssDeclarations(".chart-page-planet-table th.chart-page-planet-mid", globalStyles), /text-align:\s*center/); assert.match(cssDeclarations(".chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4)", globalStyles), /text-align:\s*left/); + for (const child of [3, 6, 7]) { + assert.match( + cssDeclarations(`.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(${child})`, globalStyles), + /text-align:\s*center/, + ); + } +}); + +test("the current dasha card keeps its tint and drops the left accent line", () => { + const current = cssDeclarations(".chart-page-dasha-period.is-current", globalStyles); + assert.match(current, /background:\s*var\(--color-selected\)/); + assert.match(current, /border-color:\s*var\(--color-border-strong\)/); + assert.doesNotMatch(current, /box-shadow/); }); test("wide chart-page content still scrolls inside its own box instead of widening the page", () => { diff --git a/frontend/tests/chart-varga-table.test.tsx b/frontend/tests/chart-varga-table.test.tsx index 12451038..cefabcf5 100644 --- a/frontend/tests/chart-varga-table.test.tsx +++ b/frontend/tests/chart-varga-table.test.tsx @@ -99,8 +99,11 @@ test("the D1 table is byte-for-byte the table the page drew before BUG-1076, asc // (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes. const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8"); const dashes = '————'; + // 原值:上升行状态格 — + // 新值:不适用 + // 原因:产品把「—」看成没数据。上升没有尊贵,与分盘表、报告同一口径写「不适用」。基线文件仍是改前的表。 const filled = 'Uttara Phalguni☉' - + '1—'; + + '1不适用'; assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row"); assert.equal(table, baseline.replace(dashes, filled)); }); @@ -113,6 +116,8 @@ test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async ( const ascendant = row(render(view, "D1"), "上升"); assert.match(ascendant, /Uttara Phalguni<\/td>/); assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun"); + assert.match(ascendant, />1不适用)[key]; @@ -136,6 +141,8 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co const markup = render(view, "D9"); const header = markup.match(/([\s\S]*?)<\/thead>/)?.[1] ?? ""; for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`)); + assert.match(header, /庙旺<\/th>/); + assert.match(header, /同位<\/th>/); assert.doesNotMatch(header, /星宿|宿主|pada/); // Same wording as the D1 status column (BUG-1202), friendship levels included. // 原值:同位行星单元格是「同位」,非同位是「—」 diff --git a/frontend/tests/database-scoring-job-claim-shape.test.ts b/frontend/tests/database-scoring-job-claim-shape.test.ts new file mode 100644 index 00000000..367a7bf3 --- /dev/null +++ b/frontend/tests/database-scoring-job-claim-shape.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + closeLocalPostgresDataPools, + createLocalPostgresDataClient, +} from "../src/lib/db/local-postgres-client-core.ts"; +import type { DataClient } from "../src/lib/db/data-client.ts"; +import { birthTimeScoringAlgorithmVersion } from "../src/lib/birth-time-scoring-job.ts"; +import { createSupabaseScoringJobStore } from "../src/lib/birth-time-scoring-job-store.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); + +function dockerAvailable(): boolean { + return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { + encoding: "utf8", + stdio: "ignore", + }).status === 0; +} + +const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host"; + +const ids = { + user: "e1111111-1111-4111-8111-111111111111", + caseId: "e2222222-2222-4222-8222-222222222222", + job: "e3333333-3333-4333-8333-333333333333", + shapeJob: "e4444444-4444-4444-8444-444444444444", +}; + +const fingerprint = "claim-shape-fingerprint"; + +function sqlLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +const identity = { + userId: ids.user, + caseId: ids.caseId, + jobId: ids.job, + evidenceFingerprint: fingerprint, + algorithmVersion: birthTimeScoringAlgorithmVersion, + now: "2026-10-15T00:00:00.000Z", +}; + +test("claimScoringJob reads the first row when rpc returns an array", async () => { + const rpc = async () => ({ + data: [{ claim_state: "claimed", algorithm_version: birthTimeScoringAlgorithmVersion }], + error: null, + }); + const store = createSupabaseScoringJobStore({ rpc } as unknown as DataClient, async () => null); + const claim = await store.claimScoringJob(identity); + assert.deepEqual(claim, { + kind: "claimed", + algorithmVersion: birthTimeScoringAlgorithmVersion, + }); +}); + +test("claimScoringJob also accepts the single-object rpc shape", async () => { + const rpc = async () => ({ + data: { claim_state: "processing", algorithm_version: birthTimeScoringAlgorithmVersion }, + error: null, + }); + const store = createSupabaseScoringJobStore({ rpc } as unknown as DataClient, async () => null); + const claim = await store.claimScoringJob(identity); + assert.deepEqual(claim, { + kind: "processing", + algorithmVersion: birthTimeScoringAlgorithmVersion, + }); +}); + +test("claim_birth_time_scoring_job returns an array and claimScoringJob accepts it", { skip: skipWithoutDocker }, async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + assert.equal( + migration.status, + 0, + JSON.stringify({ + status: migration.status, + stderr: migration.stderr, + stdoutTail: String(migration.stdout).slice(-1500), + }), + ); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `insert into identity.users (id, name, email, email_verified, email_verified_at) + values (${sqlLiteral(ids.user)}, 'Claim Shape', 'claim-shape@example.com', true, now());`, + ); + fixture.psql(` + insert into public.birth_time_rectification_cases ( + id, user_id, status, reported_date, source + ) values ( + ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, 'rectifying', '1990-01-01', 'family_exact' + ); + insert into public.birth_time_rectification_scoring_jobs ( + id, case_id, user_id, evidence_fingerprint, algorithm_version, status, expires_at + ) values ( + ${sqlLiteral(ids.job)}, ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, + ${sqlLiteral(fingerprint)}, ${sqlLiteral(birthTimeScoringAlgorithmVersion)}, + 'pending', '2026-10-15T01:00:00Z' + ), ( + ${sqlLiteral(ids.shapeJob)}, ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, + ${sqlLiteral(`${fingerprint}-shape`)}, ${sqlLiteral(birthTimeScoringAlgorithmVersion)}, + 'pending', '2026-10-15T01:00:00Z' + ); + update public.birth_time_rectification_cases + set journey_protocol = 'legacy-guided-v1', + turn_state = ${sqlLiteral(JSON.stringify({ + nextAction: { kind: "score_pending", jobId: ids.shapeJob }, + }))}::jsonb + where id = ${sqlLiteral(ids.caseId)}; + `); + + const client = createLocalPostgresDataClient( + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), + null, + "service_role", + ); + const direct = await client.rpc("claim_birth_time_scoring_job", { + p_user_id: ids.user, + p_case_id: ids.caseId, + p_job_id: ids.shapeJob, + p_evidence_fingerprint: `${fingerprint}-shape`, + p_algorithm_version: birthTimeScoringAlgorithmVersion, + p_now: identity.now, + }); + assert.equal(direct.error, null, direct.error?.message); + assert.equal(Array.isArray(direct.data), true); + assert.equal(direct.data[0]?.claim_state, "claimed"); + + fixture.psql(` + update public.birth_time_rectification_cases + set turn_state = ${sqlLiteral(JSON.stringify({ + nextAction: { kind: "score_pending", jobId: ids.job }, + }))}::jsonb + where id = ${sqlLiteral(ids.caseId)}; + `); + const store = createSupabaseScoringJobStore(client, async () => null); + const claim = await store.claimScoringJob(identity); + assert.deepEqual(claim, { + kind: "claimed", + algorithmVersion: birthTimeScoringAlgorithmVersion, + }); + } finally { + await closeLocalPostgresDataPools(); + fixture.stop(); + } +}); diff --git a/frontend/tests/fixtures/chart-view-golden.json b/frontend/tests/fixtures/chart-view-golden.json index 8d47e425..a1dfc734 100644 --- a/frontend/tests/fixtures/chart-view-golden.json +++ b/frontend/tests/fixtures/chart-view-golden.json @@ -1,5 +1,5 @@ { - "sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1201/1202): chart.ascendant nakshatra fields from jyotish_engine._nakshatra_fields(ascendant.lon) and varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions (the same calls /api/chart and /api/varga_full now make).", + "sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1202): varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions. 2026-10-15 (BUG-1352): chart.ascendant nakshatra, nakshatra_pada and nakshatra_lord are the fields returned by JyotishAPIHandler._compute_chart_sync for this birth (tests/test_api_chart_ascendant_nakshatra.py); they are not filled in by hand.", "chart": { "success": true, "birth": { diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index ca07c6cd..432467e5 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -2589,12 +2589,24 @@ def _vedastro_runtime_fingerprint() -> dict: } +def _chart_ascendant_nakshatra(ascendant_data: dict, lon: float) -> dict: + """Keep the engine's nakshatra fields when /api/chart rebuilds the ascendant.""" + fields = { + key: ascendant_data[key] + for key in ('nakshatra', 'nakshatra_pada', 'nakshatra_lord') + if ascendant_data.get(key) not in (None, '') + } + if 'nakshatra' in fields: + return fields + return _load_local_module('jyotish_engine')._nakshatra_fields(lon) + + def _build_api_chart_cache_payload(body: dict) -> dict: import api_scratch_retention as retention today = body.get('today') or body.get('current_date') return { - 'cache_schema_version': 6, + 'cache_schema_version': 7, 'birth': retention.normalize_chart_cache_birth(body), 'calculation': { 'ayanamsa': _request_ayanamsa(body), @@ -6663,6 +6675,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM 'degree': round(asc_lon % 30, 2), 'degree_in_sign': round(asc_lon % 30, 2), 'lon': round(asc_lon, 4), + **_chart_ascendant_nakshatra(ascendant_data, asc_lon), }, 'planets': planets_data, 'houses': houses, 'shadbala': shadbala_summary, 'dasha': { diff --git a/tests/test_api_chart_ascendant_nakshatra.py b/tests/test_api_chart_ascendant_nakshatra.py new file mode 100644 index 00000000..8232dc87 --- /dev/null +++ b/tests/test_api_chart_ascendant_nakshatra.py @@ -0,0 +1,56 @@ +"""The chart API must keep the ascendant nakshatra the engine already computed.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +TESTS = ROOT / "tests" +for path in (SCRIPTS, TESTS): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import jyotish_api_server # noqa: E402 +from test_api_server_security import _handler # noqa: E402 + +BEIJING = { + "year": 1990, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 39.9042, + "lon": 116.4074, + "tz": 8, + "ayanamsa": "raman", + "node_mode": "mean", + "transit_date": "2026-10-15", + "skip_vedastro_main_entry_overview": True, +} + + +def test_api_chart_keeps_ascendant_nakshatra_from_the_engine(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0") + monkeypatch.setattr( + jyotish_api_server, + "_attach_vedastro_main_entry_overview", + lambda result, _birth: result, + ) + engine = jyotish_api_server._load_local_module("jyotish_engine") + result = _handler()._compute_chart_sync(BEIJING) + lon = result["ascendant"]["lon"] + expected = engine._nakshatra_fields(lon) + + for packet in (result["ascendant"], result["modules"]["chart"]["ascendant"]): + assert packet["nakshatra"] == expected["nakshatra"] + assert packet["nakshatra_pada"] == expected["nakshatra_pada"] + assert packet["nakshatra_lord"] == expected["nakshatra_lord"] + assert result["ascendant"]["nakshatra"] == "Uttara Phalguni" + assert result["ascendant"]["nakshatra_pada"] == 1 + assert result["ascendant"]["nakshatra_lord"] == "Sun" diff --git a/tests/test_api_scratch_retention.py b/tests/test_api_scratch_retention.py index 65dab310..9c401d36 100644 --- a/tests/test_api_scratch_retention.py +++ b/tests/test_api_scratch_retention.py @@ -139,7 +139,10 @@ def test_numeric_birth_spellings_share_one_chart_cache_key(): assert _chart_key(dict(base, hour=12, tz="Asia/Kolkata")) not in keys skipped = _chart_key(dict(base, hour=12, skip_vedastro_main_entry_overview=True)) assert skipped not in keys - assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 6 + # 原值:cache_schema_version == 6 + # 新值:7 + # 原因:/api/chart 的上升补上星宿三项,旧缓存里没有这三项,必须换键。 + assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 7 def test_audit_artifact_default_off_and_compact_when_on(monkeypatch, tmp_path):