Compare commits

..
18 changed files with 261 additions and 990 deletions
+12 -12
View File
@@ -24988,16 +24988,16 @@
- 关联记录:#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-1849 | 个人报告 Mudda 表、技法审计和上升行暴露内部展示口径
- 状态:blocked(待确认上升行占位符含义)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-12。
- 影响面:中英文个人报告的 Mudda 区间与年度表、Strength Layers、行星表和技法审计。
- 现象:区间天数小数过长,说明出现内部字段名,部分表头和审计状态直接显示工程键,上升行不适用的栏位使用缺值占位。
- 触发条件:生成包含 Mudda 区间、技法审计及上升行的个人报告。
- 已确认:报告的中英文阅读说明将 `—` 定义为缺数据或核对受限。任务书第 6 项的这些上升栏位属于不适用,直接使用同一符号会与该说明冲突。
- 验证:定位两处上升行和双语阅读说明,报告开篇补充测试通过。产品口径确认后再验证渲染行为。
- 防复发计划:用公开样例回归中英文报告的区间、后续年表、审计及上升行,保留既有断言的验证范围。
- 关联记录:#204、BUG-1092、BUG-1840、BUG-1843、BUG-1844。
- 修复版本:待定。
@@ -0,0 +1,10 @@
# 个人报告措辞修订 · 2026-11-12
Gitea #204,BUG-1849。按 Claude 评论 42071 认领。任务书位于任务分支首提交 `ca64e7b5c`,staging 基线为 `017e650fdafac4a841487dbba4aca803c761dad8`。
## 待拍板
- 任务书第 6 项将上升行的 R/C、速度、力量比和分主列为不适用,并要求在 `—` 的含义与不适用冲突时停下来。
- `scripts/pl9_reader_export.py` 在原始位置表与主星和状态表生成上升行。`scripts/report_render/opening_guide.py::reading_guide` 把 `—` 定义为「缺数据或核对受限」,英文为 “missing data or a verification limit”。两种语言的个人报告均由 `scripts/pl9_full_data_export.py` 插入该说明。
- 若直接将这些格改为 `—`,读者会按现有说明理解为数据缺失或核对受限。请确认是否允许为上升行在报告说明中明确该符号的特例,或指定单独的不适用显示。
- `tests/test_report_opening_supplement.py` 的 3 项测试通过。已记录冲突,等待确认后实施并跑任务书要求的定向回归、报告套件与 Python 快速门禁。
@@ -0,0 +1,104 @@
# TASK:个人报告几处露出的原始字段和格式问题(2026-11-12)
- Gitea:#204
- 分支:`codex/report-wording-fixes-20261112`(基于 staging `017e650fdafac4a841487dbba4aca803c761dad8`)
- Bug 编号:BUG-1849。开工时核对 `docs/BUG_HISTORY.md` 末尾;若已被占用,顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-de)
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
## 背景
2026-11-12,Claude 用 `tests/public_chart_reports.py` 的第一张公开名人盘渲染了中英文个人报告(`public_chart_reports(PUBLIC_REPORT_CASES[0])`),看到下面几处问题。它们都在报告渲染层,引擎的值本身没有错。
| # | 版本 | 现在显示 | 位置 |
|---|---|---|---|
| 1 | 中、英 | Mudda 区间小表「本报告覆盖区间」一行写 `365.2555787037037 天` | `scripts/pl9_reader_export.py::_mudda_coverage_lines`,`coverage.get('days')` |
| 2 | 中 | 区间表下方一句:`报告覆盖区间视图(本次真实返照 → 下一次真实返照);完整原始时间线见 raw_periods` | 同一函数,`periods_view.label`。英文版用的是 `label_en` |
| 3 | 中 | 跨返照那句 `……(原始计算延伸至2027-02-27T20:28:43)` 句末没有句号 | 同一函数,`MUDDA_CROSSING_TEXT` |
| 4 | 中 | 第二年及以后的年度表里,Mudda 表头是英文键:`balance_profile \| lord \| start \| end \| boundary_status` | `pl9_reader_export.py` 渲染 `annual_pack` 的 `table_key == 'mudda_rows'` 那段(约 :3240) |
| 5 | 中 | 「Strength Layers」审计行结尾是 `Vimsopaka status=良好。` | 技法审计表 Strength Layers 一行的组装处,自己定位 |
| 6 | 中、英 | 行星表和主星表里,**上升**一行的「R/C」「速度」「力量比」「分主」是 `未列` / `not listed` | 行星表、行星主星与状态表的组装处,自己定位 |
| 7 | 英 | 技法审计表的 Status 列写成 `calculation: executed; interpretation: primary` | 英文版审计表。中文版同一格写的是「计算已执行,解读主要依据」 |
## 要做的
1. **天数**:保留 4 位小数,中英文都改,例如 `365.2556 天` / `365.2556 days`。表里其他数值不动。
2. **`raw_periods`**:用户能看到的文字里不出现字段名。
- 中文改为:`报告覆盖区间视图(本次真实返照 → 下一次真实返照);完整原始时间线见上表「原始完整时间线」一行`;
- 英文改为与之对应的一句。
- 在报告层替换,不改引擎和 `scripts/tajika_mudda.py`。
3. **句号**:中文跨返照句末补「。」。在报告层补,`consultation_native_layers.py` 是冻结文件,不改。
4. **英文表头**:中文版第二年及以后的 Mudda 表,表头改用第一年那张表的中文表头:`余额口径 | 主星 | 开始 | 结束 | 边界口径`。
- 单元格里的枚举值,用第一年那张表现成的映射显示。
- 英文版不变。
- 别的 `annual_pack` 表如果在中文版里也露出英文键,**列进 PROGRESS,本单不改**。
5. **Vimsopaka**:去掉 `status=良好` 这类等级词。
- 改为写出每颗行星的 Vimsopaka 原始值,取值与报告 Vimsopaka 表相同、单位相同;
- 如果拿不到原始值,就整段去掉,不写任何等级词。
- 英文版同样处理。
6. **上升行**:上升点没有逆行、速度、力量比和分主。
- 这几格改为 `—`,中英文相同,只改上升一行;
- 表格说明里已经写了 `—` 表示“缺数据或核验限制”。若这个含义和“不适用”冲突,就选需拍板,不要另造新词。
7. **英文 Status 列**:按中文版的映射,一一对应写英文短语。
- 先在 PROGRESS 列出中文全部取值和你拟的英文译法,再改代码;
- 只改展示文字,不改状态值本身。
## 要改的文件
- `scripts/pl9_reader_export.py`
- 技法审计表与行星表的组装文件(自己定位,在 PROGRESS 写明)
- 新建 `tests/test_report_wording_fixes_20261112.py`
- `docs/BUG_HISTORY.md`:只在末尾追加一条。
- `docs/tasks/PROGRESS-report-wording-fixes-20261112.md`
## 不要碰
- 冻结文件:
- `scripts/jyotish_engine.py`
- `scripts/jyotish_api_server.py`
- `scripts/yoga_engine.py`
- `scripts/consultation_native_layers.py`
- `scripts/tajika_mudda.py`
- `scripts/shadbala*.py`
- `scripts/narayana_*.py`
- `scripts/report_render/reading_facts.py`、`frontend/src/lib/personal-report-reading*.ts`、`frontend/src/mastra/personal-report-reading.ts`:Claude 在 #192 里改。
- `references/**`
## 验收标准(写进新测试)
用两张公开名人盘渲染中英文报告,断言下面 7 条:
1. 不再出现 `\d+\.\d{5,} 天` 和 `\d+\.\d{5,} days`。
2. 用户可见文字里没有 `raw_periods`。
3. 跨返照句以「。」结尾。
4. 中文版里没有 `| balance_profile | lord |` 这样的表头。
5. 没有 `Vimsopaka status=`。
6. 上升行的 R/C 和速度两格是 `—`。
7. 英文版没有 `calculation: `、`; interpretation: ` 这种写法。
另外,`tests/test_report_*` 里现有的全部测试都要通过。
## 自测命令(在仓库根目录)
```
python -m pytest -q tests/test_report_wording_fixes_20261112.py
python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
```
## 进度记录
写在 `docs/tasks/PROGRESS-report-wording-fixes-20261112.md`,内容包括:
- 每一处改在哪里;
- 第 7 条的中英对照表;
- 第 4 条发现的其他英文键表;
- 测试结果,以及在 staging 上也复现的基线失败。
## 何时停下来选“需拍板”
- 某一处只能改冻结文件才能修好。
- 第 6 条里,`—` 的现有含义和“不适用”冲突。
- 第 5 条拿不到 Vimsopaka 原始值,而去掉整段会让这一行变空。
-12
View File
@@ -4866,18 +4866,6 @@ 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,10 +1,7 @@
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";
@@ -16,10 +13,6 @@ 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 (
@@ -31,25 +24,15 @@ export function ReportReadingSection({ reading }: { reading: ReportReading }) {
<a href={`#${readingPartAnchor(section.id)}`} key={section.id}>{section.title}</a>
))}
</nav>
{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>
);
})}
{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>
))}
</section>
<p className="personal-report-basis-label">{REPORT_READING_BASIS_LABEL}</p>
</>
@@ -37,39 +37,11 @@ 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(draftParagraph).min(1).max(MAX_SECTION_PARAGRAPHS),
paragraphs: z.array(z.string().min(1)).min(1).max(MAX_SECTION_PARAGRAPHS),
}).strict()).length(REPORT_READING_SECTIONS.length),
}).strict();
export type ReportReadingDraft = z.infer<typeof reportReadingDraftSchema>;
@@ -80,32 +52,7 @@ 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),
});
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). */
/** Version 1: the eight sections before 2026-11-07. Version 2 adds 「依据与边界」. */
export const reportReadingSchema = z.union([
z.strictObject({
version: z.literal(1),
@@ -117,46 +64,5 @@ 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]}`;
}
+62 -195
View File
@@ -8,16 +8,13 @@
* 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";
@@ -39,171 +36,25 @@ 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;
/**
* 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).
*/
// 原值:含 "latin"(段落里出现任何英文字母即退稿)
// 新值:去掉 latin;技法名、分盘代号(D10、A10、UL)和 A/B/C 置信度是专业写法的一部分
// 原因:产品 2026-11-07 占星师方案:报告完整运行 skill,按她的专业写法写
export type ReadingRejection =
| "shape"
| "field_name"
| "banned_phrase"
| "too_long"
| "too_short"
| "disclaimer"
| "claim_type_missing"
| "claim_type_section"
| "grade_above_cap"
| "false_no_blocker"
| "dignity_mismatch"
| "all_sections_withheld";
| "disclaimer";
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 }
/** The draft becomes the stored reading only when every check passes. */
export function acceptReportReadingDraft(value: unknown):
| { ok: true; reading: ReportReading }
| { ok: false; reason: ReadingRejection } {
const parsed = reportReadingDraftSchema.safeParse(value);
if (!parsed.success) return { ok: false, reason: "shape" };
@@ -211,24 +62,33 @@ export function acceptReportReadingDraft(value: unknown, facts: Record<string, u
if (sections.some((section, index) => section.id !== REPORT_READING_SECTIONS[index]!.id)) {
return { ok: false, reason: "shape" };
}
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);
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" };
}
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: 3,
version: 2,
language: "zh",
contract: EVIDENCE_GRADE_CONTRACT,
skill: { name: skill.name, version: skill.version, loaded: skill.loaded.map((file) => ({ ...file })) },
sections: checked.map(({ section }) => section),
} as ReportReading,
sections: sections.map((section, index) => ({
id: section.id,
title: REPORT_READING_SECTIONS[index]!.title,
paragraphs: section.paragraphs.map((paragraph) => paragraph.trim()),
})),
},
};
}
@@ -246,6 +106,31 @@ 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 ? "出生时间:已确认。" : "出生时间:用户填报,没有确认到分钟。",
@@ -254,17 +139,11 @@ export function reportReadingUserMessage(facts: Record<string, unknown>, birthTi
].join("\n");
}
/** 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<{
export type ReportReadingWriter = (input: Readonly<{
system: string;
user: string;
signal: AbortSignal;
}>) => 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;
}>;
}>) => Promise<{ body: unknown; inputTokens: number; outputTokens: number }>;
export type ReportReadingOutcome = Readonly<{
reading: ReportReading | null;
@@ -298,11 +177,7 @@ function timedSignal(parent: AbortSignal | undefined, timeoutMs: number) {
};
}
/**
* 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.
*/
/** One attempt plus one retry. Never throws, except when the job itself is aborted. */
export async function writeReportReading(input: Readonly<{
facts: unknown;
birthTimeConfirmed: boolean;
@@ -314,28 +189,22 @@ 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, user, signal: timed.signal }),
input.write({ system: REPORT_READING_SYSTEM, user, signal: timed.signal }),
timed.lost,
]);
inputTokens += Math.max(0, result.inputTokens);
outputTokens += Math.max(0, result.outputTokens);
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}`;
}
const accepted = acceptReportReadingDraft(result.body);
if (accepted.ok) return { reading: accepted.reading, inputTokens, outputTokens };
unavailable = `rejected_${accepted.reason}`;
} catch {
if (input.signal?.aborted) throw input.signal.reason ?? new Error("aborted");
unavailable = timed.signal.aborted ? "model_timeout" : "model_failed";
@@ -343,7 +212,5 @@ export async function writeReportReading(input: Readonly<{
timed.dispose();
}
}
return best
? { reading: best.reading, inputTokens, outputTokens }
: { reading: null, unavailable, inputTokens, outputTokens };
return { reading: null, unavailable, inputTokens, outputTokens };
}
+1 -2
View File
@@ -27,8 +27,7 @@ export type ProductContractBlock =
| "chat-method-boundaries"
| "natal-instructions"
| "mevg-blocked-policy"
| "shared-reading"
| "report-reading";
| "shared-reading";
let cached: string | null = null;
+2 -15
View File
@@ -8,14 +8,7 @@ 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 {
READING_SECTION_WITHHELD_TEXT,
REPORT_READING_BASIS_LABEL,
REPORT_READING_HEADING,
evidenceGradeTag,
readingSectionView,
type ReportReading,
} from "./personal-report-reading-contract";
import { REPORT_READING_BASIS_LABEL, REPORT_READING_HEADING, type ReportReading } from "./personal-report-reading-contract";
export type ReportExportBlock = Readonly<{
id: string;
@@ -89,13 +82,7 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
if (language === "zh" && reading?.language === "zh") {
const text = [
`## ${REPORT_READING_HEADING}`,
...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))];
}),
...reading.sections.flatMap(section => [`### ${section.title}`, ...section.paragraphs]),
REPORT_READING_BASIS_LABEL,
].join("\n\n");
add("body:reading", "body", REPORT_READING_HEADING, text);
@@ -4,7 +4,6 @@ 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 {
@@ -14,7 +13,7 @@ export function createReportReadingWriter(model: ResolvedLanguageModel): ReportR
model: model.model,
instructions: "Follow the system message. Return only the requested JSON.",
});
const writeReading = async (input: Parameters<ReportReadingWriter>[0]) => {
return async function writeReading(input) {
const result = await agent.generate(
[
{ role: "system", content: input.system },
@@ -32,6 +31,4 @@ 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() });
}
@@ -1,79 +0,0 @@
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;
}
@@ -6,8 +6,7 @@ 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");
// 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 reading = reportReadingSchema.parse(JSON.parse(fixture("reading-sample.json")));
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,14 +11,7 @@ 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 { 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 { REPORT_READING_SYSTEM } from "../src/lib/personal-report-reading.ts";
import { buildTechnicalBasis } from "../src/lib/synastry-full-technical.ts";
const BANNED = ["联网", "外部资料", "外部核验", "MEVG", "真实案例", "Real Case", "Global Web"];
@@ -80,10 +73,7 @@ test("synastry technical basis never shows web-verification wording", () => {
});
test("personal report writer prompt never asks for web-verification wording", () => {
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/);
assertNoWebWording(REPORT_READING_SYSTEM, "个人报告写作提示词");
// 置信度封顶仍在,只是理由改成「没有用户经历可对照」。
assert.match(REPORT_READING_SYSTEM, /置信度最高为 B/);
});
+53 -151
View File
@@ -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,35 +34,18 @@ const zhReader = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-
const PARAGRAPH = "你的上升落在金牛座,因为管你自己的 1 宫(你自己、身体、给人的第一印象)由金星主管,金星在盘上力气中等,你给人的感觉是稳、慢热、讲究分寸。做事先看清楚再动手,一旦认定就不太轻易改主意,这份耐心是你最可靠的本钱。熟悉的人会觉得你靠得住,刚认识的人可能要多处一阵才知道你心里热。";
// #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") {
function draft(overrides: Partial<Record<string, string[]>> = {}) {
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]).map((text) => ({
text,
claim_type: SECTION_CLAIM[section.id] ?? null,
grade: SECTION_CLAIM[section.id] ? grade : null,
})),
paragraphs: overrides[section.id] ?? [PARAGRAPH, PARAGRAPH],
})),
};
}
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 的测试输入。
@@ -74,43 +57,22 @@ 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 = accept(draft());
const accepted = acceptReportReadingDraft(draft());
assert.ok(accepted.ok);
// #192 原值:version 2;新值:version 3(逐段等级、逐节状态、加载清单)
assert.equal(accepted.reading.version, 3);
assert.equal(accepted.withheld, 0);
assert.equal(accepted.reading.version, 2);
assert.deepEqual(accepted.reading.sections.map((section) => section.title), ["总评", "性格", "事业", "财富", "感情", "健康", "现在这几年", "几条建议", "依据与边界"]);
assert.equal(accepted.reading.language, "zh");
});
test("「不一定会」 is not a fate claim and is accepted", () => {
assert.deepEqual(withheldIds(draft({ love: [`${PARAGRAPH}这不一定会马上发生。`] })), []);
const accepted = acceptReportReadingDraft(draft({ love: [`${PARAGRAPH}这不一定会马上发生。`] }));
assert.equal(accepted.ok, true);
});
// 原值:段落里出现英文字母即以 latin 退稿
@@ -118,115 +80,68 @@ 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。`;
assert.deepEqual(withheldIds(draft({ career: [professional, PARAGRAPH] })), []);
// #192 原值:整份退稿 field_name;新值:只有这一节不显示(产品 2026-11-12)
assert.deepEqual(withheldIds(draft({ career: [`${PARAGRAPH}parameter_sensitive`] })), ["career:field_name"]);
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");
});
// #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]> = [
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}不构成医疗意见。`] })],
["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 whole) {
const accepted = accept(value);
for (const [reason, value] of cases) {
const accepted = acceptReportReadingDraft(value);
assert.equal(accepted.ok, false, reason);
assert.equal(!accepted.ok && accepted.reason, reason, JSON.stringify(value).slice(0, 120));
}
});
// #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", () => {
// 原值:要求 C1 白话规则(「因为……」、「讲时间时最多说三件」)
// 新值:要求她的 skill 写法:证据在前、领域分盘组合、功能吉凶冲突、双轨应期带日期、行运、A/B/C、状态标签
// 原因:产品 2026-11-07 占星师方案:报告完整运行 skill,按专业写法
test("the prompt carries the astrologer's professional rules and the red lines", () => {
for (const rule of [
"current_advice_allowed", "本命承诺 → 激活 → 落地 → 应期", "能不能挣到", "能不能留下", "心动、成对还是法律婚",
"不从 6、8、12 宫直接推出", "observation_date", "一般建议", "evidence_grades.claims", "must_disclose",
"没有关键阻塞", "本身不是冲突", "checked_houses", "dignity_zh", "不写病名", "不给投资指令",
"「注定」「先苦后甜」", "罗睺、计都", "只输出 JSON", "{{CLAIM_TYPES}}",
"证据在前,判断在后", "D2、D11、A2、A11", "D10、A10、AmK、AK", "D9、UL、A7、DK", "能不能挣到", "能不能留下",
"自然吉凶和功能吉凶不一致", "Vimshottari(大运、小运、细运,起止日期)", "Narayana(当前大运与小运", "两面都写",
"木星、土星、罗睺、计都", "置信度:A/B/C", "「阻塞」", "「参数敏感」", "「待用户经历核验」", "候选",
"宫位第一次出现时", "不写病名", "不给投资指令", "「注定」「先苦后甜」", "罗睺、计都", "只输出 JSON", "basis",
]) {
assert.ok(RULES.includes(rule), rule);
assert.ok(REPORT_READING_SYSTEM.includes(rule), rule);
}
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);
for (const gone of ["讲时间时最多说三件", "力量只说强、中、弱", "不写任何英文字母"]) {
assert.equal(REPORT_READING_SYSTEM.includes(gone), false, gone);
}
});
const PROMPT = { system: "system", skill: SKILL };
function writer(bodies: unknown[], calls: string[] = []): ReportReadingWriter {
return Object.assign(async (input: Parameters<ReportReadingWriter>[0]) => {
return async (input) => {
calls.push(input.user);
const body = bodies.shift();
if (body instanceof Error) throw body;
return { body, inputTokens: 1000, outputTokens: 500 };
}, { prompt: PROMPT });
};
}
// #192 原值:第一稿被退才重试;新值:第一稿有不显示的节也重试,取不显示节更少的一稿
test("one retry after a draft with a withheld section; tokens of both attempts are counted", async () => {
test("one retry after a rejected draft; 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 && outcome.reading.version === 3);
assert.ok(outcome.reading.sections.every((section) => "status" in section && section.status === "ok"));
assert.deepEqual(outcome.reading.skill, SKILL);
assert.ok(outcome.reading);
assert.equal(outcome.inputTokens, 2000);
assert.equal(outcome.outputTokens, 1000);
assert.equal(calls.length, 2);
@@ -243,21 +158,18 @@ 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: Object.assign(() => new Promise<never>(() => undefined), { prompt: PROMPT }) });
const hung = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, timeoutMs: 20, write: () => new Promise(() => undefined) });
assert.equal(hung.reading, null);
assert.equal(hung.unavailable, "model_timeout");
// #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");
const rejected = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([draft({ health: ["要小心糖尿病。"] }), draft({ health: ["要小心糖尿病。"] })]) });
assert.equal(rejected.unavailable, "rejected_banned_phrase");
});
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: Object.assign(({ signal }: Parameters<ReportReadingWriter>[0]) => new Promise<never>((_, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))), { prompt: PROMPT }),
write: ({ signal }) => new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))),
});
controller.abort(new Error("job_cancelled"));
await assert.rejects(pending, /job_cancelled/);
@@ -283,16 +195,13 @@ function snapshot(extra: Record<string, unknown> = {}) {
}
test("snapshots: older ones read without a reading; a stored reading must be well formed", () => {
const reading = (accept(draft()) as { reading: unknown }).reading;
const reading = (acceptReportReadingDraft(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);
// #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 v1 = { version: 1, language: "zh", sections: (reading as { sections: Array<{ id: string; title: string; paragraphs: string[] }> }).sections.slice(0, 8) };
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));
@@ -323,7 +232,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 = (accept(draft()) as { reading: unknown }).reading;
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
const ready = await read(async () => reading as never);
assert.equal(ready.status, 200);
assert.deepEqual(ready.body.reading, reading);
@@ -336,7 +245,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 = (accept(draft()) as { reading: { sections: unknown[] } }).reading;
const reading = (acceptReportReadingDraft(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,
@@ -360,13 +269,6 @@ 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);
@@ -419,10 +321,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 : Object.assign(async () => {
const writeReading = mode === "no-writer" ? undefined : 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 } : {}) });
-30
View File
@@ -248,33 +248,3 @@ 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 -->
-241
View File
@@ -1,241 +0,0 @@
"""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)
-3
View File
@@ -22,7 +22,6 @@ 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:
@@ -899,6 +898,4 @@ 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)
@@ -1,108 +0,0 @@
"""#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