diff --git a/SKILL.md b/SKILL.md index b602dc71..a4a8d1c2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -6,6 +6,17 @@ description: "印度占星(Jyotish)商业解盘与推运系统。核心能 # 印度占星专业解盘与推运系统 +## 商业运行时路由(最高优先级) + +本文件是 Mastra 实际加载的商业 Skill 入口,**不是上游研究 Skill 的镜像**。执行时按以下顺序渐进读取: + +1. `references/upstream/yinduzhanxing/SKILL.md`:只读研究快照;来源身份与哈希见同目录 `source-manifest.json`。 +2. `references/strict-workflow-router.md`:问题域与严格技法路由。 +3. `references/oracle/commercial_skill_truth_overlay.v1.json`:商业声明和受限技法的最终覆盖层。 +4. 服务端 `consumer_context.answer_policy`:当前请求可回答范围的最终合同。 + +若研究快照、商业覆盖层和服务端回执冲突,以商业覆盖层和服务端回执为准。真实计算只能来自服务端工具,模型不得重算或发明行星位置。候选出生时间不得写成 confirmed;`blocked`、参数敏感、外部验证未闭环和多体系冲突必须原样保留。正式个人报告固定按 `executive_summary -> thematic_narrative -> evidence_appendix` 排列,Technique Audit Table 位于附录,不得置于摘要之前。医疗、法律、投资、安全关键结论及确定性死亡/诊断/妊娠预测均禁止。 + > **版本**:v6.9.14 | **详细变更**:`CHANGELOG.md` > **对标状态**:中文用户端与技法覆盖领先;D1/D9/AV/Chara 等有守门,Dasha/Shadbala 外部 oracle 扩充仍在进行。 > diff --git a/contracts/personal-report/report-document.v1.schema.json b/contracts/personal-report/report-document.v1.schema.json new file mode 100644 index 00000000..5a15a950 --- /dev/null +++ b/contracts/personal-report/report-document.v1.schema.json @@ -0,0 +1,432 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://jyotisha.chat/contracts/personal-report/report-document.v1.schema.json", + "title": "ReportDocument v1", + "description": "Server-issued personal astrology report document. This contract is enforced identically by the JSON Schema below, frontend/src/lib/personal-report-contract.ts (Zod), and scripts/personal_report_contract.py (stdlib Python validator). Semantics that JSON Schema draft-07 cannot express are enforced by both runtime validators and their tests: (1) charts must contain exactly one D1 chart, chart ids must be unique, and the D1 chart must contain all twelve house numbers 1..12 (enough real houses to render without fabrication); (2) every houseNumber must be unique within its chart; (3) evidence ids (id fields of techniqueAudit, conflicts and calculationEvidence rows) must be globally unique across the whole evidence appendix so evidenceRefs are never ambiguous; (4) provenance.evidenceHash is a deterministic recomputation over the evidence appendix (techniqueAudit, conflicts, calculationEvidence in canonical field order) - it is never trusted as a model self-report; the cryptographic hash is verified by the server runtime (frontend/src/lib/personal-report-contract.server.ts) and by the Python validator (scripts/personal_report_contract.py), and a document whose evidenceHash does not equal the recomputed value is rejected; the isomorphic frontend contract validates structure only and never recomputes the hash; (5) every entry in evidenceRefs must reference an id present in evidenceAppendix.techniqueAudit, evidenceAppendix.conflicts, or evidenceAppendix.calculationEvidence; (6) sections whose claimStatus is blocked must not contain deterministic predictions (e.g. 必然, 必定, 一定会, 肯定会, 绝对会, guaranteed, definitely will); (7) the UTF-8 JSON serialization of the whole document must not exceed 1572864 bytes (1.5 MiB). Fixed reader order: this schema defines the display sequence executiveSummary, thematicNarrative, evidenceAppendix as a UI/type-level presentation contract; JSON object key order is not validated (objects are unordered by definition). Privacy rule: subject/provenance metadata must never repeat full birth date, precise coordinates, or a raw chart payload. Content rule: no HTML/JS/CSS, no executable URLs (javascript:, vbscript:, data:text/html, file:), no internal filesystem paths, no prompt/tool traces or exception stacks, no model secrets or JWTs.", + "type": "object", + "definitions": { + "claimStatus": { + "type": "string", + "enum": [ + "multi_system_consensus", + "single_system_inference", + "parameter_sensitive", + "unclosed_divisional_chart", + "user_history_verification_required", + "blocked" + ] + }, + "evidenceId": { + "type": "string", + "pattern": "^ev-[a-z0-9_-]{1,63}$", + "minLength": 4, + "maxLength": 67 + }, + "sha256Hex": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "minLength": 64, + "maxLength": 64 + }, + "iso8601": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$", + "minLength": 20, + "maxLength": 40 + }, + "house": { + "type": "object", + "additionalProperties": false, + "properties": { + "houseNumber": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "sign": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "occupants": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "maxItems": 12 + } + }, + "required": ["houseNumber", "sign", "occupants"] + }, + "planet": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "sign": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "longitudeDegrees": { + "type": "number", + "minimum": 0, + "exclusiveMaximum": 360 + }, + "houseNumber": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "retrograde": { + "type": "boolean" + } + }, + "required": ["name", "sign", "longitudeDegrees", "houseNumber", "retrograde"] + }, + "chart": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "enum": ["D1", "D9", "D10"] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "houses": { + "type": "array", + "items": { + "$ref": "#/definitions/house" + }, + "maxItems": 12 + }, + "planets": { + "type": "array", + "items": { + "$ref": "#/definitions/planet" + }, + "maxItems": 12 + }, + "claimStatus": { + "$ref": "#/definitions/claimStatus" + } + }, + "required": ["id", "title", "houses", "claimStatus"] + }, + "thematicSection": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$", + "minLength": 1, + "maxLength": 64 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "narrative": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "actions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 400 + }, + "maxItems": 12 + }, + "caveats": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 400 + }, + "maxItems": 12 + }, + "claimStatus": { + "$ref": "#/definitions/claimStatus" + }, + "evidenceRefs": { + "type": "array", + "items": { + "$ref": "#/definitions/evidenceId" + }, + "maxItems": 24 + } + }, + "required": ["id", "title", "narrative", "actions", "caveats", "claimStatus", "evidenceRefs"] + }, + "techniqueAuditRow": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/definitions/evidenceId" + }, + "techniqueId": { + "type": "string", + "pattern": "^[a-z0-9_.-]{1,80}$", + "minLength": 1, + "maxLength": 80 + }, + "techniqueName": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "status": { + "type": "string", + "enum": ["verified", "partial", "blocked"] + }, + "used": { + "type": "boolean" + }, + "notes": { + "type": "string", + "maxLength": 500 + } + }, + "required": ["id", "techniqueId", "techniqueName", "status", "used"] + }, + "conflictRow": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/definitions/evidenceId" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "impact": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "status": { + "type": "string", + "enum": ["unresolved", "partial", "resolved"] + } + }, + "required": ["id", "description", "impact", "status"] + }, + "calculationEvidenceRow": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/definitions/evidenceId" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": ["id", "label", "value", "source"] + } + }, + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "string", + "const": "report_document.v1" + }, + "reportId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "reportType": { + "type": "string", + "enum": ["personal_full", "personal_thematic"] + }, + "presentationMode": { + "type": "string", + "enum": ["default", "research"] + }, + "generatedAt": { + "$ref": "#/definitions/iso8601" + }, + "subject": { + "type": "object", + "additionalProperties": false, + "properties": { + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "birthTimeStatus": { + "type": "string", + "enum": ["reported", "candidate", "accepted", "confirmed"] + }, + "birthPlaceLabel": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": ["displayName", "birthTimeStatus", "birthPlaceLabel"] + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "properties": { + "skillSourceCommit": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{40}$", + "minLength": 40, + "maxLength": 40 + }, + "skillSnapshotSha256": { + "$ref": "#/definitions/sha256Hex" + }, + "calculationHash": { + "$ref": "#/definitions/sha256Hex" + }, + "evidenceHash": { + "$ref": "#/definitions/sha256Hex" + }, + "reportContractVersion": { + "type": "string", + "const": "1" + } + }, + "required": ["skillSourceCommit", "skillSnapshotSha256", "calculationHash", "evidenceHash", "reportContractVersion"] + }, + "executiveSummary": { + "type": "object", + "additionalProperties": false, + "properties": { + "headline": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "priorities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "maxItems": 8 + }, + "overallClaimStatus": { + "$ref": "#/definitions/claimStatus" + } + }, + "required": ["headline", "summary", "priorities", "overallClaimStatus"] + }, + "charts": { + "type": "array", + "items": { + "$ref": "#/definitions/chart" + }, + "minItems": 1, + "maxItems": 3 + }, + "thematicNarrative": { + "type": "array", + "items": { + "$ref": "#/definitions/thematicSection" + }, + "maxItems": 12 + }, + "evidenceAppendix": { + "type": "object", + "additionalProperties": false, + "properties": { + "expandedByDefault": { + "type": "boolean" + }, + "techniqueAudit": { + "type": "array", + "items": { + "$ref": "#/definitions/techniqueAuditRow" + }, + "maxItems": 100 + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/definitions/conflictRow" + }, + "maxItems": 50 + }, + "calculationEvidence": { + "type": "array", + "items": { + "$ref": "#/definitions/calculationEvidenceRow" + }, + "maxItems": 100 + }, + "blockedTechniques": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "maxItems": 100 + } + }, + "required": ["expandedByDefault", "techniqueAudit", "conflicts", "calculationEvidence", "blockedTechniques"] + }, + "disclaimer": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": [ + "schemaVersion", + "reportId", + "reportType", + "presentationMode", + "generatedAt", + "subject", + "provenance", + "executiveSummary", + "charts", + "thematicNarrative", + "evidenceAppendix", + "disclaimer" + ] +} diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index fb5f7365..b0836d3a 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -19,3 +19,8 @@ ADMIN_EMAILS= EPAY_CONFIG_ENCRYPTION_KEY= EPAY_CHAT_ENABLED=false JYOTISH_DYNAMIC_RECTIFICATION_TOKEN= + +# Personal reports are staging-only in this rollout. The web service receives +# these server-side values through env_file; they are not NEXT_PUBLIC values. +PERSONAL_REPORT_ENABLED=true +PERSONAL_REPORT_DAILY_LIMIT=5 diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index 807af650..13bef651 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -106,4 +106,22 @@ fi require_selector EPAY_CHAT_ENABLED false require_literal JYOTISH_DYNAMIC_RECTIFICATION_TOKEN 32 +personal_report_enabled_count="$(grep -Ec '^PERSONAL_REPORT_ENABLED=' "$ENV_FILE" || true)" +personal_report_enabled="$(grep -E '^PERSONAL_REPORT_ENABLED=' "$ENV_FILE" || true)" +personal_report_enabled="${personal_report_enabled#*=}" +if [ "$personal_report_enabled_count" -ne 1 ] || + [[ "$personal_report_enabled" != "true" && "$personal_report_enabled" != "false" ]]; then + echo "invalid staging personal report setting: PERSONAL_REPORT_ENABLED" >&2 + exit 1 +fi + +personal_report_daily_limit_count="$(grep -Ec '^PERSONAL_REPORT_DAILY_LIMIT=' "$ENV_FILE" || true)" +personal_report_daily_limit="$(grep -E '^PERSONAL_REPORT_DAILY_LIMIT=' "$ENV_FILE" || true)" +personal_report_daily_limit="${personal_report_daily_limit#*=}" +if [ "$personal_report_daily_limit_count" -ne 1 ] || + [[ ! "$personal_report_daily_limit" =~ ^[1-9][0-9]*$ ]]; then + echo "invalid staging personal report setting: PERSONAL_REPORT_DAILY_LIMIT" >&2 + exit 1 +fi + echo "staging environment selectors: valid" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ea97b5ab..77b4e433 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2161,3 +2161,50 @@ - 防复发:self-hosted staging 后台查询不得依赖 LocalPostgresDataClient 未实现的 Supabase builder、RPC 或 Admin Auth 能力;支付与套餐必须保持独立资源顺序。套餐与易支付配置契约必须显式拒绝 Supabase builder/RPC 并锁定参数化 SQL、404、原子函数写入和安全错误响应;支付配置必须默认折叠,后台必须拥有独立滚动容器且不得放宽聊天的全局 `overflow:hidden`。易支付配置读写测试必须同时覆盖数据库列和公开字段;创建订单只生成经公网 SSRF 校验的签名收银台 URL,商户密钥只能参与服务端签名,不得进入 URL、响应、日志或审计。对话支付默认关闭,UI 与创建订单 API 必须共享服务端开关;可用性测试不得提交伪订单或返回 URL、PID、密钥、headers/body。 - 相关记录:BUG-122、BUG-123 - 修复版本:`d44a414`(权限迁移),staging 部署 `1f44892a2cf210797e7dc74f49721a8f10c8849d` + +## BUG-125 | 个人报告入口对不可用出生时间状态错误开放 + +- 状态:resolved(local,pending staging deployment) +- 首次发现:2026-08-06 +- 最近更新:2026-08-06 +- 影响面:首页个人报告 CTA、`POST /api/reports` 出生时间门槛 +- 用户现象:资料流程已经完成、但出生时间仍为 `reported` 或 `candidate` 的用户会看到“生成个人报告”,点击后服务端必然返回 `422 birth_time_not_usable`。 +- 触发条件:用户有咨询会话和消息,`profileComplete=true`,但当前排盘时间尚未被用户采用或引擎确认。 +- 根因:首页只用资料完整度判断入口可见性,没有镜像报告 API 的 `accepted/confirmed + 有效 active time` 门槛;UI 与服务端各自正确但组合后形成误导入口。 +- 修复:首页复用既有 `isBirthTimeReadyForConsultation(profile)`,只有 `accepted` 或 `confirmed` 且当前排盘时间有效时才显示个人报告入口;服务端门槛保持不变,不把候选范围或填报时间伪装成已采用时间。 +- 验证:`frontend/tests/personal-report-entry.test.ts` 15/15 通过,新增回归直接覆盖 `reported=false`、`candidate=false`、`accepted=true`、`confirmed=true` 及缺失 active time 为 false;目标 TypeScript、ESLint 和 `git diff --check` 通过。 +- 防复发:任何报告出生时间状态扩展必须同时更新服务端事实门槛和客户端可见性测试;客户端不得仅以资料表单完成度推导报告可生成。 +- 相关记录:BUG-117、BUG-119 +- 修复版本:本次个人报告 staging 发布提交 + +## BUG-126 | 正式报告页进入能力审计后质量门禁仍断言旧路由集合 + +- 状态:resolved +- 首次发现:2026-08-06 +- 最近更新:2026-08-06 +- 影响面:`tests/test_api_server_security.py`、Gitea `Staging Backend Quality Gate`、正式报告页面可发现性审计 +- 用户现象:PR quality gate run `1453` 中 290 项 Python 检查通过,但 `test_capability_audit_scans_registry_and_local_sources` 因扫描结果新增 `reports/[reportId]` 而失败。 +- 触发条件:新增 `frontend/src/app/reports/[reportId]/page.tsx` 后运行 API 安全 quick quality gate。 +- 根因:能力审计会动态扫描前端页面,新增报告 reader 被正确识别;精确路由集合测试仍锁定新增前的六个页面,且本地个人报告聚焦矩阵没有包含该跨层能力审计测试。这是 BUG-014 的同类契约更新遗漏。 +- 修复:将 `reports/[reportId]` 明确纳入能力审计预期路由集合,不隐藏或排除真实产品入口;将该测试纳入本轮修复后的本地和远端门禁复验。 +- 验证:`tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources` 本地聚焦通过;Gitea quality gate run `1459` 在完整 runner 中通过,Python quick gate 291 passed / 1 skipped。 +- 防复发:新增或删除 Next.js 页面时必须运行能力审计安全测试;报告前端验收矩阵增加跨层 `_scan_app_routes` 契约,不能只运行 `frontend/tests/personal-report-*`。 +- 相关记录:BUG-014、BUG-125 +- 复发自:BUG-014 +- 修复版本:本次个人报告 staging 发布提交 + +## BUG-127 | 个人报告迁移成功后 self-hosted 精确表清单仍是旧值 + +- 状态:resolved +- 首次发现:2026-08-06 +- 最近更新:2026-08-06 +- 影响面:`frontend/tests/database-local-business.test.ts`、self-hosted PostgreSQL 全迁移验收、Gitea `Staging Backend Quality Gate` +- 用户现象:quality gate run `1456` 的 1409 项 frontend 测试中 1408 项通过;真实 PostgreSQL fixture 成功创建 `public.personal_reports` 后,精确表集合断言因预期值缺少该表而失败。 +- 触发条件:在完整 Docker/PostgreSQL runner 中应用全部迁移并枚举 `public` schema 表。 +- 根因:个人报告迁移契约覆盖了双迁移语义、RLS、权限和版本唯一性,但既有 self-hosted 全库精确表清单没有同步新增 `personal_reports`;本机缺少 Docker CLI,无法执行该 fixture,问题由远端完整 runner 捕获。 +- 修复:在 self-hosted 全迁移测试中显式断言 `20260806000000_personal_reports.sql` 被应用,并将 `personal_reports` 按字典序加入精确表清单;不删除真实表、不放宽集合比较。 +- 验证:静态 personal-report migration tests 9/9 和迁移版本测试通过;Gitea quality gate run `1459` 的真实 PostgreSQL fixture 与完整 frontend suite 通过,frontend 1409/1409。 +- 防复发:新增 self-hosted 业务表时必须同时更新全迁移 applied ledger 和精确 `public` 表集合;本地没有 Docker 时必须依赖并等待完整远端数据库门禁,不能仅凭迁移文本测试宣称数据库全绿。 +- 相关记录:BUG-126 +- 复发自:无 +- 修复版本:本次个人报告 staging 发布提交 diff --git a/docs/operations/personal-report-staging.md b/docs/operations/personal-report-staging.md new file mode 100644 index 00000000..1d29724e --- /dev/null +++ b/docs/operations/personal-report-staging.md @@ -0,0 +1,168 @@ +# Personal Report Staging Acceptance and Rollback + +This runbook covers the staging-only rollout of `ReportDocument v1`, personal report generation, the report reader, D1 SVG rendering, and browser print-to-PDF. It does **not** authorize a production migration or deployment. + +## Release identity + +- Pre-change application baseline: `49da8f916960030d5760d8dedf4e77820732a527` +- Read-only upstream Skill source commit: `unknown` (the local source has no usable Git metadata) +- Read-only upstream source tree SHA-256: `9034e1967032d09c7fbae83fc2205f7e75e8ad482c5f9eba1bf309fe30aef5bb` +- Packaged `SKILL.md` SHA-256: read from `references/upstream/yinduzhanxing/source-manifest.json` +- Deployment SHA: use the exact full SHA shared by `main` and `staging`; never substitute a short SHA or mutable image tag. +- Application rollback target: `49da8f916960030d5760d8dedf4e77820732a527`, subject to successful gate artifact retention. + +## Staging configuration + +The server-owned `/opt/jyotisha-staging/.env.staging` must remain mode `0600`. Do not print or copy the file. In addition to the existing self-hosted identity settings, configure: + +```dotenv +PERSONAL_REPORT_ENABLED=true +PERSONAL_REPORT_DAILY_LIMIT=5 +``` + +`PERSONAL_REPORT_ALLOWED_ORIGINS` is normally omitted because browser requests are same-origin. Add it only for an explicitly reviewed origin. `JYOTISH_SKILL_SNAPSHOT_SHA256` and `JYOTISH_SKILL_SOURCE_COMMIT` are optional deployment pins; without a valid override, the web bundle uses the statically packaged, validated source manifest and fails closed if its SHA is invalid. + +Validate without logging values: + +```bash +cd /opt/jyotisha-staging +./deploy/validate-staging-env.sh .env.staging +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet +``` + +The report API reads these values server-side through Compose `env_file`. They must not be changed to `NEXT_PUBLIC_*` build arguments. + +## Exact-SHA release sequence + +Follow `deploy/README.md`; the short form is: + +1. Confirm the reviewed `main` full SHA and fast-forward `staging` to the same SHA. +2. Wait for `Staging Backend Quality Gate` on the `staging` push to succeed and publish the immutable API/web digest manifest. +3. Let the automatic read-only deployment check stop on pending migrations. +4. Run `Migrate Staging Database` manually from the `main` controller with the exact full SHA. +5. Confirm the migration ledger contains `20260806000000_personal_reports.sql`. +6. Run `Deploy staging` manually with that same SHA and `allow_rollback=false`. +7. Verify `https://staging.jyotisha.chat/api/health` reports that exact SHA and both private services healthy. + +Do not deploy the application before the additive migration. Do not trigger production workflows. + +## Automated acceptance + +Before pushing, record the literal command and outcome for: + +```bash +python3 -m pytest -q \ + tests/test_cross_project_contract.py \ + tests/test_cross_project_sync_status.py \ + tests/test_import_yinduzhanxing.py + +python3 -m pytest -q \ + tests/test_unified_consultation_orchestrator.py \ + tests/test_report_orchestrator_reader_contract.py \ + tests/test_consultation_consumer_context.py \ + tests/test_personal_report_contract.py \ + tests/test_supabase_migration_versions.py + +npm test --prefix frontend +npm run lint --prefix frontend +npm run build --prefix frontend +``` + +If the complete frontend suite has environment-only failures, preserve their exact count and cause; do not report the suite as green. The personal-report-focused test set, TypeScript check, Python contract test, migration contract, production build, and `git diff --check` must pass before release. + +## Synthetic browser acceptance + +Use only synthetic accounts and synthetic birth facts. Never paste real birth data into tickets, logs, screenshots, or this document. + +Prepare these profiles: + +1. `confirmed` with an exact synthetic active time. +2. `accepted` with a user-adopted synthetic candidate time. +3. A report whose evidence contains blocked/conflicting techniques. +4. A second synthetic owner account for isolation checks. + +For each supported owner profile: + +1. Sign in and complete a personal consultation with workflow evidence. +2. Confirm the “生成个人报告” CTA is visible only for `accepted` or `confirmed` active time. It must be hidden for `reported` and unaccepted `candidate` states. +3. Generate one report. Confirm the button prevents duplicate in-flight clicks and the app navigates to `/reports/`. +4. Confirm content order is summary, thematic narrative, then evidence appendix. +5. Confirm D1 renders from real document facts. D9/D10 must be absent when their complete facts are unavailable. +6. Confirm blocked claims use uncertainty language and contain no deterministic prediction. +7. Reload the report URL and confirm the same validated document is returned. +8. Attempt the report URL as the second synthetic owner; expect `404`/not found and no document data. +9. Delete as the owner and confirm subsequent retrieval is not found. +10. Generate with the same request ID and payload to confirm replay behavior; a different payload under the same ID must return `409 report_request_conflict`. + +PDF/browser matrix: + +| Client | Required check | Status before user handoff | +| --- | --- | --- | +| Desktop Chrome | Print → Save as PDF; SVG sharp; Chinese text intact; tables not clipped | pending manual | +| macOS Safari | Print → Save as PDF; pagination and fonts | pending manual | +| iPhone Safari | Share/Print flow and mobile hint | pending manual | +| WeChat in-app browser | Export guidance is understandable | pending manual | + +For a long synthetic document, target 20–40 printed pages and verify that print CSS expands the evidence appendix as designed. The report action must only wait for `document.fonts.ready` and call `window.print()`. + +## Security and server assertions + +Verify logged-out and cross-owner behavior without recording tokens: + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' https://staging.jyotisha.chat/api/account +curl -sS -o /dev/null -w '%{http_code}\n' https://staging.jyotisha.chat/api/reports/00000000-0000-4000-8000-000000000000 +``` + +Expected logged-out status is `401`. Authenticated owner/non-owner checks must be performed in the browser or with ephemeral local credentials that are never pasted into logs. + +On the host, confirm no product PDF browser runtime exists: + +```bash +cd /opt/jyotisha-staging +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml exec -T web \ + sh -lc '! command -v chromium && ! command -v chromium-browser && ! command -v google-chrome' +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml exec -T web \ + sh -lc '! ps aux | grep -E "[c]hromium|[p]laywright|[p]uppeteer"' +``` + +Inspect logs only for stable codes and operational state. Do not log or copy report text, prompts, birth facts, JWTs, cookies, model keys, exception stacks, or the staging env file. + +## Resource evidence + +Record before, during one generation, and during two synthetic users attempting concurrent generation: + +```bash +cd /opt/jyotisha-staging +docker stats --no-stream +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml ps +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml logs --tail=200 api web caddy +``` + +Capture only aggregate CPU, memory, container restart count, request duration, stable failure state, and serialized report byte size. Confirm: + +- one generation per user at a time; +- a second concurrent request for the same user returns the stable in-progress response; +- serialized `ReportDocument` is at most 1.5 MiB; +- no OOM or container restart; +- no Chromium/Playwright server process. + +One successful sample does not establish high-concurrency capacity. + +## Rollback + +### Application + +Run `Deploy staging` from the trusted `main` controller using the previous known-good full SHA and `allow_rollback=true`. The selected SHA must still have a successful quality-gate artifact. Do not use a mutable tag or manually overwrite the server tree. + +### Database + +The personal report migration is additive. Application rollback intentionally leaves `public.personal_reports`, its grants, constraints, indexes, and RLS policies in place. Do not run a destructive down migration. Delete only explicitly identified synthetic test rows if cleanup is required. + +### Feature pause + +If generation must stop while the current application remains deployed, set `PERSONAL_REPORT_ENABLED=false` in the server-owned staging env under the deployment lock and recreate only the web service after validation. This is an operational containment action, not a substitute for an application rollback. Restore `true` only after the incident is resolved. + +### Skill + +Revert the commercial repository import/semantic-merge commits as reviewed. Do not modify the read-only upstream source directory. Preserve the import manifest and record that the target revision was rolled back; never rewrite provenance history. diff --git a/docs/research/yinduzhanxing_one_way_import_2026_08_06.md b/docs/research/yinduzhanxing_one_way_import_2026_08_06.md new file mode 100644 index 00000000..19c7bde9 --- /dev/null +++ b/docs/research/yinduzhanxing_one_way_import_2026_08_06.md @@ -0,0 +1,24 @@ +# Yinduzhanxing -> Jyotisha one-way import + +## Authority boundary + +- Direction: `732642856/yinduzhanxing` research source -> `root/Jyotisha` commercial target only. +- Reverse synchronization is forbidden and is not expressible by the v2 policy or importer CLI. +- The user-provided source at `../yinduzhanxing-main` is a snapshot without usable Git metadata. Imports from it must record `source_commit=unknown` and a deterministic `source_tree_hash`; they must not claim parity with a GitHub commit. +- Commercial frontend, identity, billing, database, deployment and commercial truth overlays remain protected. + +## Review flow + +```bash +.venv/bin/python scripts/import_yinduzhanxing.py \ + --source ../yinduzhanxing-main \ + --policy references/cross_project_contract/sync_policy.v2.json \ + --dry-run \ + --output artifacts/yinduzhanxing-import-plan.json +``` + +A reviewer must inspect semantic merge rows. Only then may the mirror allowlist be applied. `SKILL.md`, `AGENTS.md`, orchestrators and API entrypoints are semantic-merge paths and are never overwritten by the importer. + +## Rollback + +Revert the commercial import commit. Preserve the manifest for audit history. Never modify or push the research snapshot as part of rollback. diff --git a/frontend/db/migrations/20260806000000_personal_reports.sql b/frontend/db/migrations/20260806000000_personal_reports.sql new file mode 100644 index 00000000..2a8a7ee7 --- /dev/null +++ b/frontend/db/migrations/20260806000000_personal_reports.sql @@ -0,0 +1,104 @@ +-- Personal report persistence for self-hosted PostgreSQL (staging). +-- Mirrors supabase/migrations/20260806010000_personal_reports.sql +-- one-to-one in table shape, constraints, RLS and grants. +-- +-- Ownership model: rows are owned by auth.users(id) (the business-auth +-- mirror kept in sync by identity.sync_user_to_business_auth). Normal +-- application sessions connect as app_runtime (member of authenticated) and +-- set local role authenticated; they may select/delete only their own rows +-- through RLS plus the explicit owner grants below, and can never +-- insert/update (generation and status writes are performed exclusively +-- through service_role, which has BYPASSRLS and full table privileges). +-- admin_runtime has no direct access to report bodies (least privilege); +-- server-side generation runs through service_role, which admin_runtime may +-- SET ROLE to. +-- +-- Idempotency: unique (user_id, request_id) is the primary lock; replay of a +-- known requestId requires the same request_fingerprint (a sha256 of the +-- caller's request intent), so a different payload under the same requestId +-- surfaces as request_conflict instead of silently overwriting. Failed +-- retries are not implicitly upserted here; callers either reuse the failed +-- record via a new requestId or surface the stable failure. +-- +-- No birth details, report bodies, model prompts or exception stacks are ever +-- written to logs, index columns or audit events; failure_code is a stable +-- enum shared with frontend/src/lib/personal-report-service.ts and +-- scripts/personal_report_contract.py. + +create table if not exists public.personal_reports ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + session_id uuid, + chart_profile_id uuid, + request_id uuid not null, + request_fingerprint text not null check (request_fingerprint ~ '^[0-9a-f]{64}$'), + report_type text not null check (report_type in ('personal_full', 'personal_thematic')), + status text not null check (status in ('generating', 'ready', 'failed')), + schema_version text not null check (schema_version = 'report_document.v1'), + presentation_mode text not null check (presentation_mode in ('default', 'research')), + requested_themes text[] not null default '{}'::text[], + report_document jsonb, + calculation_hash text check (calculation_hash is null or calculation_hash ~ '^[0-9a-f]{64}$'), + evidence_hash text check (evidence_hash is null or evidence_hash ~ '^[0-9a-f]{64}$'), + skill_source_commit text check (skill_source_commit is null or skill_source_commit ~ '^[0-9a-f]{40}$'), + skill_snapshot_sha256 text not null check (skill_snapshot_sha256 ~ '^[0-9a-f]{64}$'), + failure_code text check (failure_code in ( + 'profile_incomplete', + 'birth_time_not_usable', + 'report_generation_in_progress', + 'report_rate_limited', + 'calculation_unavailable', + 'model_unavailable', + 'report_schema_invalid', + 'report_guard_rejected', + 'report_not_found' + )), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + completed_at timestamptz, + check ((status = 'ready') = (report_document is not null)), + check ((status = 'ready') = (completed_at is not null)), + check ((status = 'ready') = (calculation_hash is not null)), + check ((status = 'ready') = (evidence_hash is not null)), + check ((status = 'failed') = (failure_code is not null)), + unique (user_id, request_id) +); + +create index if not exists personal_reports_user_created_idx + on public.personal_reports (user_id, created_at desc); + +-- One in-flight generation per user, enforced by the database so a second +-- request cannot start while the first is still generating. +create unique index if not exists personal_reports_one_generating_per_user + on public.personal_reports (user_id) + where status = 'generating'; + +alter table public.personal_reports enable row level security; + +revoke all on table public.personal_reports from public, anon, authenticated, service_role; +revoke all on table public.personal_reports from app_runtime, admin_runtime, migration_runner, backup_reader; + +drop policy if exists personal_reports_select_own on public.personal_reports; +create policy personal_reports_select_own + on public.personal_reports + for select + to authenticated + using (auth.uid() = user_id); + +drop policy if exists personal_reports_delete_own on public.personal_reports; +create policy personal_reports_delete_own + on public.personal_reports + for delete + to authenticated + using (auth.uid() = user_id); + +-- Normal users can never insert or update rows: creating a generating record +-- and moving it to ready/failed are server-side operations only. RLS +-- policies alone do not grant table privileges, so the owner read/delete +-- grants below are required for the policies to be reachable. +grant select, delete on table public.personal_reports to authenticated; + +-- admin_runtime intentionally has no direct access to report bodies (least +-- privilege); server-side generation runs through service_role, which +-- admin_runtime may SET ROLE to. +grant select, insert, update, delete on table public.personal_reports to service_role; diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts new file mode 100644 index 00000000..f1f31de7 --- /dev/null +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -0,0 +1,133 @@ +import { NextResponse } from "next/server"; +import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; +import { resolveAllowedReportOrigins } from "@/lib/personal-report-entitlement"; +import { + resolveReportDelete, + resolveReportRead, +} from "@/lib/personal-report-route-core"; +import { safeParseServerReportDocument } from "@/lib/personal-report-contract.server"; +import { + createSupabasePersonalReportService, + type PersonalReportService, +} from "@/lib/personal-report-service"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ reportId: string }> }; + +function sanitizedErrorCode(error: unknown): string { + if (error instanceof Error) return error.name; + return "UnknownError"; +} + +function toNextResponse(response: { status: number; body: Record }) { + return NextResponse.json(response.body, { status: response.status }); +} + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * GET/DELETE use the AUTHENTICATED client: RLS permits owners to select and + * delete their own rows only, and the persistence service additionally scopes + * every query by userId (least privilege — no service role here). + */ +async function resolvePersistenceForUser() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return { userId: null as string | null, persistence: null as PersonalReportService | null }; + } + const persistence = createSupabasePersonalReportService(supabase); + return { userId: user.id, persistence }; +} + +export async function GET(request: Request, context: RouteContext) { + try { + const { userId, persistence } = await resolvePersistenceForUser(); + const { reportId } = await context.params; + if (!uuidPattern.test(reportId)) { + return NextResponse.json( + { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + { status: 404 }, + ); + } + const response = await resolveReportRead({ + requestUrl: request.url, + origin: request.headers.get("origin"), + allowedOrigins: resolveAllowedReportOrigins(process.env), + userId, + reportId, + persistence: persistence ?? { + async getOwnedById() { + return null; + }, + }, + // Defense in depth: a stored ready document is re-validated through the + // canonical server parse (schema + guards + evidence hash recompute) + // before it is returned to the browser. Client-side validation is never + // a substitute. + validateReadyDocument: (document) => { + const parsed = safeParseServerReportDocument(document); + return parsed.ok + ? { ok: true, document: parsed.document } + : { ok: false }; + }, + }); + return toNextResponse(response); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + console.error(`[reports] read failed reason=${sanitizedErrorCode(error)}`); + return NextResponse.json( + { error: "报告暂时无法读取", code: REPORT_STABLE_CODES.generationFailed }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: Request, context: RouteContext) { + try { + const { userId, persistence } = await resolvePersistenceForUser(); + const { reportId } = await context.params; + if (!uuidPattern.test(reportId)) { + return NextResponse.json( + { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + { status: 404 }, + ); + } + const response = await resolveReportDelete({ + requestUrl: request.url, + origin: request.headers.get("origin"), + allowedOrigins: resolveAllowedReportOrigins(process.env), + userId, + reportId, + persistence: persistence ?? { + async getOwnedById() { + return null; + }, + async deleteOwned() { + return false; + }, + }, + }); + return toNextResponse(response); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + console.error(`[reports] delete failed reason=${sanitizedErrorCode(error)}`); + return NextResponse.json( + { error: "报告暂时无法删除", code: REPORT_STABLE_CODES.generationFailed }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts new file mode 100644 index 00000000..588e7470 --- /dev/null +++ b/frontend/src/app/api/reports/route.ts @@ -0,0 +1,148 @@ +import { NextResponse } from "next/server"; +import { runConsultationWorkflow } from "@/mastra"; +import { createPersonalReportAgent } from "@/mastra/personal-report"; +import { defaultLanguageModel } from "@/mastra/model"; +import { + resolveSkillSnapshot, +} from "@/lib/personal-report-generation"; +import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; +import { + isPersonalReportFeatureEnabled, + readPersonalReportDailyLimit, + resolveAllowedReportOrigins, +} from "@/lib/personal-report-entitlement"; +import { + resolveReportCreate, + type ReportCreateCoreDeps, +} from "@/lib/personal-report-route-core"; +import { + createPersonalReportDataClient, + createSupabasePersonalReportService, + type PersonalReportService, +} from "@/lib/personal-report-service"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +function sanitizedErrorCode(error: unknown): string { + if (error instanceof Error) return error.name; + return "UnknownError"; +} + +function toNextResponse(response: { status: number; body: Record }) { + return NextResponse.json(response.body, { status: response.status }); +} + +export async function POST(request: Request) { + try { + // Authenticated client: auth, profile, session/chart-profile owner reads. + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + const userId = authError || !user ? null : user.id; + + // Admin client (service_role / self-hosted admin DB): generation writes + // and counting. The authenticated client is forbidden by migration grants + // from inserting/updating personal_reports. + const admin = createAdminSupabaseClient(); + const persistence: PersonalReportService = createSupabasePersonalReportService(admin); + const adminDataClient = createPersonalReportDataClient(admin); + + let profile: unknown = null; + let profileError: unknown = null; + if (userId) { + const result = await supabase + .from("profiles") + .select("name,birth_date,active_birth_time,birth_time_status,latitude,longitude,timezone_offset,birth_place_label") + .eq("id", userId) + .maybeSingle(); + profile = result.data ?? null; + profileError = result.error; + } + + const deps: ReportCreateCoreDeps = { + requestUrl: request.url, + origin: request.headers.get("origin"), + allowedOrigins: resolveAllowedReportOrigins(process.env), + userId, + rawBody: await request.json().catch(() => null), + profile, + checkSessionOwned: async (sessionId) => { + const { data, error } = await supabase + .from("chat_sessions") + .select("id") + .eq("id", sessionId) + .eq("user_id", userId as string) + .maybeSingle(); + if (error) throw error; + return Boolean(data); + }, + checkChartProfileOwned: async (chartProfileId) => { + const { data, error } = await supabase + .from("chart_profiles") + .select("id") + .eq("id", chartProfileId) + .eq("user_id", userId as string) + .maybeSingle(); + if (error) throw error; + return Boolean(data); + }, + featureEnabled: isPersonalReportFeatureEnabled(process.env), + dailyLimit: readPersonalReportDailyLimit(process.env), + counts: { + countGenerating: async () => { + const { data, error } = await adminDataClient.from("personal_reports") + .select("id") + .eq("user_id", userId as string) + .eq("status", "generating") + .limit(2); + if (error) throw error; + return Array.isArray(data) ? data.length : 0; + }, + countCreatedToday: async () => { + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const { data, error } = await adminDataClient.from("personal_reports") + .select("id,created_at") + .eq("user_id", userId as string); + if (error) throw error; + if (!Array.isArray(data)) return 0; + const startIso = todayStart.toISOString(); + return data.filter((row) => { + const createdAt = row && typeof row === "object" + ? (row as Record).created_at + : null; + return typeof createdAt === "string" && createdAt >= startIso; + }).length; + }, + }, + persistence, + model: defaultLanguageModel(), + runWorkflow: (input) => runConsultationWorkflow(input), + createAgent: (model) => createPersonalReportAgent(model as Parameters[0]), + skillSnapshot: resolveSkillSnapshot(), + }; + + const response = await resolveReportCreate(deps); + if (response.status >= 500 && profileError) { + console.error(`[reports] create failed request=${String(deps.rawBody && typeof deps.rawBody === "object" + ? (deps.rawBody as Record).requestId ?? "unknown" + : "unknown")} reason=${sanitizedErrorCode(profileError)}`); + } + return toNextResponse(response); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + console.error(`[reports] create failed reason=${sanitizedErrorCode(error)}`); + return NextResponse.json( + { error: "报告生成暂时不可用", code: REPORT_STABLE_CODES.generationFailed }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 92ff7adc..6b369c65 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1787,3 +1787,55 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .payment-qr-wrap img { display: block; width: 100%; height: 100%; object-fit: contain; } .payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); } .payment-qr-badge svg { display: block; width: 100%; height: 100%; } + +/* ============================================================ + personal-report: unique block — report reader + A4 print + Owned by the report UI worker. Only used by /reports/[reportId]. + Screen layout uses Tailwind utilities; this block only adds + print-critical and report-specific rules. + ============================================================ */ +@page { + size: A4; + margin: 14mm 12mm; +} + +/* Small cards / short tables / charts avoid page breaks; long themes + (personal-report-theme) intentionally paginate. */ +.personal-report-avoid-break { + break-inside: avoid-page; +} + +.personal-report-avoid-break-row { + break-inside: avoid; +} + +@media print { + html, + body { + background: #fff !important; + } + + * { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + /* Navigation, disclosure toggle and other screen-only chrome. */ + .personal-report-screen-only { + display: none !important; + } + + /* Collapsed appendix is printed in full (product decision). */ + .personal-report-print-always { + display: block !important; + } + + .personal-report-document { + max-width: none !important; + padding: 0 !important; + } + + .personal-report-chart-svg { + max-width: 120mm; + } +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index c1b4b62b..499a4453 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -16,6 +16,7 @@ import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; import { AppLoadingIndicator } from "@/components/app-loading-indicator"; import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification"; import { ChatMessageContent } from "@/components/chat-message-content"; +import { GeneratePersonalReportButton } from "@/components/personal-report/generate-personal-report-button"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; import { ModelSelector } from "@/components/model-selector"; import { @@ -35,6 +36,7 @@ import { birthTimePersistenceValues, declaredBirthInputChanged, describeBirthTimeDraft, + isBirthTimeReadyForConsultation, isDeclaredBirthProfileComplete, isBirthTimeDraftReady, normalizePersistedBirthDate, @@ -1126,6 +1128,26 @@ export default function Home() { const profileComplete = isProfileComplete(profile); const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId); const personalChartAvailable = birthTimeRoute.kind === "consult" && birthTimeRoute.mode !== "general_no_birth_time"; + + // Client-side display hint only: an in-memory workflow receipt on the latest + // assistant answer. After a reload receipts are gone, so the state falls back + // to "unknown" (the button copy says the server will verify) instead of + // fabricating an evidence boolean. The server owns real evidence validation. + const latestAssistantMessage = [...(activeSession?.messages ?? [])] + .reverse() + .find((message) => message.role === "assistant"); + const reportEvidenceState: "ready" | "unknown" = latestAssistantMessage?.workflowReceipt + ? "ready" + : "unknown"; + // Mirror the server-side birth-time gate (accepted/confirmed + usable active + // time): reported/candidate users must not see the entry, since the API would + // reject them with 422. profileComplete alone is not enough. + const reportBirthTimeUsable = isBirthTimeReadyForConsultation(profile); + const reportEntryVisible = !rectificationSurfaceOpen + && profileComplete + && reportBirthTimeUsable + && activeSession?.sessionType === "consultation" + && activeSession.messages.length > 0; const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics; const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null); const onboardingPending = profileComplete && !onboarding && !onboardingError; @@ -2819,6 +2841,12 @@ export default function Home() { : personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"}
+ {reportEntryVisible && activeSession && ( + + )} {account.isAdmin && account.adminUrl ? (