Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f1b9ac33f | ||
|
|
06d242bae8 | ||
|
|
b3a938dafb | ||
|
|
56bb1bed95 | ||
|
|
aa82210005 | ||
|
|
44f181770d | ||
|
|
0150a87496 |
@@ -24987,3 +24987,31 @@
|
||||
- 防复发:校验全部静态阻塞项都有原因码。用原生引擎验证公开盘边界两侧的分盘上升点并回归内部单号清理。
|
||||
- 关联记录:#201、#192、BUG-1846、BUG-1844。
|
||||
- 修复版本:`codex/reading-facts-v3-20261112`。
|
||||
|
||||
## BUG-1851 | 个人报告解读没有按占星师的 skill 写,等级由模型自评,退稿时整节消失
|
||||
|
||||
- 状态:fixed-pending-verify(staging 待真机)。
|
||||
- 首次发现 / 最近更新:2026-11-11 / 2026-11-12。
|
||||
- 影响面:新生成的中文个人报告「解读」。
|
||||
- 现象:解读只用产品自己的系统提示,没有加载她的 SKILL 与路由;每个领域节末尾由模型自评「置信度:A/B/C」,并沿用「没有用户经历所以最高 B」;公开名人盘样稿出现健康节隐瞒阻塞、D2 尊贵度写错、给已故人物当前建议等问题;任一段违规整份退稿,报告上不显示「解读」。
|
||||
- 触发条件:所有带解读的个人报告。
|
||||
- 根因:#113 的解读是一次性按事实写九节,提示词写在 TypeScript 里;没有逐结论的依赖与上限,也没有发布前的数据一致性复查。
|
||||
- 修复:按占星师 2026-11-12 审核(她的仓库 8aaf6099,handoff/2026-11-12/preset_q3):后端 `scripts/report_render/evidence_grade.py` 按她路由文件 §3–§7 的必查模块给每类结论算等级上限与六维账本(合同 report_evidence_grade_v1,无适配案例最高 C,必需依据缺失为 D);解读加载她的 SKILL.md 与 8 份 references 原文,产品规则移到 references/product_runtime_contract.md 的 report-reading 块,加载清单与哈希存入每份解读;模型逐段给结论类型与等级,复查逐节进行(等级越过上限、依据缺失却说无阻塞、尊贵度与事实不符、禁用词),只把违规的那一节换成「这一节没有通过数据核对,暂不显示。」;页面每段下方显示等级与含义(产品负责人 2026-11-12 选原型方案 B)。
|
||||
- 验证:DeepSeek v4 pro 两张公开名人盘各一稿:历史回放盘九节通过、无当前建议、健康 Badhaka 段为 D、D2 写入旺、两套大运不再按主星名比冲突、双行运只用于第 7 宫;在世盘因「口径」被拦一节。评测发现的尊贵度误判(落陷取消)与正文写检索和案例校准两处已修,并补回归。
|
||||
- 防复发:`tests/test_report_evidence_grade_20261112.py`;`frontend/tests/report-reading-20261027.test.tsx` 的上限、无阻塞、尊贵度、逐节不显示用例;前后端结论类型一致性测试。
|
||||
- 关联记录:#192、#201、#180、BUG-1846、BUG-1848、BUG-1794、BUG-1834。
|
||||
- 修复版本:`claude/report-reading-grade-20261112`。
|
||||
|
||||
## BUG-1850 | 回答时钟回归依赖真实耗时,资料库校验隐式选中旧导出
|
||||
|
||||
- 状态:fixed-pending-verify(待独立验收)。
|
||||
- 首次发现 / 最近更新:2026-11-12 / 2026-11-12。
|
||||
- 影响面:咨询证据查表回归、预置资料库来源校验与 CLI。
|
||||
- 现象:前端回归在负载下收到 `empty_answer`,原断言要求 `answer_truncated`。资料库定向测试在旧导出存在时收到哈希不匹配。
|
||||
- 触发条件:真实测试计时先于首段正文到点,或没有显式指定资料库固定来源。
|
||||
- 根因:旧夹具用短时真实等待,无法固定超时与首段正文的顺序。资料库测试从旧导出路径取输入,并以单个文件是否存在作为跳过条件。
|
||||
- 修复:接收首段正文后触发真实回答时钟订阅的受控 deadline。保留截断、零扣点、单时钟和中止回执断言。资料库测试要求显式来源并比对 Git HEAD 或所有固定文件哈希,CLI 要求显式选择来源模式。
|
||||
- 验证:四个 CPU 负载进程下,原测试文件 20 遍有 4 遍失败,修订后 20 遍通过。固定来源的资料库测试通过,错误来源与缺失目录均失败。全量结果见同轮进度。
|
||||
- 防复发:检查实际 deadline 对象、正文已发出和查表次数。回归 CLI 缺少来源选项及跳过来源时禁止写清单。
|
||||
- 关联记录:#205、BUG-1051、BUG-1053、BUG-1142、BUG-1847。
|
||||
- 修复版本:`codex/flaky-tests-20261112`。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 回答时钟与资料库来源测试 · 2026-11-12
|
||||
|
||||
Gitea #205,BUG-1850。staging 基线 `017e650fdafac4a841487dbba4aca803c761dad8`。BUG-1849 已由并行的 #204 占用,沿用本单任务书指定的 BUG-1850。
|
||||
|
||||
## 回答时钟
|
||||
|
||||
- 原用例在查表和短文本分段中使用真实延迟。计算完成时答案时钟已经启动,查表、模型启动、Mastra 流处理及正文放行都消耗同一预算。负载增大时,300 ms 到点前可能还没有可交付正文。运行时此时返回 `empty_answer`,已有正文才记为 `answer_truncated`,两种结果均不扣点。这符合 BUG-1051 与 BUG-1053 的运行时契约。
|
||||
- Windows 以四个独立忙循环进程加负载,原文件串行跑 20 遍,4 遍失败且均为指定用例的 `empty_answer`。同文件另一条依赖工具阶段预算的用例在 20 遍中均通过。
|
||||
- 保留真实 Agent、时钟及查表。测试捕获首次答案 deadline,假模型在发送部分正文后等待,客户端收到首段正文才触发原 deadline。通过 signal 数量和身份核对查表未新建 deadline。
|
||||
|
||||
| 项目 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 回答时钟用例的夹具 | 40 ms 查表、每段 25 ms、真实 300 ms deadline | 客户端收到首段后触发受控 deadline | 将超时固定在正文已输出的边界,保留原四项断言。 |
|
||||
| 资料库导出校验条件 | 隐式读取旧目录,按一份资料文件是否存在决定跳过 | 仅在未配置环境变量时跳过;配置后比对 Git HEAD 或所有固定来源文件哈希 | 避免把缺失或不匹配的显式来源作为跳过处理。原有文件、哈希和摘录断言仍执行。 |
|
||||
| 资料库 CLI | 未指定来源时尝试默认目录 | 必须显式选 `--her-export` 或 `--skip-her-export`,两者互斥 | 区分固定来源验证与开发检查。 |
|
||||
|
||||
## 验证
|
||||
|
||||
- 修订后的同一前端测试文件在四个负载进程下连续跑 20 遍,15 项用例每遍均通过。结束后按各进程 PID 核对其均已退出。
|
||||
- Python 资料库定向:环境变量未设置时 39 项通过、2 项按预期跳过。指定与固定 Git 版本逐文件校验一致的纯导出时 41 项通过。显式指定错误 Git HEAD、文件哈希不一致或目录缺失时均以退出码 1 结束并给出「来源与 VERIFIED.json 固定的版本不一致」。
|
||||
- 资料库 CLI 未选来源选项时退出码 2;显式跳过来源和固定导出两种模式均验证 44 条资料。跳过来源仍无法写 `VERIFIED.json`。
|
||||
- 预检确认远端可见、Python 运行时与外部引擎适配器诊断可用。Windows 上碎片扫描与预检定向测试分别在 90 秒、45 秒超时,与此前同机预检记录一致。
|
||||
- 原任务基线上的首轮前端全量运行出现 Windows 内存不足,部分测试进程未启动;使用有界并发重跑。
|
||||
- 原任务基线的 Python quick 分支运行 1463 项通过、2 项跳过、3 项失败,另有 29 项子测试通过。三项 Kaala 失败在相同基线 `017e650fd` 独立复现。
|
||||
- staging 更新至 `56bb1bed9` 后,同步到任务分支。唯一冲突是 Bug 历史的并行追加,保留 #192 的 BUG-1851 原文并在末尾追加本单原定编号 BUG-1850。低并发全量复核以更新后的 staging 为对照。
|
||||
- 更新基线后按同一 `npm test` 文件列表和 Node TAP runner 复跑,每树 4 个并发。两边各 3652 项、0 项取消,3632 个顶层 TAP 名称及出现次数完全一致。staging 3624 项通过、28 项失败,任务分支 3625 项通过、27 项失败,两边退出码均为 1。唯一仅在基线失败的是本单修订的回答时钟用例。
|
||||
- 更新基线后的 Python quick 仍为 1463 项通过、2 项跳过、3 项失败,另有 29 项子测试通过。staging `56bb1bed9` 单跑复现同样的三个 `tests/test_upstream_technique_layers.py` Kaala 用例失败。
|
||||
- 更新基线后的 TypeScript 检查通过,ESLint 0 错误、62 条警告。
|
||||
- 清理构建缓存后,staging 与任务分支 Next.js 构建均通过,`/` 为静态页面。两边均引用 39 个资源,逐文件 gzip -9 的 JS 共 515860 字节、CSS 共 90886 字节。
|
||||
- 隐私检查 62 项通过。
|
||||
@@ -0,0 +1,94 @@
|
||||
# TASK:两个会误报的测试(2026-11-12)
|
||||
|
||||
- Gitea:#205
|
||||
- 分支:`codex/flaky-tests-20261112`(基于 staging `017e650fdafac4a841487dbba4aca803c761dad8`)
|
||||
- Bug 编号:BUG-1850。开工时核对 `docs/BUG_HISTORY.md` 末尾;若已被占用,顺延,并在 PROGRESS 写明。
|
||||
- 负责验收:Claude(jyotisha-de)
|
||||
|
||||
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
|
||||
|
||||
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
|
||||
|
||||
## 一、回答时钟测试靠真实计时,CI 负载高时会失败
|
||||
|
||||
测试:`frontend/tests/consult-evidence-lookup-20260927.test.ts:280`,
|
||||
「a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase」。
|
||||
|
||||
用例的写法:
|
||||
|
||||
- `answerMs: 300`;
|
||||
- 查表延迟 40 ms;
|
||||
- 回答分 6 段,每段延迟 25 ms;
|
||||
- 期望结果是 `run.failed:answer_truncated`。
|
||||
|
||||
实际情况:
|
||||
|
||||
- CI 运行 7208、7230 各失败过一次,单独重跑那一项就通过。
|
||||
- 本机在负载下得到的是 `empty_answer`,不是 `answer_truncated`。
|
||||
|
||||
要做的:
|
||||
|
||||
1. 先查清楚负载下为什么会变成 `empty_answer`。在 PROGRESS 写明,是测试的时间预算问题,还是产品代码在边界上真的有分支差异。
|
||||
- 如果是**产品**问题:停下来,选需拍板,不要改产品代码。
|
||||
2. 如果是测试的问题,把这个用例改成不依赖真实耗时:
|
||||
- 用注入的时钟或可控的 signal,让“回答阶段超时”在确定的那一步发生;
|
||||
- 保留四个断言的含义:结果是 `answer_truncated`;`charges` 为 0;回答时钟只启动一次;有 `compose-abort`。
|
||||
3. 用同样的方式检查同文件里其他依赖 `delayMs` 和预算的用例。只改那些在 4 倍 CPU 负载下会失败的,逐个列进 PROGRESS。
|
||||
4. 本机验证:开 4 个后台 `yes > /dev/null` 加负载(Windows 用等效做法),同文件连续跑 20 遍,0 失败。验证完结束这些负载进程,按 PID 结束,**不要按名字批量结束进程**。
|
||||
|
||||
## 二、预置资料库测试默认读旧导出
|
||||
|
||||
测试:`tests/test_preset_evidence_library.py::test_every_her_file_exists_sha_matches_and_quotes_are_verbatim`。
|
||||
|
||||
- 它默认读 `/workspace/up-run-r40`(`scripts/preset_evidence/library.py` 里的 `DEFAULT_HER_EXPORT`)。
|
||||
- 资料库现在钉的是她来源仓的 `1b33aff61d733213d9df67906ddf16473aafc9bb`,见 `references/preset_evidence_library/VERIFIED.json`。
|
||||
- 本机如果存在旧的 r40 导出,这个测试会因 `scripts/dasha_analyzer.py` 的哈希不同而失败。CI 上没有这个目录,测试会跳过。
|
||||
|
||||
要做的:
|
||||
|
||||
1. 去掉对 `/workspace/up-run-r40` 的默认值。只有设置了环境变量 `PRESET_EVIDENCE_HER_EXPORT` 时,才跑需要导出的测试;没设置就跳过,并在 skip 原因里写明。
|
||||
2. 设置了环境变量时,先核对这个目录和 `VERIFIED.json` 记录的来源是否一致。
|
||||
- 有 `.git` 的,比对 HEAD;
|
||||
- 没有的,比对 `VERIFIED.json` 里的文件哈希。
|
||||
- 不一致时,测试**失败**,并给出明确信息:“来源与 VERIFIED.json 固定的版本不一致”。不能跳过。
|
||||
3. CLI `python -m scripts.preset_evidence.library` 的 `--her-export` 改为必填,`--skip-her-export` 除外。帮助文字里写明要指向固定版本的来源仓。
|
||||
4. 同步更新 `SCHEMA.md` 里关于导出目录的说明。
|
||||
|
||||
## 要改的文件
|
||||
|
||||
- `frontend/tests/consult-evidence-lookup-20260927.test.ts`。如果需要注入时钟,测试辅助文件也可以改;**不改 `frontend/src`**,除非第一节查明是产品问题,那种情况选需拍板。
|
||||
- `scripts/preset_evidence/library.py`:只改默认路径和 CLI 参数。
|
||||
- `tests/test_preset_evidence_library.py`、`tests/test_preset_library_gates_20261112.py`:只改导出相关的跳过和核对逻辑。
|
||||
- `references/preset_evidence_library/SCHEMA.md`:只改导出目录的说明。
|
||||
- `docs/BUG_HISTORY.md`:只在末尾追加一条。
|
||||
- `docs/tasks/PROGRESS-flaky-tests-20261112.md`
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `frontend/src/**`
|
||||
- `frontend/src/lib/consult-preset-prompts.ts`
|
||||
- 资料库条目和 `VERIFIED.json`
|
||||
- 冻结文件
|
||||
|
||||
## 自测命令
|
||||
|
||||
```
|
||||
cd frontend && npm test
|
||||
python -m pytest -q tests/test_preset_evidence_library.py tests/test_preset_library_gates_20261112.py
|
||||
PRESET_EVIDENCE_HER_EXPORT=<固定版本来源目录> python -m pytest -q tests/test_preset_evidence_library.py
|
||||
python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
|
||||
```
|
||||
|
||||
## 进度记录
|
||||
|
||||
写在 `docs/tasks/PROGRESS-flaky-tests-20261112.md`,内容包括:
|
||||
|
||||
- `empty_answer` 的原因;
|
||||
- 改了哪些用例,加负载连续 20 遍的结果;
|
||||
- 资料库测试分别在三种情况下的表现:未设置环境变量、设置为一致的来源、设置为不一致的来源;
|
||||
- 测试结果,以及在 staging 上也复现的基线失败。
|
||||
|
||||
## 何时停下来选“需拍板”
|
||||
|
||||
- `empty_answer` 是产品代码的边界问题,不是测试的问题。
|
||||
- 要稳定这个测试,只能放宽断言。
|
||||
@@ -4866,6 +4866,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.personal-report-reading-part p { font-size: 17px; line-height: 1.85; }
|
||||
.personal-report-reading-part { scroll-margin-top: 80px; padding-top: var(--space-4); }
|
||||
.personal-report-reading-part + .personal-report-reading-part { margin-top: var(--space-5); }
|
||||
/* #192 (owner 2026-11-12, prototype option B): under each graded paragraph a
|
||||
small grey line with the grade and its meaning; a withheld section is one
|
||||
muted line in its place. */
|
||||
.personal-report-reading-paragraph { margin: 0 0 var(--space-5); }
|
||||
.personal-report-md-article .personal-report-reading-paragraph > p { margin-bottom: var(--space-1); }
|
||||
.personal-report-md-article .personal-report-reading-part p.personal-report-evidence-grade {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.personal-report-reading-withheld { color: var(--color-ink-tertiary); }
|
||||
.personal-report-reading-number {
|
||||
display: block;
|
||||
color: var(--color-action-strong);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
READING_SECTION_WITHHELD_TEXT,
|
||||
REPORT_READING_ANCHOR,
|
||||
REPORT_READING_BASIS_LABEL,
|
||||
REPORT_READING_HEADING,
|
||||
evidenceGradeTag,
|
||||
readingSectionView,
|
||||
type ReportReading,
|
||||
} from "@/lib/personal-report-reading-contract";
|
||||
import { readingPartAnchor } from "@/lib/report-toc-groups";
|
||||
@@ -13,6 +16,10 @@ import { readingPartAnchor } from "@/lib/report-toc-groups";
|
||||
*
|
||||
* Reader direction A (2026-11-07): a row of section chips under the heading,
|
||||
* and each section numbered and anchored so the grouped contents can land on it.
|
||||
*
|
||||
* #192 (owner 2026-11-12, prototype option B): under each graded paragraph one
|
||||
* small grey line gives the grade and its meaning; a section that failed the
|
||||
* post-check shows one line in its place, the other sections stay.
|
||||
*/
|
||||
export function ReportReadingSection({ reading }: { reading: ReportReading }) {
|
||||
return (
|
||||
@@ -24,15 +31,25 @@ export function ReportReadingSection({ reading }: { reading: ReportReading }) {
|
||||
<a href={`#${readingPartAnchor(section.id)}`} key={section.id}>{section.title}</a>
|
||||
))}
|
||||
</nav>
|
||||
{reading.sections.map((section, index) => (
|
||||
<div className="personal-report-reading-part" id={readingPartAnchor(section.id)} key={section.id}>
|
||||
<span className="personal-report-reading-number" aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
|
||||
<h3>{section.title}</h3>
|
||||
{section.paragraphs.map((paragraph, paragraphIndex) => (
|
||||
<p key={paragraphIndex}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{reading.sections.map((section, index) => {
|
||||
const view = readingSectionView(section);
|
||||
return (
|
||||
<div className="personal-report-reading-part" id={readingPartAnchor(section.id)} key={section.id}>
|
||||
<span className="personal-report-reading-number" aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
|
||||
<h3>{section.title}</h3>
|
||||
{view.status === "withheld" ? (
|
||||
<p className="personal-report-reading-withheld">{READING_SECTION_WITHHELD_TEXT}</p>
|
||||
) : view.paragraphs.map((paragraph, paragraphIndex) => (
|
||||
<div className="personal-report-reading-paragraph" key={paragraphIndex}>
|
||||
<p>{paragraph.text}</p>
|
||||
{paragraph.grade ? (
|
||||
<p className="personal-report-evidence-grade">{evidenceGradeTag(paragraph.grade)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<p className="personal-report-basis-label">{REPORT_READING_BASIS_LABEL}</p>
|
||||
</>
|
||||
|
||||
@@ -37,11 +37,39 @@ export const MIN_TOTAL_CHARS = 1_500;
|
||||
export const MAX_TOTAL_CHARS = 18_000;
|
||||
export const MAX_SECTION_PARAGRAPHS = 6;
|
||||
|
||||
/**
|
||||
* Claim types and grades of the evidence-grade contract (astrologer 2026-11-12,
|
||||
* her repo 8aaf6099 handoff/2026-11-12/preset_q3 §1.3–1.4, §5.2). The backend
|
||||
* (scripts/report_render/evidence_grade.py, `report_evidence_grade_v1`) fixes each
|
||||
* claim type's ceiling; ids here must equal its CLAIM_TYPES keys.
|
||||
*/
|
||||
export const EVIDENCE_GRADE_CONTRACT = "report_evidence_grade_v1";
|
||||
export const REPORT_CLAIM_TYPES = [
|
||||
"character_structure",
|
||||
"career_promise",
|
||||
"wealth_earning",
|
||||
"wealth_retention",
|
||||
"relationship_structure",
|
||||
"health_pressure",
|
||||
"health_obstruction",
|
||||
"timing_window",
|
||||
] as const;
|
||||
export type ReportClaimType = (typeof REPORT_CLAIM_TYPES)[number];
|
||||
export const EVIDENCE_GRADES = ["A", "B", "C", "D"] as const;
|
||||
export type EvidenceGrade = (typeof EVIDENCE_GRADES)[number];
|
||||
/** Sections whose every paragraph carries a claim type and a grade. */
|
||||
export const GRADED_SECTION_IDS: readonly ReportReadingSectionId[] = ["character", "career", "wealth", "love", "health", "now"];
|
||||
|
||||
/** What the model returns. Titles are ours, not the model's. */
|
||||
const draftParagraph = z.object({
|
||||
text: z.string().min(1),
|
||||
claim_type: z.enum(REPORT_CLAIM_TYPES).nullable(),
|
||||
grade: z.enum(EVIDENCE_GRADES).nullable(),
|
||||
}).strict();
|
||||
export const reportReadingDraftSchema = z.object({
|
||||
sections: z.array(z.object({
|
||||
id: z.enum(SECTION_IDS),
|
||||
paragraphs: z.array(z.string().min(1)).min(1).max(MAX_SECTION_PARAGRAPHS),
|
||||
paragraphs: z.array(draftParagraph).min(1).max(MAX_SECTION_PARAGRAPHS),
|
||||
}).strict()).length(REPORT_READING_SECTIONS.length),
|
||||
}).strict();
|
||||
export type ReportReadingDraft = z.infer<typeof reportReadingDraftSchema>;
|
||||
@@ -52,7 +80,32 @@ const storedSection = z.strictObject({
|
||||
title: z.string().min(1).max(12),
|
||||
paragraphs: z.array(z.string().min(1).max(MAX_PARAGRAPH_CHARS)).min(1).max(MAX_SECTION_PARAGRAPHS),
|
||||
});
|
||||
/** Version 1: the eight sections before 2026-11-07. Version 2 adds 「依据与边界」. */
|
||||
const storedParagraphV3 = z.strictObject({
|
||||
text: z.string().min(1).max(MAX_PARAGRAPH_CHARS),
|
||||
claimType: z.enum(REPORT_CLAIM_TYPES).optional(),
|
||||
grade: z.enum(EVIDENCE_GRADES).optional(),
|
||||
});
|
||||
/** A section that failed the post-check is kept as a visible placeholder (owner 2026-11-12). */
|
||||
const storedSectionV3 = z.union([
|
||||
z.strictObject({
|
||||
id: z.enum(SECTION_IDS),
|
||||
title: z.string().min(1).max(12),
|
||||
status: z.literal("ok"),
|
||||
paragraphs: z.array(storedParagraphV3).min(1).max(MAX_SECTION_PARAGRAPHS),
|
||||
}),
|
||||
z.strictObject({
|
||||
id: z.enum(SECTION_IDS),
|
||||
title: z.string().min(1).max(12),
|
||||
status: z.literal("withheld"),
|
||||
reason: z.string().regex(/^[a-z_]{1,64}$/),
|
||||
}),
|
||||
]);
|
||||
/** Which of the astrologer's files the reading was written with (her §7.3: actually_loaded, not a path). */
|
||||
const loadedFile = z.strictObject({
|
||||
path: z.string().min(1).max(200),
|
||||
sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
});
|
||||
/** Version 1: the eight sections before 2026-11-07. Version 2 adds 「依据与边界」. Version 3: graded paragraphs (2026-11-12). */
|
||||
export const reportReadingSchema = z.union([
|
||||
z.strictObject({
|
||||
version: z.literal(1),
|
||||
@@ -64,5 +117,46 @@ export const reportReadingSchema = z.union([
|
||||
language: z.literal("zh"),
|
||||
sections: z.array(storedSection).length(REPORT_READING_SECTIONS.length),
|
||||
}),
|
||||
z.strictObject({
|
||||
version: z.literal(3),
|
||||
language: z.literal("zh"),
|
||||
contract: z.literal(EVIDENCE_GRADE_CONTRACT),
|
||||
skill: z.strictObject({
|
||||
name: z.string().min(1).max(80),
|
||||
version: z.string().min(1).max(40),
|
||||
loaded: z.array(loadedFile).min(1).max(40),
|
||||
}),
|
||||
sections: z.array(storedSectionV3).length(REPORT_READING_SECTIONS.length),
|
||||
}),
|
||||
]);
|
||||
export type ReportReading = z.infer<typeof reportReadingSchema>;
|
||||
export type ReportReadingParagraph = Readonly<{ text: string; grade?: EvidenceGrade }>;
|
||||
|
||||
/** Shown in place of a section that did not pass the post-check (owner 2026-11-12). */
|
||||
export const READING_SECTION_WITHHELD_TEXT = "这一节没有通过数据核对,暂不显示。";
|
||||
export const EVIDENCE_GRADE_LABEL = "证据等级";
|
||||
/**
|
||||
* The line under a graded paragraph (owner 2026-11-12, prototype option B): the
|
||||
* grade and its meaning in one short line, no tap to open. Contract
|
||||
* report_evidence_grade_v1 (astrologer 2026-11-12 §5.2).
|
||||
*/
|
||||
export const EVIDENCE_GRADE_MEANING: Readonly<Record<EvidenceGrade, string>> = {
|
||||
A: "在明确的案例集里经过较强的独立验证",
|
||||
B: "有对得上的真实案例支持,几种独立方法方向一致",
|
||||
C: "有星盘依据,还没有用真实案例校准过",
|
||||
D: "关键依据缺失,暂不能判断",
|
||||
};
|
||||
|
||||
/** One reading section as the page and the export show it, for every stored version. */
|
||||
export function readingSectionView(section: ReportReading["sections"][number]):
|
||||
| Readonly<{ status: "ok"; paragraphs: readonly ReportReadingParagraph[] }>
|
||||
| Readonly<{ status: "withheld" }> {
|
||||
if (!("status" in section)) return { status: "ok", paragraphs: section.paragraphs.map((text) => ({ text })) };
|
||||
if (section.status === "withheld") return { status: "withheld" };
|
||||
return { status: "ok", paragraphs: section.paragraphs.map(({ text, grade }) => (grade ? { text, grade } : { text })) };
|
||||
}
|
||||
|
||||
/** 「证据等级 C:有星盘依据,还没有用真实案例校准过」 */
|
||||
export function evidenceGradeTag(grade: EvidenceGrade): string {
|
||||
return `${EVIDENCE_GRADE_LABEL} ${grade}:${EVIDENCE_GRADE_MEANING[grade]}`;
|
||||
}
|
||||
|
||||
@@ -8,13 +8,16 @@
|
||||
* the same leaves the chat card uses). It is optional: any failure, timeout or
|
||||
* rejected draft leaves the report without a reading, never failed.
|
||||
*/
|
||||
import { HOUSE_LIFE_AREA_RULE } from "./consultation-thinking-plan";
|
||||
import {
|
||||
EVIDENCE_GRADES,
|
||||
EVIDENCE_GRADE_CONTRACT,
|
||||
GRADED_SECTION_IDS,
|
||||
MAX_PARAGRAPH_CHARS,
|
||||
MAX_TOTAL_CHARS,
|
||||
MIN_TOTAL_CHARS,
|
||||
REPORT_READING_SECTIONS,
|
||||
reportReadingDraftSchema,
|
||||
type EvidenceGrade,
|
||||
type ReportReading,
|
||||
} from "./personal-report-reading-contract";
|
||||
|
||||
@@ -36,25 +39,171 @@ const BANNED_PHRASES = [
|
||||
"本仓", "检测器", "字段", "口径",
|
||||
// One name per thing, same as the chat (罗睺 / 计都).
|
||||
"北交点", "南交点", "克图", "凯图",
|
||||
// Web search and case calibration stay backstage (product 2026-11-08, BUG-1794); the page shows the grade line.
|
||||
"联网", "全网", "网络资料", "外部资料", "真实案例", "MEVG",
|
||||
];
|
||||
/** 「一定会」「必然会」 are fate claims; 「不一定会」 is the opposite and stays allowed. */
|
||||
const CERTAINTY = /(?<!不)(一定会|必然会)/;
|
||||
const DISCLAIMER = /仅供参考|不构成|请咨询专业|不能替代/g;
|
||||
/** 「没有关键阻塞」 and the like: allowed only when the claim's required layers are all available (her §6 P0). */
|
||||
const NO_BLOCKER = /(没有|无|不存在)(任何)?(关键)?(层)?(的)?阻塞/;
|
||||
const PLANETS_ZH = ["太阳", "月亮", "火星", "水星", "木星", "金星", "土星", "罗睺", "计都"] as const;
|
||||
/** Dignity words a draft may not invent (her §6 P1: D2 Exalted written as own sign). */
|
||||
const CHECKED_DIGNITIES = ["入旺", "入庙", "落陷", "根本三分"] as const;
|
||||
|
||||
// 原值:含 "latin"(段落里出现任何英文字母即退稿)
|
||||
// 新值:去掉 latin;技法名、分盘代号(D10、A10、UL)和 A/B/C 置信度是专业写法的一部分
|
||||
// 原因:产品 2026-11-07 占星师方案:报告完整运行 skill,按她的专业写法写
|
||||
/**
|
||||
* Why a draft or one of its sections was not used. Whole-draft reasons leave the
|
||||
* report without a reading; section reasons withhold that section only and the
|
||||
* page says so in its place (owner 2026-11-12).
|
||||
*/
|
||||
export type ReadingRejection =
|
||||
| "shape"
|
||||
| "field_name"
|
||||
| "banned_phrase"
|
||||
| "too_long"
|
||||
| "too_short"
|
||||
| "disclaimer";
|
||||
| "disclaimer"
|
||||
| "claim_type_missing"
|
||||
| "claim_type_section"
|
||||
| "grade_above_cap"
|
||||
| "false_no_blocker"
|
||||
| "dignity_mismatch"
|
||||
| "all_sections_withheld";
|
||||
|
||||
/** The draft becomes the stored reading only when every check passes. */
|
||||
export function acceptReportReadingDraft(value: unknown):
|
||||
| { ok: true; reading: ReportReading }
|
||||
type Claims = Readonly<Record<string, { cap?: unknown; sections?: unknown; required?: unknown }>>;
|
||||
|
||||
function claimsOf(facts: Record<string, unknown> | null): Claims {
|
||||
const grades = facts?.evidence_grades as { contract?: unknown; claims?: unknown } | undefined;
|
||||
return grades?.contract === EVIDENCE_GRADE_CONTRACT && grades.claims && typeof grades.claims === "object"
|
||||
? grades.claims as Claims
|
||||
: {};
|
||||
}
|
||||
|
||||
function gradeAtOrBelow(grade: EvidenceGrade, cap: unknown): boolean {
|
||||
const capIndex = EVIDENCE_GRADES.indexOf(cap as EvidenceGrade);
|
||||
return capIndex !== -1 && EVIDENCE_GRADES.indexOf(grade) >= capIndex;
|
||||
}
|
||||
|
||||
/** Each planet's dignity words anywhere in the facts (D1, D9 and every domain varga). */
|
||||
function dignitiesOf(facts: Record<string, unknown> | null): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
const zh: Record<string, string> = {
|
||||
Sun: "太阳", Moon: "月亮", Mars: "火星", Mercury: "水星", Jupiter: "木星", Venus: "金星", Saturn: "土星", Rahu: "罗睺", Ketu: "计都",
|
||||
};
|
||||
const add = (planet: string, word: unknown) => {
|
||||
const name = zh[planet];
|
||||
if (!name || typeof word !== "string" || !word) return;
|
||||
if (!out.has(name)) out.set(name, new Set());
|
||||
out.get(name)!.add(word);
|
||||
};
|
||||
const planets = (facts?.planets ?? {}) as Record<string, Record<string, unknown>>;
|
||||
for (const [planet, row] of Object.entries(planets)) {
|
||||
if (!row || typeof row !== "object") continue;
|
||||
add(planet, row.d1_dignity_zh);
|
||||
add(planet, row.d9_dignity_zh);
|
||||
}
|
||||
const domains = (facts?.domains ?? {}) as Record<string, { varga_charts?: Record<string, unknown> }>;
|
||||
for (const domain of Object.values(domains)) {
|
||||
for (const chart of Object.values(domain?.varga_charts ?? {})) {
|
||||
if (!chart || typeof chart !== "object") continue;
|
||||
for (const [planet, row] of Object.entries(chart as Record<string, unknown>)) {
|
||||
if (row && typeof row === "object") add(planet, (row as Record<string, unknown>).dignity_zh);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A planet name followed closely by a dignity word the facts never give that planet. */
|
||||
function dignityMismatch(text: string, dignities: Map<string, Set<string>>): boolean {
|
||||
if (dignities.size === 0) return false;
|
||||
for (const planet of PLANETS_ZH) {
|
||||
let from = 0;
|
||||
for (let at = text.indexOf(planet, from); at !== -1; at = text.indexOf(planet, from)) {
|
||||
from = at + planet.length;
|
||||
const window = text.slice(from, from + 14);
|
||||
const nextPlanet = Math.min(...PLANETS_ZH.map((other) => {
|
||||
const index = window.indexOf(other);
|
||||
return index === -1 ? window.length : index;
|
||||
}));
|
||||
const clause = window.slice(0, nextPlanet).split(/[,。;;,]/)[0] ?? "";
|
||||
for (const word of CHECKED_DIGNITIES) {
|
||||
// 「落陷取消」 (cancelled debilitation) in the facts allows 「落陷」 in the text.
|
||||
const known = [...(dignities.get(planet) ?? [])];
|
||||
if (clause.includes(word) && !known.some((given) => given.includes(word))) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type DraftSection = Readonly<{
|
||||
id: string;
|
||||
paragraphs: ReadonlyArray<{ text: string; claim_type: string | null; grade: EvidenceGrade | null }>;
|
||||
}>;
|
||||
|
||||
type CheckedSection = ReportReading extends infer R
|
||||
? R extends { version: 3; sections: ReadonlyArray<infer S> } ? S : never
|
||||
: never;
|
||||
|
||||
function checkSection(
|
||||
section: DraftSection,
|
||||
title: string,
|
||||
claims: Claims,
|
||||
dignities: Map<string, Set<string>>,
|
||||
): { section: CheckedSection; chars: number } {
|
||||
const withheld = (reason: ReadingRejection) => ({
|
||||
section: { id: section.id, title, status: "withheld" as const, reason },
|
||||
chars: 0,
|
||||
}) as { section: CheckedSection; chars: number };
|
||||
const graded = (GRADED_SECTION_IDS as readonly string[]).includes(section.id);
|
||||
const anyClaimMissing = Object.values(claims).some((claim) => claim?.cap === "D");
|
||||
let disclaimers = 0;
|
||||
let chars = 0;
|
||||
const paragraphs: { text: string; claimType?: string; grade?: EvidenceGrade }[] = [];
|
||||
for (const raw of section.paragraphs) {
|
||||
const text = raw.text.trim();
|
||||
if (!text) return withheld("too_short");
|
||||
if (text.length > MAX_PARAGRAPH_CHARS) return withheld("too_long");
|
||||
if (/[_={}<>#*|`]/.test(text)) return withheld("field_name");
|
||||
if (BANNED_PHRASES.some((phrase) => text.includes(phrase)) || CERTAINTY.test(text)) return withheld("banned_phrase");
|
||||
if (dignityMismatch(text, dignities)) return withheld("dignity_mismatch");
|
||||
disclaimers += text.match(DISCLAIMER)?.length ?? 0;
|
||||
chars += text.length;
|
||||
if (!graded) {
|
||||
if (NO_BLOCKER.test(text) && anyClaimMissing) return withheld("false_no_blocker");
|
||||
paragraphs.push({ text });
|
||||
continue;
|
||||
}
|
||||
if (!raw.claim_type || !raw.grade) return withheld("claim_type_missing");
|
||||
const claim = claims[raw.claim_type];
|
||||
if (!claim) return withheld("claim_type_missing");
|
||||
if (!Array.isArray(claim.sections) || !claim.sections.includes(section.id)) return withheld("claim_type_section");
|
||||
if (!gradeAtOrBelow(raw.grade, claim.cap)) return withheld("grade_above_cap");
|
||||
if (NO_BLOCKER.test(text) && claim.cap === "D") return withheld("false_no_blocker");
|
||||
paragraphs.push({ text, claimType: raw.claim_type, grade: raw.grade });
|
||||
}
|
||||
if (disclaimers > 1) return withheld("disclaimer");
|
||||
return {
|
||||
section: { id: section.id, title, status: "ok", paragraphs } as CheckedSection,
|
||||
chars,
|
||||
};
|
||||
}
|
||||
|
||||
export type ReadingSkillMeta = Readonly<{
|
||||
name: string;
|
||||
version: string;
|
||||
loaded: ReadonlyArray<Readonly<{ path: string; sha256: string }>>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The draft becomes the stored reading only when its shape holds and at least one
|
||||
* section passes. Each section is checked on its own: a section that breaks a rule
|
||||
* is withheld and shown as withheld, the others stay (owner 2026-11-12). Grades are
|
||||
* checked against the backend's per-claim ceilings in the facts (astrologer Q3-2).
|
||||
*/
|
||||
export function acceptReportReadingDraft(value: unknown, facts: Record<string, unknown> | null, skill: ReadingSkillMeta):
|
||||
| { ok: true; reading: ReportReading; withheld: number }
|
||||
| { ok: false; reason: ReadingRejection } {
|
||||
const parsed = reportReadingDraftSchema.safeParse(value);
|
||||
if (!parsed.success) return { ok: false, reason: "shape" };
|
||||
@@ -62,33 +211,24 @@ export function acceptReportReadingDraft(value: unknown):
|
||||
if (sections.some((section, index) => section.id !== REPORT_READING_SECTIONS[index]!.id)) {
|
||||
return { ok: false, reason: "shape" };
|
||||
}
|
||||
let total = 0;
|
||||
for (const section of sections) {
|
||||
let disclaimers = 0;
|
||||
for (const raw of section.paragraphs) {
|
||||
const paragraph = raw.trim();
|
||||
if (!paragraph) return { ok: false, reason: "shape" };
|
||||
if (paragraph.length > MAX_PARAGRAPH_CHARS) return { ok: false, reason: "too_long" };
|
||||
if (/[_={}<>#*|`]/.test(paragraph)) return { ok: false, reason: "field_name" };
|
||||
if (BANNED_PHRASES.some((phrase) => paragraph.includes(phrase)) || CERTAINTY.test(paragraph)) return { ok: false, reason: "banned_phrase" };
|
||||
disclaimers += paragraph.match(DISCLAIMER)?.length ?? 0;
|
||||
total += paragraph.length;
|
||||
}
|
||||
if (disclaimers > 1) return { ok: false, reason: "disclaimer" };
|
||||
}
|
||||
const claims = claimsOf(facts);
|
||||
const dignities = dignitiesOf(facts);
|
||||
const checked = sections.map((section, index) => checkSection(section, REPORT_READING_SECTIONS[index]!.title, claims, dignities));
|
||||
const withheld = checked.filter(({ section }) => section.status === "withheld").length;
|
||||
if (withheld === checked.length) return { ok: false, reason: "all_sections_withheld" };
|
||||
const total = checked.reduce((sum, { chars }) => sum + chars, 0);
|
||||
if (total < MIN_TOTAL_CHARS) return { ok: false, reason: "too_short" };
|
||||
if (total > MAX_TOTAL_CHARS) return { ok: false, reason: "too_long" };
|
||||
return {
|
||||
ok: true,
|
||||
withheld,
|
||||
reading: {
|
||||
version: 2,
|
||||
version: 3,
|
||||
language: "zh",
|
||||
sections: sections.map((section, index) => ({
|
||||
id: section.id,
|
||||
title: REPORT_READING_SECTIONS[index]!.title,
|
||||
paragraphs: section.paragraphs.map((paragraph) => paragraph.trim()),
|
||||
})),
|
||||
},
|
||||
contract: EVIDENCE_GRADE_CONTRACT,
|
||||
skill: { name: skill.name, version: skill.version, loaded: skill.loaded.map((file) => ({ ...file })) },
|
||||
sections: checked.map(({ section }) => section),
|
||||
} as ReportReading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,31 +246,6 @@ export function acceptReadingFacts(value: unknown): Record<string, unknown> | nu
|
||||
|
||||
export const READING_FACTS_SCHEMA = "report-reading-facts-v3";
|
||||
|
||||
/**
|
||||
* 产品 2026-11-07 占星师方案(item 5、8、10):报告解读按她的 skill 写。
|
||||
* 证据在前、判断在后;每个领域标 A/B/C;功能吉凶与自然吉凶冲突要说出来;
|
||||
* 应期双轨(Vimshottari + Narayana,带起止日期)再叠行运;阻塞、参数敏感照实标。
|
||||
* 旧版(禁术语、禁分数、最多两条依据、时间最多三件事)是普通聊天的白话规则,不再用于报告。
|
||||
*/
|
||||
export const REPORT_READING_SYSTEM = [
|
||||
"你在给一份个人星盘报告写开头的「解读」,按印度占星专业解盘的写法。用户消息里的盘面事实是引擎已经算好的全部材料,你只用这些,不编数据,不另起算法;事实里没有的层就当没有,不补算。",
|
||||
"写法:证据在前,判断在后。每段先列这一段用到的盘面依据(技法名、星座、宫位、宫主、度数、力量数值、大运起止日期),再写这些依据合起来说明什么,最后落到工作、钱、感情、家里、身体作息上的具体样子。依据用原值写,例如「土星为 9、10 宫主,功能吉星(Yogakaraka),Shadbala 7.2 Rupa(门槛 5)」,不把数值改写成强弱一个字。",
|
||||
"分领域必须调取的层:事业看 D1 第 10 宫与宫主、D10、A10、AmK、AK、Karakamsha;财富看 2、11 宫与宫主、D2、D11、A2、A11(财富来自职业时再看 D10、A10),并分开说「能不能挣到」和「能不能留下」;感情看 7 宫与宫主、D9、UL、A7、DK、金星;健康看 1、6、8、12 宫,D6、D8,出生分钟确认后才用 D30。事实里某层标为阻塞或缺失,就写明这一层没有参与。",
|
||||
"功能吉凶:先写上升对应的功能吉星、功能凶星、Yogakaraka。用到某颗星时,自然吉凶和功能吉凶不一致(事实里的 natural_vs_functional_conflicts),要写出冲突来源(它主管哪几宫),并据此降低这一条的置信度。罗睺、计都不单独定功能吉凶。",
|
||||
"应期双轨:「现在这几年」必须同时写 Vimshottari(大运、小运、细运,起止日期)和 Narayana(当前大运与小运的星座、宫主、起止日期,日期按出生日加年龄换算),再写接下来的两段。两套方向一致就说一致;方向不同,两面都写,不挑一套。然后叠行运:木星、土星、罗睺、计都落在哪个星座,从上升、月亮各算第几宫,是否逆行,有没有双重行运,Sade Sati 是否进行中;再写年盘(Muntha、年主星、Mudda 当前段)。只说到月份,不说具体哪一天。",
|
||||
"置信度:每个领域节(事业、财富、感情、健康、现在这几年)最后一句写「置信度:A/B/C」并说明理由。A = 已有用户经历核验(本报告没有用户经历,所以不给 A);B = 本命承诺、相关分盘、双轨大运至少三个维度同向,且没有关键层阻塞;C = 只有单一维度、维度之间冲突、关键层阻塞或参数敏感,或出生时间没有确认到分钟。",
|
||||
"状态标签照实写中文:「阻塞」(缺输入或不能运行)、「参数敏感」(换岁差、交点、宫制或方法版本结果会变)、「待用户经历核验」(算出来了但没有现实事件对照)。KP(Placidus 宫头、KP 岁差)与整宫制结果不同时,写成方法差异,不算矛盾。事实里的 method_differences 至少挑与本节相关的写进去。Yoga 只能写成「候选」,同时写出它的成立条件;没有核对取消条件和现实事件之前,不写成已经成立的结果。",
|
||||
HOUSE_LIFE_AREA_RULE,
|
||||
// 原值:依据与边界里要求模型写「外部资料核验与真实案例校准在本报告中没有执行,所以全篇置信度最高为 B」
|
||||
// 新值:改为只说「没有用户经历可对照」,置信度封顶不变
|
||||
// 原因:产品负责人 2026-11-08:用户可见文字不出现联网/外部核验字样,后台保留
|
||||
"九节固定,按顺序:总评(这张盘最突出的两三点,各带依据)、性格、事业、财富、感情、健康、现在这几年(双轨应期加行运和年盘)、几条建议(两到四条,和盘面直接相关的具体做法,每条注明依据)、依据与边界(列出:功能吉星/凶星与冲突;本报告实际用到的技法;阻塞和参数敏感的层;本报告没有用户经历可对照,所以全篇置信度最高为 B;岁差、交点、宫制参数)。每节一到六段,每段不超过六百五十字,全篇三千到一万五千字。",
|
||||
"术语:可以写技法名和分盘代号(Vimshottari、Narayana、D10、A10、UL、AK、AmK、DK、KP、Shadbala、Ashtakavarga、Sade Sati),第一次出现时在括号里用半句中文说明它看什么。行星、星座用中文;交点只叫罗睺、计都。不写字段名、下划线、花括号、代码,不写「本仓」「检测器」「字段」「口径」这类内部说法。",
|
||||
"红线:不写病名、诊断和身体部位的疾病;健康只说哪段时间要多休息、哪方面的作息要留意。不给投资指令,不点名股票、基金、币。不写「注定」「先苦后甜」「宿命」「一定会」。不预测死亡、必然离婚、怀孕与孩子性别、某天必然发财。婚姻、生死不下确定结论。每节最多一句提醒,不逐句免责。",
|
||||
"出生时间如果不是确认过的,不把上升、宫位、D10 之后的细分盘说成定论,置信度最高写 C,也不写「校正」「确认」。",
|
||||
"只输出 JSON:{\"sections\":[{\"id\":\"overview\",\"paragraphs\":[\"……\"]}, …]},九节的 id 依次是 overview、character、career、wealth、love、health、now、advice、basis。",
|
||||
].join("\n");
|
||||
|
||||
export function reportReadingUserMessage(facts: Record<string, unknown>, birthTimeConfirmed: boolean): string {
|
||||
return [
|
||||
birthTimeConfirmed ? "出生时间:已确认。" : "出生时间:用户填报,没有确认到分钟。",
|
||||
@@ -139,11 +254,17 @@ export function reportReadingUserMessage(facts: Record<string, unknown>, birthTi
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export type ReportReadingWriter = (input: Readonly<{
|
||||
/** The system prompt and the files it was built from (astrologer §7.3: record what was actually loaded). */
|
||||
export type ReportReadingPrompt = Readonly<{ system: string; skill: ReadingSkillMeta }>;
|
||||
|
||||
export type ReportReadingWriter = ((input: Readonly<{
|
||||
system: string;
|
||||
user: string;
|
||||
signal: AbortSignal;
|
||||
}>) => Promise<{ body: unknown; inputTokens: number; outputTokens: number }>;
|
||||
}>) => Promise<{ body: unknown; inputTokens: number; outputTokens: number }>) & Readonly<{
|
||||
/** Built once by the server from her live SKILL.md, references and the product contract block. */
|
||||
prompt: ReportReadingPrompt;
|
||||
}>;
|
||||
|
||||
export type ReportReadingOutcome = Readonly<{
|
||||
reading: ReportReading | null;
|
||||
@@ -177,7 +298,11 @@ function timedSignal(parent: AbortSignal | undefined, timeoutMs: number) {
|
||||
};
|
||||
}
|
||||
|
||||
/** One attempt plus one retry. Never throws, except when the job itself is aborted. */
|
||||
/**
|
||||
* One attempt plus one retry. A draft with withheld sections is kept, and the
|
||||
* retry is used only if it withholds fewer. Never throws, except when the job
|
||||
* itself is aborted.
|
||||
*/
|
||||
export async function writeReportReading(input: Readonly<{
|
||||
facts: unknown;
|
||||
birthTimeConfirmed: boolean;
|
||||
@@ -189,22 +314,28 @@ export async function writeReportReading(input: Readonly<{
|
||||
const facts = acceptReadingFacts(input.facts);
|
||||
if (!facts) return { reading: null, unavailable: "facts_unavailable", inputTokens: 0, outputTokens: 0 };
|
||||
const user = reportReadingUserMessage(facts, input.birthTimeConfirmed);
|
||||
const { system, skill } = input.write.prompt;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let unavailable = "model_failed";
|
||||
let best: { reading: ReportReading; withheld: number } | null = null;
|
||||
for (let attempt = 0; attempt < (input.attempts ?? 2); attempt += 1) {
|
||||
input.signal?.throwIfAborted();
|
||||
const timed = timedSignal(input.signal, input.timeoutMs ?? REPORT_READING_TIMEOUT_MS);
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
input.write({ system: REPORT_READING_SYSTEM, user, signal: timed.signal }),
|
||||
input.write({ system, user, signal: timed.signal }),
|
||||
timed.lost,
|
||||
]);
|
||||
inputTokens += Math.max(0, result.inputTokens);
|
||||
outputTokens += Math.max(0, result.outputTokens);
|
||||
const accepted = acceptReportReadingDraft(result.body);
|
||||
if (accepted.ok) return { reading: accepted.reading, inputTokens, outputTokens };
|
||||
unavailable = `rejected_${accepted.reason}`;
|
||||
const accepted = acceptReportReadingDraft(result.body, facts, skill);
|
||||
if (accepted.ok) {
|
||||
if (!best || accepted.withheld < best.withheld) best = { reading: accepted.reading, withheld: accepted.withheld };
|
||||
if (accepted.withheld === 0) break;
|
||||
} else {
|
||||
unavailable = `rejected_${accepted.reason}`;
|
||||
}
|
||||
} catch {
|
||||
if (input.signal?.aborted) throw input.signal.reason ?? new Error("aborted");
|
||||
unavailable = timed.signal.aborted ? "model_timeout" : "model_failed";
|
||||
@@ -212,5 +343,7 @@ export async function writeReportReading(input: Readonly<{
|
||||
timed.dispose();
|
||||
}
|
||||
}
|
||||
return { reading: null, unavailable, inputTokens, outputTokens };
|
||||
return best
|
||||
? { reading: best.reading, inputTokens, outputTokens }
|
||||
: { reading: null, unavailable, inputTokens, outputTokens };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ export type ProductContractBlock =
|
||||
| "chat-method-boundaries"
|
||||
| "natal-instructions"
|
||||
| "mevg-blocked-policy"
|
||||
| "shared-reading";
|
||||
| "shared-reading"
|
||||
| "report-reading";
|
||||
|
||||
let cached: string | null = null;
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@ import { factCellText, factStatusLabel } from "./report-fact-table-display";
|
||||
import type { ReportLanguage } from "./report-language";
|
||||
import { SIGN_ZH, type SignName } from "./chart-view-labels";
|
||||
import { projectOrdinaryReportMarkdown } from "./report-public-projection";
|
||||
import { REPORT_READING_BASIS_LABEL, REPORT_READING_HEADING, type ReportReading } from "./personal-report-reading-contract";
|
||||
import {
|
||||
READING_SECTION_WITHHELD_TEXT,
|
||||
REPORT_READING_BASIS_LABEL,
|
||||
REPORT_READING_HEADING,
|
||||
evidenceGradeTag,
|
||||
readingSectionView,
|
||||
type ReportReading,
|
||||
} from "./personal-report-reading-contract";
|
||||
|
||||
export type ReportExportBlock = Readonly<{
|
||||
id: string;
|
||||
@@ -82,7 +89,13 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
|
||||
if (language === "zh" && reading?.language === "zh") {
|
||||
const text = [
|
||||
`## ${REPORT_READING_HEADING}`,
|
||||
...reading.sections.flatMap(section => [`### ${section.title}`, ...section.paragraphs]),
|
||||
...reading.sections.flatMap(section => {
|
||||
const view = readingSectionView(section);
|
||||
// #192: the export carries the same grade tags and withheld line as the page.
|
||||
return [`### ${section.title}`, ...(view.status === "withheld"
|
||||
? [READING_SECTION_WITHHELD_TEXT]
|
||||
: view.paragraphs.map(paragraph => paragraph.grade ? `${paragraph.text}\n\n${evidenceGradeTag(paragraph.grade)}` : paragraph.text))];
|
||||
}),
|
||||
REPORT_READING_BASIS_LABEL,
|
||||
].join("\n\n");
|
||||
add("body:reading", "body", REPORT_READING_HEADING, text);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { z } from "zod";
|
||||
import type { ResolvedLanguageModel } from "./model.ts";
|
||||
import { reportReadingDraftSchema } from "@/lib/personal-report-reading-contract";
|
||||
import type { ReportReadingWriter } from "@/lib/personal-report-reading";
|
||||
import { reportReadingPrompt } from "./report-reading-skill.ts";
|
||||
|
||||
/** One structured call for the report reading (issue #113 R1). Same pattern as the synastry section writer. */
|
||||
export function createReportReadingWriter(model: ResolvedLanguageModel): ReportReadingWriter {
|
||||
@@ -13,7 +14,7 @@ export function createReportReadingWriter(model: ResolvedLanguageModel): ReportR
|
||||
model: model.model,
|
||||
instructions: "Follow the system message. Return only the requested JSON.",
|
||||
});
|
||||
return async function writeReading(input) {
|
||||
const writeReading = async (input: Parameters<ReportReadingWriter>[0]) => {
|
||||
const result = await agent.generate(
|
||||
[
|
||||
{ role: "system", content: input.system },
|
||||
@@ -31,4 +32,6 @@ export function createReportReadingWriter(model: ResolvedLanguageModel): ReportR
|
||||
outputTokens: Math.max(0, usage?.outputTokens ?? 0),
|
||||
};
|
||||
};
|
||||
// #192: her SKILL.md + routed references + the product report-reading block.
|
||||
return Object.assign(writeReading, { prompt: reportReadingPrompt() });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { productContractBlock, PRODUCT_RUNTIME_CONTRACT_FILE } from "../lib/product-runtime-contract.ts";
|
||||
import { REPORT_CLAIM_TYPES } from "../lib/personal-report-reading-contract.ts";
|
||||
import type { ReportReadingPrompt } from "../lib/personal-report-reading.ts";
|
||||
import { jyotishSkillPackage } from "./skill-binding.ts";
|
||||
|
||||
/**
|
||||
* #192 (owner 2026-11-11 decision 2; astrologer 2026-11-12, her repo 8aaf6099
|
||||
* handoff/2026-11-12/preset_q3 §7.3): the report reading is written with her
|
||||
* skill, like the chat. The chat binds SKILL.md and opens the references it
|
||||
* routes to with skill_read; the reading is one structured call with no tools,
|
||||
* so the references its nine sections route to are quoted in full instead.
|
||||
*
|
||||
* Which files: her SKILL.md; the router whose §3–§7 mandatory modules are the
|
||||
* claim dependencies in scripts/report_render/evidence_grade.py; the event
|
||||
* judgment skeleton (Promise → Activation → Manifestation → Timing) and the
|
||||
* career / wealth / marriage judgment files; the precision methodology; the
|
||||
* functional role profile; the interpretation status contract. Then the
|
||||
* product's report-reading block. Every file is hashed into the stored reading.
|
||||
*/
|
||||
export const REPORT_READING_REFERENCES = [
|
||||
"references/strict-workflow-router.md",
|
||||
"references/event_judgment_skeleton.md",
|
||||
"references/event_judgment_career.md",
|
||||
"references/event_judgment_wealth.md",
|
||||
"references/event_judgment_marriage.md",
|
||||
"references/precision-reading-methodology.md",
|
||||
"references/functional-role-profile.md",
|
||||
"references/interpretation_status_contract.md",
|
||||
] as const;
|
||||
|
||||
export const REPORT_READING_SKILL_MARKER = `<jyotish-skill name="${jyotishSkillPackage.name}" use="report-reading">`;
|
||||
|
||||
function read(path: string): { text: string; sha256: string } {
|
||||
const bytes = readFileSync(resolve(jyotishSkillPackage.resolvedPath, path));
|
||||
return { text: bytes.toString("utf8").replace(/\r\n/g, "\n"), sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||
}
|
||||
|
||||
function skillBody(raw: string): string {
|
||||
const frontmatter = /^---\n[\s\S]*?\n---\n/.exec(raw);
|
||||
return (frontmatter ? raw.slice(frontmatter[0].length) : raw).trim();
|
||||
}
|
||||
|
||||
let cached: ReportReadingPrompt | null = null;
|
||||
|
||||
/** Built once per process from the live skill tree. */
|
||||
export function reportReadingPrompt(): ReportReadingPrompt {
|
||||
if (cached) return cached;
|
||||
const skill = read("SKILL.md");
|
||||
const body = skillBody(skill.text);
|
||||
if (!body) throw new Error(`Skill ${jyotishSkillPackage.name} has no method body for the report reading`);
|
||||
const references = REPORT_READING_REFERENCES.map((path) => ({ path, ...read(path) }));
|
||||
const contract = read(PRODUCT_RUNTIME_CONTRACT_FILE);
|
||||
const rules = productContractBlock("report-reading", { CLAIM_TYPES: REPORT_CLAIM_TYPES.join("、") });
|
||||
const system = [
|
||||
`The jyotish-vedic-astrology skill is loaded for a written report. Its full SKILL.md (the astrologer's upstream skill, verbatim) is quoted below, then the references this report routes to, verbatim. Follow its method, routing and truth boundaries. There are no tools in this call: everything you may use is in this message and the chart facts.`,
|
||||
REPORT_READING_SKILL_MARKER,
|
||||
body,
|
||||
...references.map((file) => `<reference path="${file.path}">\n${file.text.trim()}\n</reference>`),
|
||||
"</jyotish-skill>",
|
||||
rules,
|
||||
].join("\n\n");
|
||||
cached = {
|
||||
system,
|
||||
skill: {
|
||||
name: jyotishSkillPackage.name,
|
||||
version: jyotishSkillPackage.version,
|
||||
loaded: [
|
||||
{ path: "SKILL.md", sha256: skill.sha256 },
|
||||
...references.map(({ path, sha256 }) => ({ path, sha256 })),
|
||||
{ path: PRODUCT_RUNTIME_CONTRACT_FILE, sha256: contract.sha256 },
|
||||
],
|
||||
},
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
@@ -92,7 +92,8 @@ const lookupCall = (section: string, text?: string): Turn => ({
|
||||
finish: "tool-calls",
|
||||
});
|
||||
|
||||
const run = (turns: Turn[], options: { toolPhaseMs?: number; answerMs?: number } = {}) => runNatalAgent(turns, { workflow, ...options });
|
||||
const run = (turns: Turn[], options: Omit<Parameters<typeof runNatalAgent>[1], "workflow"> = {}) =>
|
||||
runNatalAgent(turns, { workflow, ...options });
|
||||
|
||||
function toolResultText(prompt: unknown[] | undefined, toolName: string) {
|
||||
return JSON.stringify((prompt ?? []).filter((message) => {
|
||||
@@ -277,16 +278,40 @@ test("a step that only opens like the released text is not mistaken for a restar
|
||||
assert.equal(result.state.steps.some((step) => step.name === "answer-restart-dropped"), false);
|
||||
});
|
||||
|
||||
test("a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase", async () => {
|
||||
test("a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase", { timeout: 20_000 }, async (t) => {
|
||||
const answerMs = 300;
|
||||
const realTimeout = AbortSignal.timeout.bind(AbortSignal);
|
||||
const deadlines: AbortController[] = [];
|
||||
t.mock.method(AbortSignal, "timeout", (ms: number) => {
|
||||
if (ms !== answerMs) return realTimeout(ms);
|
||||
const deadline = new AbortController();
|
||||
deadlines.push(deadline);
|
||||
return deadline.signal;
|
||||
});
|
||||
let releaseFinish!: () => void;
|
||||
const waitBeforeFinish = new Promise<void>((resolve) => { releaseFinish = resolve; });
|
||||
const head = ANSWER.slice(0, ANSWER.indexOf(`## ${REPORT_HEADING.timing}`));
|
||||
const result = await run([
|
||||
calcCall(),
|
||||
{ ...lookupCall("varga:D60"), delayMs: 40 },
|
||||
{ parts: pieces(ANSWER, 6), finish: "stop", delayMs: 25 },
|
||||
], { answerMs: 300 });
|
||||
lookupCall("varga:D60"),
|
||||
{ parts: pieces(head), finish: "stop", waitBeforeFinish },
|
||||
], {
|
||||
answerMs,
|
||||
onAnswerDelta: (text) => {
|
||||
if (text.trim()) {
|
||||
deadlines[0]?.abort(new DOMException("Answer deadline elapsed", "TimeoutError"));
|
||||
releaseFinish();
|
||||
}
|
||||
},
|
||||
});
|
||||
assert.deepEqual(result.terminal.map((event) => `${event.type}:${event.code ?? ""}`), ["run.failed:answer_truncated"]);
|
||||
assert.equal(result.charges, 0);
|
||||
assert.equal(result.answerSignals.length, 1, "the answer clock started once");
|
||||
assert.ok(result.state.steps.some((step) => step.kind === "abort" && step.name === "compose-abort"));
|
||||
assert.equal(result.state.evidenceLookupCallCount, 1);
|
||||
assert.equal(deadlines.length, 1, "the lookup did not create another answer deadline");
|
||||
assert.equal(result.answerSignals[0], deadlines[0].signal);
|
||||
assert.ok(result.answer.trim(), "the deadline elapsed after the client received text");
|
||||
});
|
||||
|
||||
test("a lookup in the answer phase is not cut by the tool phase's deadline", async () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from "../src/mastra/consultation-tools.ts";
|
||||
|
||||
export type Part = { text?: string; tool?: { name: string; input: Record<string, unknown> } };
|
||||
export type Turn = { parts: Part[]; finish: string; delayMs?: number };
|
||||
export type Turn = { parts: Part[]; finish: string; delayMs?: number; waitBeforeFinish?: Promise<void> };
|
||||
|
||||
/** A LanguageModelV2 that plays `turns` in order and records every prompt. */
|
||||
export function scriptedModel(turns: Turn[]) {
|
||||
@@ -85,6 +85,13 @@ export function scriptedModel(turns: Turn[]) {
|
||||
}
|
||||
}
|
||||
if (textOpen) controller.enqueue({ type: "text-end", id: `t${call}` });
|
||||
if (turn.waitBeforeFinish) {
|
||||
await turn.waitBeforeFinish;
|
||||
if (signal?.aborted) {
|
||||
controller.error(signal.reason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
controller.enqueue({
|
||||
type: "finish",
|
||||
finishReason: turn.finish,
|
||||
@@ -133,6 +140,8 @@ export async function runNatalAgent(
|
||||
question?: string;
|
||||
toolPhaseMs?: number;
|
||||
answerMs?: number;
|
||||
/** Observe text as the client receives it, before the model finishes. */
|
||||
onAnswerDelta?: (text: string) => void;
|
||||
/** The bound subject's server chart; defaults to the public AA chart without a gender. */
|
||||
serverChart?: Record<string, unknown>;
|
||||
/** The shape instruction the user turn carries; defaults to the first-turn natal shape. */
|
||||
@@ -212,8 +221,19 @@ export async function runNatalAgent(
|
||||
onError: (error) => { errored = error; },
|
||||
});
|
||||
const events: Array<{ type: string; code?: string; text?: string; label?: string; phase?: string; receipt?: unknown }> = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event as never));
|
||||
parser.finish(await response.text());
|
||||
const parser = createNdjsonParser((event) => {
|
||||
events.push(event as never);
|
||||
if (event.type === "answer.delta") options.onAnswerDelta?.(event.text);
|
||||
});
|
||||
if (options.onAnswerDelta) {
|
||||
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
|
||||
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
||||
parser.push(chunk.value);
|
||||
}
|
||||
parser.finish();
|
||||
} else {
|
||||
parser.finish(await response.text());
|
||||
}
|
||||
const answer = events.filter((event) => event.type === "answer.delta").map((event) => event.text ?? "").join("");
|
||||
const terminal = events.filter((event) => event.type === "run.completed" || event.type === "run.failed");
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,8 @@ import { REPORT_READING_BASIS_LABEL, REPORT_READING_HEADING, reportReadingSchema
|
||||
import { buildReportExportBlocks, joinExportBlocks, presetSelection } from "../src/lib/report-export-blocks";
|
||||
|
||||
const fixture = (name: string) => readFileSync(new URL(`./fixtures/report-export-20261111/${name}`, import.meta.url), "utf8");
|
||||
const reading = reportReadingSchema.parse(JSON.parse(fixture("reading-sample.json")));
|
||||
// The fixture is a stored version-2 reading (plain paragraphs); #192 keeps it readable.
|
||||
const reading = reportReadingSchema.parse(JSON.parse(fixture("reading-sample.json"))) as Extract<ReturnType<typeof reportReadingSchema.parse>, { version: 2 }>;
|
||||
const synthetic = "# 个人报告\n\n这段摘要随报告一起导出。\n\n## 引擎章节\n\n这是计算资料。\n";
|
||||
|
||||
for (const [name, markdown] of [["stored reading", synthetic], ["public report opening", fixture("jobs-head-zh.md")]]) {
|
||||
|
||||
@@ -11,7 +11,14 @@ import test from "node:test";
|
||||
|
||||
import { buildAppendix, rangeView } from "../src/lib/annual-report-generate.ts";
|
||||
import type { AnnualBirthTime } from "../src/lib/annual-report-shared.ts";
|
||||
import { REPORT_READING_SYSTEM } from "../src/lib/personal-report-reading.ts";
|
||||
import { readFileSync as readContract } from "node:fs";
|
||||
|
||||
// #192: the report writer's product rules moved to references/product_runtime_contract.md (block report-reading).
|
||||
const contractSource = readContract(new URL("../../references/product_runtime_contract.md", import.meta.url), "utf8");
|
||||
const REPORT_READING_RULES = contractSource.slice(
|
||||
contractSource.indexOf("<!-- product-block:report-reading -->"),
|
||||
contractSource.indexOf("<!-- /product-block:report-reading -->"),
|
||||
);
|
||||
import { buildTechnicalBasis } from "../src/lib/synastry-full-technical.ts";
|
||||
|
||||
const BANNED = ["联网", "外部资料", "外部核验", "MEVG", "真实案例", "Real Case", "Global Web"];
|
||||
@@ -73,7 +80,10 @@ test("synastry technical basis never shows web-verification wording", () => {
|
||||
});
|
||||
|
||||
test("personal report writer prompt never asks for web-verification wording", () => {
|
||||
assertNoWebWording(REPORT_READING_SYSTEM, "个人报告写作提示词");
|
||||
// 置信度封顶仍在,只是理由改成「没有用户经历可对照」。
|
||||
assert.match(REPORT_READING_SYSTEM, /置信度最高为 B/);
|
||||
assertNoWebWording(REPORT_READING_RULES.replace(/^<!--.*-->$/gm, ""), "个人报告写作提示词");
|
||||
// 原值:assert.match(REPORT_READING_SYSTEM, /置信度最高为 B/)(「没有用户经历可对照,全篇置信度最高为 B」)
|
||||
// 新值:不再有全篇封顶句,等级由后端按每条结论给上限(report_evidence_grade_v1)
|
||||
// 原因:占星师 2026-11-12(8aaf6099 §5.2):「全报告不采用“没有用户经历所以最高B”」
|
||||
assert.doesNotMatch(REPORT_READING_RULES, /置信度最高为 B/);
|
||||
assert.match(REPORT_READING_RULES, /evidence_grades/);
|
||||
});
|
||||
|
||||
@@ -17,9 +17,9 @@ import { hashLongformMarkdown } from "../src/lib/personal-report-longform-append
|
||||
import { validateLongformSnapshot } from "../src/lib/personal-report-longform-snapshot";
|
||||
import {
|
||||
REPORT_READING_SECTIONS,
|
||||
REPORT_READING_SYSTEM,
|
||||
acceptReportReadingDraft,
|
||||
writeReportReading,
|
||||
type ReadingSkillMeta,
|
||||
type ReportReadingWriter,
|
||||
} from "../src/lib/personal-report-reading";
|
||||
import { resolveReportRead } from "../src/lib/personal-report-route-core";
|
||||
@@ -34,18 +34,35 @@ const zhReader = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-
|
||||
|
||||
const PARAGRAPH = "你的上升落在金牛座,因为管你自己的 1 宫(你自己、身体、给人的第一印象)由金星主管,金星在盘上力气中等,你给人的感觉是稳、慢热、讲究分寸。做事先看清楚再动手,一旦认定就不太轻易改主意,这份耐心是你最可靠的本钱。熟悉的人会觉得你靠得住,刚认识的人可能要多处一阵才知道你心里热。";
|
||||
|
||||
function draft(overrides: Partial<Record<string, string[]>> = {}) {
|
||||
// #192 原值:每段是字符串;新值:每段是 { text, claim_type, grade },六个领域节必须带结论类型与等级
|
||||
// 原因:占星师 2026-11-12(8aaf6099 §1.3–1.4):等级逐结论标,上限由后端给
|
||||
const SECTION_CLAIM: Record<string, string | null> = {
|
||||
overview: null, character: "character_structure", career: "career_promise", wealth: "wealth_earning",
|
||||
love: "relationship_structure", health: "health_pressure", now: "timing_window", advice: null, basis: null,
|
||||
};
|
||||
|
||||
function draft(overrides: Partial<Record<string, string[]>> = {}, grade = "C") {
|
||||
return {
|
||||
sections: REPORT_READING_SECTIONS.map((section) => ({
|
||||
id: section.id,
|
||||
// 原值:[PARAGRAPH](一段约 150 字,八节合计过 900 字下限)
|
||||
// 新值:[PARAGRAPH, PARAGRAPH](九节合计过 1500 字下限)
|
||||
// 原因:产品 2026-11-07 占星师方案:报告完整运行 skill,证据在前的专业写法篇幅更长
|
||||
paragraphs: overrides[section.id] ?? [PARAGRAPH, PARAGRAPH],
|
||||
paragraphs: (overrides[section.id] ?? [PARAGRAPH, PARAGRAPH]).map((text) => ({
|
||||
text,
|
||||
claim_type: SECTION_CLAIM[section.id] ?? null,
|
||||
grade: SECTION_CLAIM[section.id] ? grade : null,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const SKILL: ReadingSkillMeta = { name: "jyotish-vedic-astrology", version: "6.9.15", loaded: [{ path: "SKILL.md", sha256: "a".repeat(64) }] };
|
||||
|
||||
function claim(cap: string, sections: string[]) {
|
||||
return { cap, sections, required: [], checked: [], must_disclose: [], parameter_sensitive: [] };
|
||||
}
|
||||
|
||||
// 原值:report-reading-facts-v2
|
||||
// 新值:report-reading-facts-v3
|
||||
// 原因:按 #201 事实包版本更新写作和 worker 的测试输入。
|
||||
@@ -57,22 +74,43 @@ const FACTS = {
|
||||
planets: { Sun: { d1_sign: "Pisces", d1_house: 11 } },
|
||||
domains: { career: { varga: "D10", houses: { "10": { lord: "Saturn" } } } },
|
||||
timing: { md: "Rahu", mahadasha: { lord: "Rahu", start: "2023-07-08", end: "2041-07-08" } },
|
||||
evidence_grades: {
|
||||
contract: "report_evidence_grade_v1",
|
||||
claims: {
|
||||
character_structure: claim("C", ["overview", "character"]),
|
||||
career_promise: claim("C", ["overview", "career"]),
|
||||
wealth_earning: claim("C", ["overview", "wealth"]),
|
||||
wealth_retention: claim("C", ["overview", "wealth"]),
|
||||
relationship_structure: claim("C", ["overview", "love"]),
|
||||
health_pressure: claim("C", ["overview", "health"]),
|
||||
health_obstruction: claim("D", ["health"]),
|
||||
timing_window: claim("C", ["overview", "career", "wealth", "love", "health", "now"]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const accept = (value: unknown) => acceptReportReadingDraft(value, FACTS, SKILL);
|
||||
function withheldIds(value: unknown) {
|
||||
const accepted = accept(value);
|
||||
assert.ok(accepted.ok, JSON.stringify(accepted));
|
||||
return accepted.reading.sections.flatMap((section) => "status" in section && section.status === "withheld" ? [`${section.id}:${section.reason}`] : []);
|
||||
}
|
||||
|
||||
// 原值:八节,最后一节「几条建议」,存为 version 1
|
||||
// 新值:九节,加「依据与边界」,存为 version 2
|
||||
// 原因:产品 2026-11-07 占星师方案:依据、置信度与阻塞项单列(interpretation_status_contract 的报告顺序)
|
||||
test("a clean nine-part draft becomes the stored reading with our titles", () => {
|
||||
const accepted = acceptReportReadingDraft(draft());
|
||||
const accepted = accept(draft());
|
||||
assert.ok(accepted.ok);
|
||||
assert.equal(accepted.reading.version, 2);
|
||||
// #192 原值:version 2;新值:version 3(逐段等级、逐节状态、加载清单)
|
||||
assert.equal(accepted.reading.version, 3);
|
||||
assert.equal(accepted.withheld, 0);
|
||||
assert.deepEqual(accepted.reading.sections.map((section) => section.title), ["总评", "性格", "事业", "财富", "感情", "健康", "现在这几年", "几条建议", "依据与边界"]);
|
||||
assert.equal(accepted.reading.language, "zh");
|
||||
});
|
||||
|
||||
test("「不一定会」 is not a fate claim and is accepted", () => {
|
||||
const accepted = acceptReportReadingDraft(draft({ love: [`${PARAGRAPH}这不一定会马上发生。`] }));
|
||||
assert.equal(accepted.ok, true);
|
||||
assert.deepEqual(withheldIds(draft({ love: [`${PARAGRAPH}这不一定会马上发生。`] })), []);
|
||||
});
|
||||
|
||||
// 原值:段落里出现英文字母即以 latin 退稿
|
||||
@@ -80,68 +118,115 @@ test("「不一定会」 is not a fate claim and is accepted", () => {
|
||||
// 原因:产品 2026-11-07 占星师方案:按她的 skill 专业写法(证据在前、A/B/C 置信度)
|
||||
test("technique names, varga codes and A/B/C confidence are accepted", () => {
|
||||
const professional = `土星为 9、10 宫主,功能吉星(Yogakaraka),Shadbala 7.2 Rupa;D10 第 10 宫有木星,A10 落狮子座;Vimshottari 罗睺大运至 2041-07-08,Narayana 双鱼座大运 2026-04-07 起。置信度:B。`;
|
||||
const accepted = acceptReportReadingDraft(draft({ career: [professional, PARAGRAPH] }));
|
||||
assert.equal(accepted.ok, true);
|
||||
const snake = acceptReportReadingDraft(draft({ career: [`${PARAGRAPH}parameter_sensitive`] }));
|
||||
assert.equal(!snake.ok && snake.reason, "field_name");
|
||||
assert.deepEqual(withheldIds(draft({ career: [professional, PARAGRAPH] })), []);
|
||||
// #192 原值:整份退稿 field_name;新值:只有这一节不显示(产品 2026-11-12)
|
||||
assert.deepEqual(withheldIds(draft({ career: [`${PARAGRAPH}parameter_sensitive`] })), ["career:field_name"]);
|
||||
});
|
||||
|
||||
test("drafts with field names, fate, disease, investment or stacked disclaimers are rejected", () => {
|
||||
const cases: Array<[string, unknown]> = [
|
||||
["field_name", draft({ career: [`${PARAGRAPH}mars_house=2`.replace(/[a-z]/g, "")] })],
|
||||
["banned_phrase", draft({ love: [`${PARAGRAPH}你注定晚婚。`] })],
|
||||
["banned_phrase", draft({ now: [`${PARAGRAPH}这几年先苦后甜。`] })],
|
||||
["banned_phrase", draft({ health: [`${PARAGRAPH}要小心糖尿病。`] })],
|
||||
["banned_phrase", draft({ wealth: [`${PARAGRAPH}适合炒股。`] })],
|
||||
["banned_phrase", draft({ overview: [`${PARAGRAPH}北交点在 10 宫。`] })],
|
||||
["banned_phrase", draft({ overview: [`${PARAGRAPH}本仓功能吉凶层这样判。`] })],
|
||||
["banned_phrase", draft({ love: [`${PARAGRAPH}明年一定会结婚。`] })],
|
||||
["disclaimer", draft({ health: [`${PARAGRAPH}仅供参考。`, `${PARAGRAPH}不构成医疗意见。`] })],
|
||||
// #192 原值:任一段违规即整份退稿;新值:只把违规的那一节换成「这一节没有通过数据核对,暂不显示。」,
|
||||
// 结构错误和全篇字数越界仍整份退稿。原因:产品负责人 2026-11-12 选「该节显示一行说明」;占星师 §6 P2「不悄悄删除解读」。
|
||||
test("a section with field names, fate, disease, investment or stacked disclaimers is withheld on its own", () => {
|
||||
const sections: Array<[string, unknown]> = [
|
||||
["career:field_name", draft({ career: [`${PARAGRAPH}mars_house=2`.replace(/[a-z]/g, "")] })],
|
||||
["love:banned_phrase", draft({ love: [`${PARAGRAPH}你注定晚婚。`] })],
|
||||
["now:banned_phrase", draft({ now: [`${PARAGRAPH}这几年先苦后甜。`] })],
|
||||
["health:banned_phrase", draft({ health: [`${PARAGRAPH}要小心糖尿病。`] })],
|
||||
["wealth:banned_phrase", draft({ wealth: [`${PARAGRAPH}适合炒股。`] })],
|
||||
["overview:banned_phrase", draft({ overview: [`${PARAGRAPH}北交点在 10 宫。`] })],
|
||||
["overview:banned_phrase", draft({ overview: [`${PARAGRAPH}本仓功能吉凶层这样判。`] })],
|
||||
["love:banned_phrase", draft({ love: [`${PARAGRAPH}明年一定会结婚。`] })],
|
||||
// #192 eval 2026-11-12: the draft wrote that web and case calibration were not done (BUG-1794: backstage only).
|
||||
["basis:banned_phrase", draft({ basis: [`${PARAGRAPH}全球网络资料采集与真实案例校准在本次都未完成。`] })],
|
||||
["health:disclaimer", draft({ health: [`${PARAGRAPH}仅供参考。`, `${PARAGRAPH}不构成医疗意见。`] })],
|
||||
// 原值:PARAGRAPH.repeat(5)(690 字,超过旧上限 420);新值:repeat(6)(828 字,超过新上限 700)
|
||||
// 原因:产品 2026-11-07 占星师方案:段落上限随专业写法放宽
|
||||
["career:too_long", draft({ career: [PARAGRAPH.repeat(6)] })],
|
||||
];
|
||||
for (const [expected, value] of sections) assert.deepEqual(withheldIds(value), [expected]);
|
||||
const whole: Array<[string, unknown]> = [
|
||||
["shape", { sections: draft().sections.slice(0, 8) }],
|
||||
["shape", { sections: [...draft().sections].reverse() }],
|
||||
["too_short", draft(Object.fromEntries(REPORT_READING_SECTIONS.map((section) => [section.id, ["很短的一句。"]])))],
|
||||
// 原值:PARAGRAPH.repeat(5)(690 字,超过旧上限 420);新值:repeat(6)(828 字,超过新上限 700)
|
||||
// 原因:产品 2026-11-07 占星师方案:段落上限随专业写法放宽
|
||||
["too_long", draft({ career: [PARAGRAPH.repeat(6)] })],
|
||||
["too_long", draft(Object.fromEntries(REPORT_READING_SECTIONS.map((section) => [section.id, Array(6).fill(PARAGRAPH.repeat(4))])))],
|
||||
["all_sections_withheld", draft(Object.fromEntries(REPORT_READING_SECTIONS.map((section) => [section.id, [`${PARAGRAPH}你注定晚婚。`]])))],
|
||||
];
|
||||
for (const [reason, value] of cases) {
|
||||
const accepted = acceptReportReadingDraft(value);
|
||||
assert.equal(accepted.ok, false, reason);
|
||||
for (const [reason, value] of whole) {
|
||||
const accepted = accept(value);
|
||||
assert.equal(!accepted.ok && accepted.reason, reason, JSON.stringify(value).slice(0, 120));
|
||||
}
|
||||
});
|
||||
|
||||
// 原值:要求 C1 白话规则(「因为……」、「讲时间时最多说三件」)
|
||||
// 新值:要求她的 skill 写法:证据在前、领域分盘组合、功能吉凶冲突、双轨应期带日期、行运、A/B/C、状态标签
|
||||
// 原因:产品 2026-11-07 占星师方案:报告完整运行 skill,按专业写法
|
||||
test("the prompt carries the astrologer's professional rules and the red lines", () => {
|
||||
// #192:占星师 2026-11-12(8aaf6099 §5.2、§6):等级不得高于后端上限;声称无阻塞要与依赖一致;尊贵度不得自改。
|
||||
test("grades above the backend ceiling, false no-blocker claims and invented dignities withhold the section", () => {
|
||||
// Six graded sections withheld leave the three others; give those enough text to clear the 1,500-character floor.
|
||||
const long = { overview: Array(6).fill(PARAGRAPH), advice: Array(6).fill(PARAGRAPH), basis: Array(6).fill(PARAGRAPH) };
|
||||
assert.deepEqual(withheldIds(draft(long, "B")), [
|
||||
"character:grade_above_cap", "career:grade_above_cap", "wealth:grade_above_cap",
|
||||
"love:grade_above_cap", "health:grade_above_cap", "now:grade_above_cap",
|
||||
]);
|
||||
const missingType = draft();
|
||||
(missingType.sections[2]!.paragraphs[0] as { claim_type: string | null }).claim_type = null;
|
||||
assert.deepEqual(withheldIds(missingType), ["career:claim_type_missing"]);
|
||||
const wrongSection = draft();
|
||||
(wrongSection.sections[2]!.paragraphs[0] as { claim_type: string | null }).claim_type = "relationship_structure";
|
||||
assert.deepEqual(withheldIds(wrongSection), ["career:claim_type_section"]);
|
||||
const obstruction = draft();
|
||||
Object.assign(obstruction.sections[5]!.paragraphs[0]!, { claim_type: "health_obstruction", grade: "C" });
|
||||
assert.deepEqual(withheldIds(obstruction), ["health:grade_above_cap"]);
|
||||
Object.assign(obstruction.sections[5]!.paragraphs[0]!, { grade: "D", text: `${PARAGRAPH}这里没有关键阻塞。` });
|
||||
assert.deepEqual(withheldIds(obstruction), ["health:false_no_blocker"]);
|
||||
assert.deepEqual(withheldIds(draft({ basis: [`${PARAGRAPH}本报告无关键层阻塞。`] })), ["basis:false_no_blocker"]);
|
||||
const dignityFacts = { ...FACTS, domains: { wealth: { varga_charts: { D2: { Jupiter: { sign: "Cancer", dignity: "Exalted", dignity_zh: "入旺" } } } } } };
|
||||
const own = acceptReportReadingDraft(draft({ wealth: [`${PARAGRAPH}D2 木星在巨蟹入庙。`, PARAGRAPH] }), dignityFacts, SKILL);
|
||||
assert.ok(own.ok && own.reading.sections.some((section) => "status" in section && section.status === "withheld" && section.reason === "dignity_mismatch"));
|
||||
const exalted = acceptReportReadingDraft(draft({ wealth: [`${PARAGRAPH}D2 木星在巨蟹入旺。`, PARAGRAPH] }), dignityFacts, SKILL);
|
||||
assert.ok(exalted.ok && exalted.withheld === 0);
|
||||
// A cancelled debilitation in the facts (「落陷取消」) allows writing 「落陷」 (DeepSeek eval 2026-11-12, public chart B).
|
||||
const cancelledFacts = { ...FACTS, planets: { Jupiter: { d1_dignity_zh: "落陷取消" } } };
|
||||
const cancelled = acceptReportReadingDraft(draft({ overview: [`${PARAGRAPH}木星在第一宫摩羯落陷但成立落陷取消。`, PARAGRAPH] }), cancelledFacts, SKILL);
|
||||
assert.ok(cancelled.ok && cancelled.withheld === 0);
|
||||
});
|
||||
|
||||
// #192 原值:REPORT_READING_SYSTEM(TypeScript 里的产品提示,九节 + 节尾「置信度:A/B/C」+「全篇最高 B」)
|
||||
// 新值:她的 SKILL.md 与路由 references 原文,加 references/product_runtime_contract.md 的 report-reading 块
|
||||
// 原因:产品负责人 2026-11-11 决定 2(报告按她的 skill 写);占星师 2026-11-12 §5、§7.3
|
||||
const CONTRACT = readFileSync(new URL("../../references/product_runtime_contract.md", import.meta.url), "utf8");
|
||||
const RULES = CONTRACT.slice(CONTRACT.indexOf("<!-- product-block:report-reading -->"), CONTRACT.indexOf("<!-- /product-block:report-reading -->"));
|
||||
test("the report rules follow her 2026-11-12 contract and keep the red lines", () => {
|
||||
for (const rule of [
|
||||
"证据在前,判断在后", "D2、D11、A2、A11", "D10、A10、AmK、AK", "D9、UL、A7、DK", "能不能挣到", "能不能留下",
|
||||
"自然吉凶和功能吉凶不一致", "Vimshottari(大运、小运、细运,起止日期)", "Narayana(当前大运与小运", "两面都写",
|
||||
"木星、土星、罗睺、计都", "置信度:A/B/C", "「阻塞」", "「参数敏感」", "「待用户经历核验」", "候选",
|
||||
"宫位第一次出现时", "不写病名", "不给投资指令", "「注定」「先苦后甜」", "罗睺、计都", "只输出 JSON", "basis",
|
||||
"current_advice_allowed", "本命承诺 → 激活 → 落地 → 应期", "能不能挣到", "能不能留下", "心动、成对还是法律婚",
|
||||
"不从 6、8、12 宫直接推出", "observation_date", "一般建议", "evidence_grades.claims", "must_disclose",
|
||||
"没有关键阻塞", "本身不是冲突", "checked_houses", "dignity_zh", "不写病名", "不给投资指令",
|
||||
"「注定」「先苦后甜」", "罗睺、计都", "只输出 JSON", "{{CLAIM_TYPES}}",
|
||||
]) {
|
||||
assert.ok(REPORT_READING_SYSTEM.includes(rule), rule);
|
||||
assert.ok(RULES.includes(rule), rule);
|
||||
}
|
||||
for (const gone of ["讲时间时最多说三件", "力量只说强、中、弱", "不写任何英文字母"]) {
|
||||
assert.equal(REPORT_READING_SYSTEM.includes(gone), false, gone);
|
||||
for (const gone of ["置信度最高为 B", "置信度:A/B/C", "Yoga 只能写成「候选」"]) {
|
||||
assert.equal(RULES.includes(gone), false, gone);
|
||||
}
|
||||
const loader = readFileSync(new URL("../src/mastra/report-reading-skill.ts", import.meta.url), "utf8");
|
||||
for (const file of ["SKILL.md", "references/strict-workflow-router.md", "references/event_judgment_skeleton.md", "references/event_judgment_career.md"]) {
|
||||
assert.ok(loader.includes(file), file);
|
||||
}
|
||||
});
|
||||
|
||||
const PROMPT = { system: "system", skill: SKILL };
|
||||
function writer(bodies: unknown[], calls: string[] = []): ReportReadingWriter {
|
||||
return async (input) => {
|
||||
return Object.assign(async (input: Parameters<ReportReadingWriter>[0]) => {
|
||||
calls.push(input.user);
|
||||
const body = bodies.shift();
|
||||
if (body instanceof Error) throw body;
|
||||
return { body, inputTokens: 1000, outputTokens: 500 };
|
||||
};
|
||||
}, { prompt: PROMPT });
|
||||
}
|
||||
|
||||
test("one retry after a rejected draft; tokens of both attempts are counted", async () => {
|
||||
// #192 原值:第一稿被退才重试;新值:第一稿有不显示的节也重试,取不显示节更少的一稿
|
||||
test("one retry after a draft with a withheld section; tokens of both attempts are counted", async () => {
|
||||
const calls: string[] = [];
|
||||
const outcome = await writeReportReading({ facts: FACTS, birthTimeConfirmed: false, write: writer([draft({ love: ["你注定晚婚。"] }), draft()], calls) });
|
||||
assert.ok(outcome.reading);
|
||||
assert.ok(outcome.reading && outcome.reading.version === 3);
|
||||
assert.ok(outcome.reading.sections.every((section) => "status" in section && section.status === "ok"));
|
||||
assert.deepEqual(outcome.reading.skill, SKILL);
|
||||
assert.equal(outcome.inputTokens, 2000);
|
||||
assert.equal(outcome.outputTokens, 1000);
|
||||
assert.equal(calls.length, 2);
|
||||
@@ -158,18 +243,21 @@ test("no facts, a failing model or a hung model all leave the report without a r
|
||||
assert.equal(failed.reading, null);
|
||||
assert.equal(failed.unavailable, "model_failed");
|
||||
// A writer that ignores its abort signal still loses to the timeout.
|
||||
const hung = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, timeoutMs: 20, write: () => new Promise(() => undefined) });
|
||||
const hung = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, timeoutMs: 20, write: Object.assign(() => new Promise<never>(() => undefined), { prompt: PROMPT }) });
|
||||
assert.equal(hung.reading, null);
|
||||
assert.equal(hung.unavailable, "model_timeout");
|
||||
const rejected = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([draft({ health: ["要小心糖尿病。"] }), draft({ health: ["要小心糖尿病。"] })]) });
|
||||
assert.equal(rejected.unavailable, "rejected_banned_phrase");
|
||||
// #192 原值:两稿都含禁用词即整份不可用(rejected_banned_phrase);新值:只有那一节不显示,结构错误才整份不可用
|
||||
const partial = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([draft({ health: ["要小心糖尿病。"] }), draft({ health: ["要小心糖尿病。"] })]) });
|
||||
assert.ok(partial.reading?.sections.some((section) => section.id === "health" && "status" in section && section.status === "withheld"));
|
||||
const rejected = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([{ sections: [] }, { sections: [] }]) });
|
||||
assert.equal(rejected.unavailable, "rejected_shape");
|
||||
});
|
||||
|
||||
test("a cancelled job still cancels; the reading never swallows the abort", async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = writeReportReading({
|
||||
facts: FACTS, birthTimeConfirmed: true, signal: controller.signal,
|
||||
write: ({ signal }) => new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))),
|
||||
write: Object.assign(({ signal }: Parameters<ReportReadingWriter>[0]) => new Promise<never>((_, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))), { prompt: PROMPT }),
|
||||
});
|
||||
controller.abort(new Error("job_cancelled"));
|
||||
await assert.rejects(pending, /job_cancelled/);
|
||||
@@ -195,13 +283,16 @@ function snapshot(extra: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
test("snapshots: older ones read without a reading; a stored reading must be well formed", () => {
|
||||
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
|
||||
const reading = (accept(draft()) as { reading: unknown }).reading;
|
||||
assert.equal(validateLongformSnapshot(snapshot(), identity).version, 2);
|
||||
const stored = validateLongformSnapshot(snapshot({ reading }), identity);
|
||||
// 原值:8 节;新值:9 节,且 2026-11-07 前存的 version 1 八节解读仍能读出
|
||||
// 原因:产品 2026-11-07 占星师方案:加「依据与边界」一节
|
||||
assert.equal(stored.version === 2 && stored.reading?.sections.length, 9);
|
||||
const v1 = { version: 1, language: "zh", sections: (reading as { sections: Array<{ id: string; title: string; paragraphs: string[] }> }).sections.slice(0, 8) };
|
||||
// #192:v1、v2 存的是字符串段落,照常能读
|
||||
const plain = REPORT_READING_SECTIONS.map((section) => ({ id: section.id, title: section.title, paragraphs: [PARAGRAPH] }));
|
||||
const v1 = { version: 1, language: "zh", sections: plain.slice(0, 8) };
|
||||
assert.equal(validateLongformSnapshot(snapshot({ reading: { version: 2, language: "zh", sections: plain } }), identity).version, 2);
|
||||
const older = validateLongformSnapshot(snapshot({ reading: v1 }), identity);
|
||||
assert.equal(older.version === 2 && older.reading?.sections.length, 8);
|
||||
assert.throws(() => validateLongformSnapshot(snapshot({ reading: { ...v1, version: 2 } }), identity));
|
||||
@@ -232,7 +323,7 @@ async function read(loadLongformReading?: Parameters<typeof resolveReportRead>[0
|
||||
}
|
||||
|
||||
test("the read API returns the reading when there is one and never fails the report over it", async () => {
|
||||
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
|
||||
const reading = (accept(draft()) as { reading: unknown }).reading;
|
||||
const ready = await read(async () => reading as never);
|
||||
assert.equal(ready.status, 200);
|
||||
assert.deepEqual(ready.body.reading, reading);
|
||||
@@ -245,7 +336,7 @@ test("the read API returns the reading when there is one and never fails the rep
|
||||
});
|
||||
|
||||
test("the page keeps a reading only in the stored shape and shows it before the engine report in Chinese only", () => {
|
||||
const reading = (acceptReportReadingDraft(draft()) as { reading: { sections: unknown[] } }).reading;
|
||||
const reading = (accept(draft()) as { reading: { sections: unknown[] } }).reading;
|
||||
const envelope = (extra: Record<string, unknown>) => classifyReportEnvelope(200, {
|
||||
report: { id: identity.reportId, status: "ready", createdAt: "2026-10-27T00:00:00.000Z" },
|
||||
longformMarkdown: zhReader.markdown, ...extra,
|
||||
@@ -269,6 +360,13 @@ test("the page keeps a reading only in the stored shape and shows it before the
|
||||
assert.ok(zh.indexOf('id="report-reading-overview"') > readingAt, "each reading section is an anchor");
|
||||
assert.ok(readingAt > zh.indexOf("印度占星星盘报告") && readingAt < basisAt && basisAt < engineAt, zh.slice(0, 400));
|
||||
for (const title of ["总评", "现在这几年", "几条建议", "依据与边界"]) assert.ok(zh.includes(`<h3>${title}</h3>`), title);
|
||||
// #192(产品 2026-11-12 原型方案 B):领域节每段下面一行灰字,写等级和含义;总评等不分级的节没有
|
||||
assert.ok(zh.includes("证据等级 C:有星盘依据,还没有用真实案例校准过"), "grade line");
|
||||
assert.match(zh, /<p class="personal-report-evidence-grade">证据等级 C:/, "a plain line, no tap to open");
|
||||
const withheld = (accept(draft({ love: [`${PARAGRAPH}你注定晚婚。`] })) as { reading: unknown }).reading;
|
||||
const shown = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} reading={withheld as never} language="zh" />);
|
||||
assert.ok(shown.includes("这一节没有通过数据核对,暂不显示。"));
|
||||
assert.ok(shown.includes("<h3>感情</h3>"));
|
||||
const en = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} reading={reading as never} language="en" />);
|
||||
assert.equal(en.includes("report-reading"), false);
|
||||
assert.equal(en.includes("计算依据"), false);
|
||||
@@ -321,10 +419,10 @@ for (const mode of ["reading", "model-down", "no-writer"]) {
|
||||
async upsert(row) { rows.set(table, structuredClone(row)); return { error: null }; },
|
||||
}; }};
|
||||
const report = { id: "123e4567-e89b-12d3-a456-426614174000", userId: "123e4567-e89b-12d3-a456-426614174001", requestId: "123e4567-e89b-12d3-a456-426614174002", reportType: "personal_full", presentationMode: "default", depth: "standard", requestedThemes: ["career"], skillName: "jyotish-vedic-astrology", skillVersion: "6.9.14", skillSourceCommit: null, skillSnapshotSha256: "ab".repeat(32) };
|
||||
const writeReading = mode === "no-writer" ? undefined : async () => {
|
||||
const writeReading = mode === "no-writer" ? undefined : Object.assign(async () => {
|
||||
if (mode === "model-down") throw new Error("model down");
|
||||
return { body: draft, inputTokens: 1200, outputTokens: 800 };
|
||||
};
|
||||
}, { prompt: { system: "system", skill: ${JSON.stringify(SKILL)} } });
|
||||
const result = await generatePersonalReportLongform({ report, admin, profile: { birth_date: "2000-01-01", reported_birth_time: "12:00", birth_time_status: "reported", latitude: 0, longitude: 0, timezone_offset: 0, ayanamsa: "lahiri" }, candidateRange: null, displayName: "虚构测试", birthTimeStatus: "reported", now: () => new Date("2026-10-27T12:00:00Z"),
|
||||
fetchImpl: async (_url, init) => { bodies.push(JSON.parse(init.body)); return Response.json({ format: "markdown", markdown: golden.markdown, fact_table_packet: packet, reader_dasha_applicability: golden.reader_dasha_applicability, ...(mode === "no-writer" ? {} : { reading_facts: facts }) }); },
|
||||
...(writeReading ? { writeReading } : {}) });
|
||||
|
||||
@@ -20,7 +20,7 @@ Profiles record their role separately from their identifier. Mudda records the l
|
||||
|
||||
## Validation and release manifest
|
||||
|
||||
Run `python -m scripts.preset_evidence.library --her-export <source-directory>` to check the schema, receipt, source hashes, normalized excerpts, and the bound repository functions and tests. For the source directory, use an exact Git checkout or an export with source revision and file hashes from that checkout. A plain export of the same pinned files can be verified against the previously generated manifest. Add `--write-verified` to publish `VERIFIED.json` after a full check. `--skip-her-export` runs development checks and cannot write the manifest.
|
||||
Run `python -m scripts.preset_evidence.library --her-export <source-directory>` to check the schema, receipt, source hashes, normalized excerpts, and the bound repository functions and tests. Pass either `--her-export` or `--skip-her-export` explicitly. Use a Git checkout at the source revision pinned in `VERIFIED.json` or a plain export with the same source-file hashes. For source-backed pytest checks, set `PRESET_EVIDENCE_HER_EXPORT` to that directory. The checks skip when the variable is unset and fail when the supplied checkout or export does not match the pin. Add `--write-verified` to publish `VERIFIED.json` after a full check. `--skip-her-export` runs development checks and cannot write the manifest.
|
||||
|
||||
`VERIFIED.json` records the source revision, hashes of every resolved source dependency, validator and matcher versions, and generation time. The library content hash covers the bytes of `schema.json` and every entry file in sorted path order. Git keeps their LF line endings across platforms. The receipt has its own pinned hash. An absolute path, parent traversal, or symlink outside its declared root fails validation.
|
||||
|
||||
|
||||
@@ -248,3 +248,33 @@ evidence_card.backstage records, for the backend only, that global web evidence
|
||||
- 裁定 7 两个状态轴(BUG-1753):审计表每行分开写 Calculation(`calculation_status`:executed / partial / blocked / not_applicable)与 Interpretation(`interpretation_status`:她 interpretation_status_contract 的 primary / supporting / conditional / observation_only / blocked,另加产品的 `not_used`)。原值单一 Status 里的「Used(候选)」改为 executed + conditional;能算但方法有分歧不写 blocked。前端行结构(`techniqueAuditRowSchema`)、表格解析、技法表组件与年报附录同步改为两轴;旧的单值行按 executed → executed + primary、candidate → executed + conditional、blocked → blocked + blocked、not_applicable → not_applicable + not_used 读入。
|
||||
- 裁定 8 外部验证四行(BUG-1754):行名照她写的「本地计算」「预置来源匹配」「实时联网」「真实案例校准」,不加「MEVG ·」前缀;值依次为 executed(或 partial / blocked)、不足(产品没有预置来源库;有库并匹配才写 matched)、blocked、按实际情况(本轮 blocked,「未完成」),不默认完成。 **BUG-1794 起(产品 2026-11-08 决定)**:这四行不再写进回答的审计表,只留在后台回执,见「F」6c。
|
||||
- 裁定 6 F 节(BUG-1755、BUG-1756、BUG-1757):见「F」第 1–6 条。白话可以减少术语,不能减少信息:删「最多两条依据」「两个及以上受冲才写压力」「白话不主动提核验缺口」「限制放末尾」;方法分歧时写技法名;受冲条数留在后台,用户需要理解冲突时写实际来源。专业模式与白话模式两套 VISIBLE VOICE 都改。
|
||||
|
||||
## G. 个人报告「解读」(report-reading,#192,2026-11-12)
|
||||
|
||||
依据:占星师 2026-11-12 审核回复(她的仓库 `8aaf6099`,`handoff/2026-11-12/preset_q3/README.md` 第一、五、六、七节)。报告「解读」先绑定她的 SKILL.md 全文与相关 references(见 `frontend/src/mastra/report-reading-skill.ts`,实际加载清单与哈希存进每份解读),再附本块。本块是产品对报告体裁的规定,不是她的方法;与她历史文件里的分级说法冲突时,按她 11-12 交接合同 `report_evidence_grade_v1` 执行(她 §7.3)。原值:`frontend/src/lib/personal-report-reading.ts` 的 `REPORT_READING_SYSTEM`(2026-11-07 方案,九节 + 节尾 A/B/C + 「没有用户经历所以最高 B」)。
|
||||
|
||||
<!-- product-block:report-reading -->
|
||||
<!-- 产品规则,不是占星师的方法:R-01 输入边界。 -->
|
||||
你在给一份个人星盘报告写开头的「解读」。用户消息里的盘面事实(report-reading-facts-v3)是引擎已经算好的全部材料,只用这些,不编数据,不另起算法,不凭印象重算尊贵度、宫位或日期。事实里没有的层就当没有。
|
||||
<!-- 产品规则,不是占星师的方法:R-02 报告模式。她 §6 P0:已故的公开人物不能给当前建议。 -->
|
||||
先看 report_mode 和 current_advice_allowed。current_advice_allowed 为 false 时,这是历史人物的计算回放:不给当前的健康、职业、感情或行动建议,「现在这几年」只写观察日期所在的运程如何排列,「几条建议」写成对这张盘的阅读要点;不要按真人身份或生平去写。
|
||||
<!-- 产品规则,不是占星师的方法:R-03 九节职责。她 §5.1。 -->
|
||||
九节固定,按顺序:总评、性格、事业、财富、感情、健康、现在这几年、几条建议、依据与边界。
|
||||
- 总评:先用三到六句回答这张盘最突出的地方,再简短给依据。原始数值和逐项技法放在后面的计算依据里,正文不必每段先列一长串依据,但用到的数值必须照事实原值写。
|
||||
- 性格:只写静态结构(上升、月亮、上升主等),不要求每项性格都配大运。
|
||||
- 事业、财富、感情、健康:先结论,再简短依据。讲时间时按「本命承诺 → 激活 → 落地 → 应期」推理;落地的依据不足时,只写条件窗口,不说已经落地。财富分开写「能不能挣到」和「能不能留下」。感情先分清是心动、成对还是法律婚,不把所有关系需求都当成婚姻。
|
||||
- 健康:只写有依据的传统条件和要多留意的时段,不从 6、8、12 宫直接推出免疫、睡眠这类具体身体事实,不写病名。D30 能不能用看 birth_precision 和 evidence_grades,不只看 minute_confirmed。
|
||||
- 现在这几年:写明实际覆盖哪几年。行运只是 observation_date 那一天的位置,不外推成几个月或几年的结论;时间粒度由证据决定。
|
||||
- 几条建议:两到四条,每条只能落在前面已经有依据的主题上。只是一般性的建议,就写明「一般建议」,不装成星盘算出来的结论;不从数值门槛推出具体作息时刻、贷款或资产安排。
|
||||
- 依据与边界:功能吉星、功能凶星及与自然吉凶的冲突;实际用到的技法;没有使用的层及原因(按 participation 的 state 写:未路由到本领域、references 没有本领域条目、没有计算);参数敏感的层;出生时间精度与 birth_precision 里接近边界的分盘;岁差、交点、宫制。
|
||||
<!-- 产品规则,不是占星师的方法:R-04 证据等级。她 §1.3–1.4、§5.2,合同 report_evidence_grade_v1。 -->
|
||||
证据等级按每条结论标,不按节标。事实里的 evidence_grades.claims 给出每一类结论(claim type)的等级上限(cap)、必需依据、需要说明的缺口(must_disclose)和参数敏感的层。性格、事业、财富、感情、健康、现在这几年六节的每一段都要选一个 claim type,并给一个不高于它 cap 的等级(A 最高,D 最低);总评、几条建议、依据与边界的段落 claim type 和 grade 都填 null。等级含义:C 是有星盘来源和可计算的依据、还没有经过案例校准;D 是这条结论需要的关键依据缺失或无法判别。D 的段落只写缺了什么、暂不能判断,不写结果。不要在正文里写「置信度:」或「证据等级:」,等级由页面显示。must_disclose 里的缺口和参数敏感的层要在相关段落里说出来。只有某类结论的必需依据全部可用时,才能说它「没有关键阻塞」。
|
||||
<!-- 产品规则,不是占星师的方法:R-05 不把不同体系的不同当冲突。她 §5.1、§6 P1。 -->
|
||||
两套大运(Vimshottari、Narayana)的主星或星座不同,本身不是冲突;同名主星也不自动算一致。只有对应同一件事、同一段时间、同一个判断方向时,才比较是否一致。KP 与整宫制结果不同,写成方法差异。Double Transit 只对 checked_houses 里的宫位有结论,不推到别的宫或别的领域。
|
||||
<!-- 产品规则,不是占星师的方法:R-06 格局与尊贵度。她 §5.1、§6 P1。 -->
|
||||
格局按 yogas 每一行的形成状态写:满足了来源的形成条件就可以说形成,现实里是否应验另说;不把未经现实验证写成未形成。尊贵度照 dignity_zh 原词写(入旺、入庙、落陷……),不自己翻译。
|
||||
<!-- 产品规则,不是占星师的方法:R-07 术语与红线(原 REPORT_READING_SYSTEM 第 9–11 条,未改)。 -->
|
||||
可以写技法名和分盘代号(Vimshottari、Narayana、D10、A10、UL、AK、AmK、DK、KP、Shadbala、Ashtakavarga、Sade Sati),第一次出现时在括号里用半句中文说明它看什么。行星、星座用中文;交点只叫罗睺、计都。不写字段名、下划线、花括号、井号、代码,不写「本仓」「检测器」「字段」「口径」这类内部说法。审计行里网上检索和案例校准这两行只在后台记录(产品 2026-11-08 决定,BUG-1794),正文不写它们有没有做;等级由页面显示。不写病名、诊断和身体部位的疾病;不给投资指令,不点名股票、基金、币;不写「注定」「先苦后甜」「宿命」「一定会」;不预测死亡、必然离婚、怀孕与孩子性别、某天必然发财;婚姻、生死不下确定结论。每节最多一句提醒,不逐句免责。
|
||||
<!-- 产品规则,不是占星师的方法:R-08 篇幅与输出格式。 -->
|
||||
每节一到六段,每段不超过六百五十字,全篇三千到一万五千字。只输出 JSON:{"sections":[{"id":"overview","paragraphs":[{"text":"……","claim_type":null,"grade":null}]}, …]}。九节的 id 依次是 overview、character、career、wealth、love、health、now、advice、basis。claim_type 只能取 {{CLAIM_TYPES}} 之一或 null,grade 只能取 A、B、C、D 或 null。
|
||||
<!-- /product-block:report-reading -->
|
||||
|
||||
@@ -5,7 +5,6 @@ import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -18,7 +17,6 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
LIBRARY_DIR = ROOT / "references" / "preset_evidence_library"
|
||||
ENTRIES_DIR = LIBRARY_DIR / "entries"
|
||||
SCHEMA_PATH = LIBRARY_DIR / "schema.json"
|
||||
DEFAULT_HER_EXPORT = Path(os.environ.get("PRESET_EVIDENCE_HER_EXPORT", "/workspace/up-run-r40"))
|
||||
RECEIPT_PATH = "rulings/review_receipt_20261112.json"
|
||||
RECEIPT_SHA256 = "426c456f99848c14e0ffd20a8657f7251c24cb9ae555d77a64ddeef402ea801d"
|
||||
VALIDATOR_VERSION = "preset-library-validator.v2"
|
||||
@@ -422,7 +420,7 @@ def _validate_snapshot(raw: dict[str, bytes], receipt: dict, files: _FileIndex |
|
||||
return entries, errors
|
||||
|
||||
|
||||
def validate(entries_dir: Path = ENTRIES_DIR, her_export: Optional[Path] = DEFAULT_HER_EXPORT,
|
||||
def validate(entries_dir: Path = ENTRIES_DIR, her_export: Optional[Path] = None,
|
||||
our_root: Path = ROOT, *, library_dir: Path = LIBRARY_DIR) -> List[str]:
|
||||
"""Collect schema and policy errors from every category."""
|
||||
try:
|
||||
@@ -617,19 +615,20 @@ def summary(entries: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--her-export", type=Path)
|
||||
parser.add_argument("--skip-her-export", action="store_true", help="Development checks only")
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--her-export", type=Path, help="Checkout or export of the source revision pinned in VERIFIED.json")
|
||||
source.add_argument("--skip-her-export", action="store_true", help="Development checks only")
|
||||
parser.add_argument("--write-verified", action="store_true")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
if args.write_verified and (args.skip_her_export or args.her_export is None):
|
||||
if args.write_verified and args.skip_her_export:
|
||||
parser.error("--write-verified requires an explicit --her-export and full validation")
|
||||
entries = []
|
||||
errors = []
|
||||
try:
|
||||
raw = _library_bytes(ENTRIES_DIR, LIBRARY_DIR)
|
||||
receipt = _read_receipt(LIBRARY_DIR)
|
||||
files = None if args.skip_her_export else _FileIndex(args.her_export or DEFAULT_HER_EXPORT)
|
||||
files = None if args.skip_her_export else _FileIndex(args.her_export)
|
||||
entries, errors = _validate_snapshot(raw, receipt, files, ROOT)
|
||||
if files is not None and not errors:
|
||||
source_revision = _validate_source_pin(files, receipt)
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Per-claim evidence-grade caps for the report reading (`report_evidence_grade_v1`).
|
||||
|
||||
Astrologer 2026-11-12 (her repo 8aaf6099, handoff/2026-11-12/preset_q3 §1.3–1.4
|
||||
and §5.2): the backend fixes each conclusion's required dependencies by route
|
||||
and claim type, computes a grade ceiling from a six-axis evidence ledger, and the
|
||||
model may only write at or below it. Grades are not probabilities.
|
||||
|
||||
| Grade | Meaning in this contract |
|
||||
|---|---|
|
||||
| A | strongly and independently validated on a defined case set (never reached here) |
|
||||
| B | matching cases plus several independent methods agree (never reached here) |
|
||||
| C | sourced and computable, but not calibrated on matching cases |
|
||||
| D | a dependency the conclusion needs is missing, undecidable or in open conflict |
|
||||
|
||||
No conclusion in a report has matching external cases (event calibration is
|
||||
`not_done` everywhere), so the ceiling is C at best. A conclusion falls to D when
|
||||
a *required* layer of its route is missing, failed, withheld, or sits within the
|
||||
birth-time tolerance of an ascendant change in a varga it needs. A missing layer
|
||||
that only the route's checklist names (``checked``) is recorded in the ledger and
|
||||
must be disclosed; it does not by itself make the conclusion D, because an
|
||||
unrelated missing technique must not drag valid conclusions down (§5.2).
|
||||
|
||||
Dependencies follow her `references/strict-workflow-router.md` §2–§7 mandatory
|
||||
modules. The split into ``required`` and ``checked`` is the product's v1 reading
|
||||
of that list and is listed for her review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
GRADE_CONTRACT = "report_evidence_grade_v1"
|
||||
GRADE_ORDER = ("A", "B", "C", "D")
|
||||
# Product policy, versioned: how far the true birth instant may sit from the
|
||||
# recorded one. A minute-confirmed record is taken as +/- 1 minute; an
|
||||
# unconfirmed one has no stated tolerance, so varga stability is not evaluated.
|
||||
PRECISION_POLICY = "birth_record_minute_v1"
|
||||
CONFIRMED_TOLERANCE_MINUTES = 1.0
|
||||
|
||||
MISSING_STATES = {"blocked", "withheld", "missing", "calculation_failed", "missing_input", "not_computed"}
|
||||
|
||||
# Dependency kinds:
|
||||
# ("p", domain, id) domain participation row
|
||||
# ("s", id) shared technique row
|
||||
# ("v", code) varga ascendant stable within the birth-time tolerance
|
||||
# ("t", key) timing / transit / annual block present
|
||||
# Each claim type: route (her router section), section ids it may appear in,
|
||||
# required deps (missing -> D) and checked deps (missing -> disclosed only).
|
||||
CLAIM_TYPES: dict[str, dict[str, Any]] = {
|
||||
"character_structure": {
|
||||
"route": "full-reading-strict §2",
|
||||
"sections": ("overview", "character"),
|
||||
"required": [("v", "D1"), ("s", "functional")],
|
||||
"checked": [("s", "d9"), ("v", "D9"), ("s", "shadbala"), ("s", "avastha"), ("s", "dispositor_chain")],
|
||||
},
|
||||
"career_promise": {
|
||||
"route": "career-timing-strict §3",
|
||||
"sections": ("overview", "career"),
|
||||
"required": [("v", "D1"), ("s", "functional"), ("p", "career", "D10"), ("v", "D10")],
|
||||
"checked": [("s", "d9"), ("p", "career", "AmK"), ("p", "career", "AK"), ("p", "career", "Karakamsha"),
|
||||
("p", "career", "A10"), ("p", "career", "AL"), ("s", "shadbala"), ("s", "ashtakavarga"),
|
||||
("s", "dispositor_chain"), ("p", "career", "D1_D9_D10"), ("s", "argala")],
|
||||
},
|
||||
"wealth_earning": {
|
||||
"route": "finance-timing-strict §5",
|
||||
"sections": ("overview", "wealth"),
|
||||
"required": [("v", "D1"), ("s", "functional"), ("p", "wealth", "D2"), ("v", "D2")],
|
||||
"checked": [("p", "wealth", "D11"), ("p", "wealth", "A2"), ("p", "wealth", "A11"), ("p", "wealth", "D10"),
|
||||
("p", "wealth", "A10"), ("s", "shadbala"), ("s", "ashtakavarga"), ("s", "argala")],
|
||||
},
|
||||
"wealth_retention": {
|
||||
"route": "finance-timing-strict §5",
|
||||
"sections": ("overview", "wealth"),
|
||||
"required": [("v", "D1"), ("s", "functional"), ("p", "wealth", "D2"), ("v", "D2")],
|
||||
"checked": [("p", "wealth", "A2"), ("s", "shadbala"), ("s", "ashtakavarga"), ("s", "argala")],
|
||||
},
|
||||
"relationship_structure": {
|
||||
"route": "relationship-timing-strict §4",
|
||||
"sections": ("overview", "love"),
|
||||
"required": [("v", "D1"), ("s", "functional"), ("p", "marriage", "D9_7"), ("v", "D9")],
|
||||
"checked": [("p", "marriage", "UL"), ("p", "marriage", "A7"), ("p", "marriage", "DK"),
|
||||
("p", "marriage", "Vivah"), ("s", "shadbala")],
|
||||
},
|
||||
"health_pressure": {
|
||||
"route": "health-timing-strict §7",
|
||||
"sections": ("overview", "health"),
|
||||
"required": [("v", "D1"), ("s", "functional"), ("p", "health", "D6"), ("v", "D6")],
|
||||
"checked": [("p", "health", "D8"), ("v", "D8"), ("p", "health", "D30"), ("v", "D30"),
|
||||
("p", "health", "drekkana22"), ("s", "shadbala")],
|
||||
},
|
||||
"health_obstruction": {
|
||||
"route": "health-timing-strict §7 (Badhaka)",
|
||||
"sections": ("health",),
|
||||
"required": [("v", "D1"), ("p", "health", "Badhaka")],
|
||||
"checked": [],
|
||||
},
|
||||
"timing_window": {
|
||||
"route": "event-timing-strict §6",
|
||||
"sections": ("overview", "career", "wealth", "love", "health", "now"),
|
||||
"required": [("s", "vimshottari"), ("s", "narayana"), ("t", "transits")],
|
||||
"checked": [("t", "annual"), ("t", "double_transit"), ("s", "yogini"), ("s", "kp")],
|
||||
},
|
||||
}
|
||||
|
||||
GRADED_SECTIONS = ("character", "career", "wealth", "love", "health", "now")
|
||||
|
||||
|
||||
def _dep_id(dep: tuple) -> str:
|
||||
return ":".join(str(part) for part in dep)
|
||||
|
||||
|
||||
def _participation(facts: dict, domain: str, item_id: str) -> str:
|
||||
rows = ((facts.get("domains") or {}).get(domain) or {}).get("participation") or []
|
||||
for row in rows:
|
||||
if isinstance(row, dict) and row.get("id") == item_id:
|
||||
return str(row.get("state") or "missing")
|
||||
return "missing"
|
||||
|
||||
|
||||
def _shared(facts: dict, item_id: str) -> str:
|
||||
for row in facts.get("shared_techniques") or []:
|
||||
if isinstance(row, dict) and row.get("id") == item_id:
|
||||
return str(row.get("state") or "missing")
|
||||
return "missing"
|
||||
|
||||
|
||||
def _varga_stability(facts: dict, code: str) -> tuple[str, dict[str, Any]]:
|
||||
precision = facts.get("birth_precision") or {}
|
||||
margin = (precision.get("ascendant_margins") or {}).get(code)
|
||||
if not facts.get("minute_confirmed"):
|
||||
return "not_evaluated", {"policy": PRECISION_POLICY, "reason": "birth time not confirmed to the minute"}
|
||||
if not isinstance(margin, dict):
|
||||
return "missing", {"policy": PRECISION_POLICY, "reason": "no ascendant margin for this varga"}
|
||||
nearest = min(float(margin.get("before_minutes", 0)), float(margin.get("after_minutes", 0)))
|
||||
detail = {"policy": PRECISION_POLICY, "nearest_change_minutes": nearest,
|
||||
"tolerance_minutes": CONFIRMED_TOLERANCE_MINUTES}
|
||||
return ("stable" if nearest > CONFIRMED_TOLERANCE_MINUTES else "boundary_sensitive"), detail
|
||||
|
||||
|
||||
def _block_state(facts: dict, key: str) -> str:
|
||||
if key == "transits":
|
||||
return "executed" if (facts.get("transits") or {}).get("planets") else "missing"
|
||||
if key == "annual":
|
||||
annual = facts.get("annual") or {}
|
||||
return "missing" if annual.get("status") == "blocked" or not annual else "executed"
|
||||
if key == "double_transit":
|
||||
double = (facts.get("transits") or {}).get("double_transit") or {}
|
||||
return "executed" if double.get("checked_houses") else "missing"
|
||||
return "missing"
|
||||
|
||||
|
||||
def _parameter_sensitive(facts: dict) -> set[str]:
|
||||
listed = (facts.get("blocked_states") or {}).get("parameter_sensitive") or []
|
||||
out = {str(item) for item in listed}
|
||||
narayana_status = ((facts.get("timing") or {}).get("narayana") or {}).get("status")
|
||||
if narayana_status in {"partial", "partial_verified", "parameter_sensitive", "conditional"}:
|
||||
out.add("narayana")
|
||||
return out
|
||||
|
||||
|
||||
def _evaluate(facts: dict, dep: tuple, sensitive: set[str]) -> dict[str, Any]:
|
||||
kind = dep[0]
|
||||
detail: dict[str, Any] = {}
|
||||
if kind == "p":
|
||||
state = _participation(facts, dep[1], dep[2])
|
||||
elif kind == "s":
|
||||
state = _shared(facts, dep[1])
|
||||
if dep[1] in sensitive and state == "executed":
|
||||
state = "parameter_sensitive"
|
||||
elif kind == "v":
|
||||
state, detail = _varga_stability(facts, dep[1])
|
||||
else:
|
||||
state = _block_state(facts, dep[1])
|
||||
return {"id": _dep_id(dep), "state": state, **({"detail": detail} if detail else {})}
|
||||
|
||||
|
||||
def _is_missing(row: dict) -> bool:
|
||||
return row["state"] in MISSING_STATES or row["state"] == "boundary_sensitive"
|
||||
|
||||
|
||||
def _contradictions(facts: dict) -> list[dict[str, Any]]:
|
||||
functional = facts.get("functional") or {}
|
||||
return [row for row in functional.get("natural_vs_functional_conflicts") or [] if isinstance(row, dict)]
|
||||
|
||||
|
||||
def claim_grade(facts: dict, claim_type: str) -> dict[str, Any]:
|
||||
spec = CLAIM_TYPES[claim_type]
|
||||
sensitive = _parameter_sensitive(facts)
|
||||
required = [_evaluate(facts, dep, sensitive) for dep in spec["required"]]
|
||||
checked = [_evaluate(facts, dep, sensitive) for dep in spec["checked"]]
|
||||
missing_required = [row["id"] for row in required if _is_missing(row)]
|
||||
missing_checked = [row["id"] for row in checked if _is_missing(row)]
|
||||
sensitive_rows = [row["id"] for row in required + checked if row["state"] == "parameter_sensitive"]
|
||||
stability = [row for row in required + checked if row["id"].startswith("v:")]
|
||||
if any(row["state"] == "boundary_sensitive" for row in stability):
|
||||
purpose = "boundary_sensitive"
|
||||
elif any(row["state"] == "not_evaluated" for row in stability):
|
||||
purpose = "not_evaluated"
|
||||
else:
|
||||
purpose = "stable"
|
||||
cap = "D" if missing_required else "C"
|
||||
reasons = (
|
||||
[f"required dependency unavailable: {item}" for item in missing_required]
|
||||
or ["no matching external cases: event calibration not done"]
|
||||
)
|
||||
return {
|
||||
"route": spec["route"],
|
||||
"sections": list(spec["sections"]),
|
||||
"cap": cap,
|
||||
"reasons": reasons,
|
||||
"required": required,
|
||||
"checked": checked,
|
||||
"must_disclose": missing_checked,
|
||||
"parameter_sensitive": sensitive_rows,
|
||||
# Six axes (§5.2), raw enums; a missing value never defaults to good.
|
||||
"ledger": {
|
||||
"case_fit": "none",
|
||||
"method_maturity": "source_bounded" if not missing_required else "dependency_missing",
|
||||
"independent_agreement": "not_assessed",
|
||||
"purpose_stability": purpose,
|
||||
"external_calculation_check": "not_done",
|
||||
"contradiction": [f"{row.get('planet')}: natural {row.get('natural')} / functional {row.get('functional')}"
|
||||
for row in _contradictions(facts)],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def evidence_grades(facts: dict) -> dict[str, Any]:
|
||||
"""The per-claim ceilings the reading must respect."""
|
||||
return {
|
||||
"contract": GRADE_CONTRACT,
|
||||
"precision_policy": PRECISION_POLICY,
|
||||
"grade_order": list(GRADE_ORDER),
|
||||
"graded_sections": list(GRADED_SECTIONS),
|
||||
"claims": {name: claim_grade(facts, name) for name in CLAIM_TYPES},
|
||||
}
|
||||
|
||||
|
||||
def grade_allowed(grade: str, cap: str) -> bool:
|
||||
"""A grade may equal its ceiling or be weaker (later in GRADE_ORDER)."""
|
||||
return GRADE_ORDER.index(grade) >= GRADE_ORDER.index(cap)
|
||||
@@ -22,6 +22,7 @@ from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from .birth_precision_margins import ascendant_margins
|
||||
from .evidence_grade import evidence_grades
|
||||
from .parity_chapters import _VIMSOPAKA_DIGNITY
|
||||
|
||||
try:
|
||||
@@ -898,4 +899,6 @@ def build_report_reading_facts(
|
||||
"method_differences": _method_differences(working, narayana, functional, transits),
|
||||
"blocked_states": _blocked_states(domains, shared_rows, families, evidence, transits, annual),
|
||||
}
|
||||
# Astrologer 2026-11-12 Q3-2: the backend fixes each claim type's grade ceiling.
|
||||
facts["evidence_grades"] = evidence_grades(facts)
|
||||
return _strip_internal_issue_references(facts)
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -11,9 +13,34 @@ from scripts.preset_evidence import library as lib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
HER = lib.DEFAULT_HER_EXPORT
|
||||
needs_export = pytest.mark.skipif(not (HER / "references" / "yoga_rules.json").is_file(),
|
||||
reason=f"her export not present at {HER}")
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def her_export() -> Path:
|
||||
value = os.environ.get("PRESET_EVIDENCE_HER_EXPORT")
|
||||
if not value:
|
||||
pytest.skip("Set PRESET_EVIDENCE_HER_EXPORT to check the pinned source export.")
|
||||
|
||||
verified = json.loads((lib.LIBRARY_DIR / "VERIFIED.json").read_bytes())
|
||||
mismatch = "来源与 VERIFIED.json 固定的版本不一致"
|
||||
try:
|
||||
directory = Path(value).resolve()
|
||||
if (directory / ".git").exists():
|
||||
git = ["git", "-c", f"safe.directory={directory.as_posix()}", "-C", str(directory)]
|
||||
subprocess.check_output([*git, "status", "-sb"], stderr=subprocess.PIPE, timeout=20)
|
||||
root, revision = subprocess.check_output(
|
||||
[*git, "rev-parse", "--show-toplevel", "HEAD"], stderr=subprocess.PIPE, timeout=20,
|
||||
).decode("utf-8").splitlines()
|
||||
matches = Path(root).resolve() == directory and revision == verified["source_repo_commit"]
|
||||
else:
|
||||
files = lib._FileIndex(directory)
|
||||
for relative in verified["source_files"]:
|
||||
files.read(relative)
|
||||
matches = files.hashes() == verified["source_files"]
|
||||
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
|
||||
matches = False
|
||||
if not matches:
|
||||
pytest.fail(mismatch, pytrace=False)
|
||||
return directory
|
||||
|
||||
|
||||
def _entry(entry_id: str) -> dict:
|
||||
@@ -40,9 +67,8 @@ def test_policy_holds_without_export():
|
||||
assert lib.validate(her_export=None) == []
|
||||
|
||||
|
||||
@needs_export
|
||||
def test_every_her_file_exists_sha_matches_and_quotes_are_verbatim():
|
||||
assert lib.validate(her_export=HER) == []
|
||||
def test_every_her_file_exists_sha_matches_and_quotes_are_verbatim(her_export):
|
||||
assert lib.validate(her_export=her_export) == []
|
||||
|
||||
|
||||
def test_ids_unique_and_seed_counts():
|
||||
@@ -98,14 +124,14 @@ def test_missing_field_needs_a_note_and_missing_source_cannot_be_admitted():
|
||||
assert any("requires a located source" in e for e in lib.entry_policy_errors(entry))
|
||||
|
||||
|
||||
@needs_export
|
||||
def test_changed_her_file_is_caught(tmp_path):
|
||||
def test_changed_her_file_is_caught(her_export):
|
||||
files = lib._FileIndex(her_export)
|
||||
entry = _entry("functional.pvr_table30")
|
||||
entry["source"]["sha256"] = "0" * 64
|
||||
assert any("sha256 mismatch" in e for e in lib.entry_policy_errors(entry, lib._FileIndex(HER)))
|
||||
assert any("sha256 mismatch" in e for e in lib.entry_policy_errors(entry, files))
|
||||
entry = _entry("functional.pvr_table30")
|
||||
entry["source"]["quote_excerpt"] = "|Vi|—|Mercury,Venus,Saturn|"
|
||||
assert any("quote not found" in e for e in lib.entry_policy_errors(entry, lib._FileIndex(HER)))
|
||||
assert any("quote not found" in e for e in lib.entry_policy_errors(entry, files))
|
||||
|
||||
|
||||
def test_open_shadbala_parameters_and_harsha_stay_blocked():
|
||||
|
||||
@@ -226,6 +226,14 @@ def test_fingerprint_includes_facts_domain_and_branch():
|
||||
assert len({row["match_fingerprint"] for row in (first, other_domain, other_branch, other_facts)}) == 4
|
||||
|
||||
|
||||
def test_cli_requires_an_explicit_source_option(capsys):
|
||||
with pytest.raises(SystemExit) as error:
|
||||
lib.main([])
|
||||
assert error.value.code == 2
|
||||
diagnostic = capsys.readouterr().err
|
||||
assert "--her-export" in diagnostic and "--skip-her-export" in diagnostic
|
||||
|
||||
|
||||
def test_skipping_export_cannot_write_a_manifest():
|
||||
manifest = lib.LIBRARY_DIR / "VERIFIED.json"
|
||||
before = manifest.read_bytes()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""#192: per-claim evidence-grade ceilings (astrologer 2026-11-12, contract report_evidence_grade_v1)."""
|
||||
|
||||
import copy
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.report_render.evidence_grade import CLAIM_TYPES, GRADE_CONTRACT, claim_grade, evidence_grades, grade_allowed
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _facts(**overrides):
|
||||
facts = {
|
||||
"minute_confirmed": True,
|
||||
"shared_techniques": [{"id": name, "state": "executed"} for name in (
|
||||
"functional", "d9", "shadbala", "avastha", "dispositor_chain", "ashtakavarga", "argala",
|
||||
"vimshottari", "narayana", "yogini", "kp",
|
||||
)],
|
||||
"domains": {
|
||||
"career": {"participation": [{"id": i, "state": "executed"} for i in ("D10", "AmK", "AK", "Karakamsha", "A10", "AL", "D1_D9_D10")]},
|
||||
"wealth": {"participation": [{"id": i, "state": "executed"} for i in ("D2", "D11", "A2", "A11", "D10", "A10")]},
|
||||
"marriage": {"participation": [{"id": i, "state": "executed"} for i in ("D9_7", "UL", "A7", "DK", "Vivah")]},
|
||||
"health": {"participation": [{"id": "D6", "state": "executed"}, {"id": "D8", "state": "executed"},
|
||||
{"id": "D30", "state": "executed"}, {"id": "drekkana22", "state": "executed"},
|
||||
{"id": "Badhaka", "state": "blocked", "reason_code": "not_computed"},
|
||||
{"id": "Gulika", "state": "no_reference_entry"}]},
|
||||
},
|
||||
"birth_precision": {"ascendant_margins": {code: {"before_minutes": 30.0, "after_minutes": 30.0}
|
||||
for code in ("D1", "D2", "D6", "D8", "D9", "D10", "D30")}},
|
||||
"transits": {"planets": {"Saturn": {"sign": "Pisces"}}, "double_transit": {"checked_houses": [7]}},
|
||||
"annual": {"target_year": 2026},
|
||||
"timing": {"narayana": {"status": "partial_verified"}},
|
||||
"functional": {"natural_vs_functional_conflicts": [{"planet": "Moon", "natural": "benefic", "functional": "malefic"}]},
|
||||
"blocked_states": {"parameter_sensitive": ["kp"]},
|
||||
}
|
||||
facts.update(overrides)
|
||||
return facts
|
||||
|
||||
|
||||
def test_no_matching_cases_caps_every_available_claim_at_c():
|
||||
grades = evidence_grades(_facts())
|
||||
assert grades["contract"] == GRADE_CONTRACT
|
||||
caps = {name: claim["cap"] for name, claim in grades["claims"].items()}
|
||||
assert caps == {**{name: "C" for name in CLAIM_TYPES}, "health_obstruction": "D"}
|
||||
for claim in grades["claims"].values():
|
||||
assert claim["ledger"]["case_fit"] == "none"
|
||||
assert claim["ledger"]["external_calculation_check"] == "not_done"
|
||||
|
||||
|
||||
def test_unrelated_missing_layer_does_not_drag_other_claims_to_d():
|
||||
# Badhaka is blocked: only the claim that needs it is D (her §5.2).
|
||||
grades = evidence_grades(_facts())["claims"]
|
||||
assert grades["health_obstruction"]["cap"] == "D"
|
||||
assert grades["health_pressure"]["cap"] == "C"
|
||||
assert grades["health_obstruction"]["reasons"] == ["required dependency unavailable: p:health:Badhaka"]
|
||||
|
||||
|
||||
def test_not_routed_and_no_reference_layers_are_not_required():
|
||||
facts = _facts()
|
||||
assert all(dep != ("p", "health", "Gulika") for spec in CLAIM_TYPES.values() for dep in spec["required"])
|
||||
assert claim_grade(facts, "health_pressure")["cap"] == "C"
|
||||
|
||||
|
||||
def test_missing_required_layer_or_boundary_varga_gives_d():
|
||||
facts = _facts()
|
||||
facts["domains"]["career"]["participation"][0]["state"] = "blocked"
|
||||
assert claim_grade(facts, "career_promise")["cap"] == "D"
|
||||
boundary = _facts()
|
||||
boundary["birth_precision"]["ascendant_margins"]["D6"] = {"before_minutes": 0.4, "after_minutes": 9.0}
|
||||
health = claim_grade(boundary, "health_pressure")
|
||||
assert health["cap"] == "D"
|
||||
assert health["ledger"]["purpose_stability"] == "boundary_sensitive"
|
||||
checked_only = _facts()
|
||||
checked_only["birth_precision"]["ascendant_margins"]["D30"] = {"before_minutes": 0.4, "after_minutes": 9.0}
|
||||
disclosed = claim_grade(checked_only, "health_pressure")
|
||||
assert disclosed["cap"] == "C"
|
||||
assert disclosed["must_disclose"] == ["v:D30"]
|
||||
|
||||
|
||||
def test_unconfirmed_birth_time_is_not_evaluated_not_assumed_stable():
|
||||
facts = _facts(minute_confirmed=False)
|
||||
claim = claim_grade(facts, "career_promise")
|
||||
assert claim["ledger"]["purpose_stability"] == "not_evaluated"
|
||||
assert claim["cap"] == "C"
|
||||
|
||||
|
||||
def test_parameter_sensitive_timing_is_flagged_not_hidden():
|
||||
claim = claim_grade(_facts(), "timing_window")
|
||||
assert claim["cap"] == "C"
|
||||
assert "s:narayana" in claim["parameter_sensitive"]
|
||||
|
||||
|
||||
def test_grade_allowed_is_at_or_below_cap():
|
||||
assert grade_allowed("C", "C") and grade_allowed("D", "C")
|
||||
assert not grade_allowed("B", "C") and not grade_allowed("C", "D")
|
||||
|
||||
|
||||
def test_frontend_claim_types_match_the_backend():
|
||||
source = (ROOT / "frontend/src/lib/personal-report-reading-contract.ts").read_text(encoding="utf-8")
|
||||
block = source[source.index("export const REPORT_CLAIM_TYPES"):source.index("] as const;", source.index("export const REPORT_CLAIM_TYPES"))]
|
||||
assert re.findall(r'"([a-z_]+)"', block) == list(CLAIM_TYPES)
|
||||
|
||||
|
||||
def test_grades_ride_in_the_facts_without_mutating_input():
|
||||
facts = _facts()
|
||||
before = copy.deepcopy(facts)
|
||||
evidence_grades(facts)
|
||||
assert facts == before
|
||||
Reference in New Issue
Block a user