feat(report): give the writer a static interpretation guide

The report writer had no interpretation methodology at all: a local agent
calling the jyotish skill can read the reference library, the report model
could read nothing. It could only restate the bundle.

- frontend/src/lib/report-interpretation-packs/ holds one general pack and
  one pack per report theme, distilled from the in-repo reference guides.
  They constrain wording and reasoning discipline (term modernisation,
  how to talk about relative strength and SAV scores, the reasoning errors
  to avoid, the banned phrasings) and never assert a chart fact.
- The general pack rides INSIDE the cached system message so the cached
  prefix stays byte-identical across sections; the chapter pack follows it
  and summary calls get the general pack only.
- Skill jyotish-personal-report goes to 1.1.0 (1.0.0 deprecated): the
  contract now names interpretiveFacts and themeNarrativeSeeds as a
  bounded fact layer and states that the knowledge pack is not a fact
  source and cannot raise certainty.
- Telemetry records interpretiveFactCount and knowledgePackCharacters as
  numbers only; the counter never throws so telemetry cannot break a run.

Tests lock every theme resolving a pack, the 3,000 character budget, a
forbidden-substring scan (paths, module names, vendor names, artefact
names), the byte-stable cache prefix, and that no evidence id or date
appears in the static content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
This commit is contained in:
Jesse_Chen
2026-09-01 18:54:52 +00:00
parent 90bad10d6f
commit ef1bd6dfa9
14 changed files with 567 additions and 29 deletions
@@ -0,0 +1,29 @@
/**
* Cross-theme interpretation pack. Distilled product copy: it constrains HOW a
* fact is explained and worded, never WHICH facts exist. It contains no paths,
* no module names, no vendor names and no user data.
*/
export const GENERAL_INTERPRETATION_PACK = `【解读方法与措辞规范·通用】
一、表达顺序
每段先说结构,再说这个结构在现实里怎么表现,最后才说可以怎么做。不要先下结论再补理由,也不要只罗列术语。一个结构最多展开三层:它是什么、它在生活里长什么样、当事人可以据此调整什么。
二、术语现代化(必须转译,不要原样堆砌)
· 太阳=自我定位与公开身份;月亮=情绪与内在需求;火星=执行力与冲突处理;水星=分析、沟通与商务;木星=扩张、学习与判断力;金星=审美、关系与资源交换;土星=责任、结构与长期积累;Rahu=突破、非常规与放大;Ketu=收束、专注与抽离。
· 入庙=天赋领域;落陷=需要方法弥补的领域,不是"注定不行";逆行=能量内向化与反复打磨;合相=能力叠加;相位=影响外溢到另一个领域。
· 大运=人生主阶段;小运=阶段内的子周期。用"阶段特征"表达,不要用"运势好坏"。
三、强弱与分值怎么说
六分力排名只说明发力顺序与相对可依赖度,不等于好坏,也不能推出成败。八分力分值只说明该宫位的支持厚度:偏高=阻力小、可持续推进;偏低=需要更多准备与外部条件。任何分值都不要换算成百分比、概率或成功率。
四、多层验证
一条结论至少要有两层证据同时支持(本命结构、分盘、强度层、时间层任取其二)。只有一层时必须写成倾向或需要观察的方向,并说明还缺什么。不同层给出的方向不一致时,如实写出张力,不要挑一层当定论。
五、必须避免的推理错误
· 不要按宫位字面名外推:第8宫是转化与共有资源,不是死亡;第12宫是消耗、独处与远方,不是牢狱;第6宫是日常劳务、竞争与健康管理,不是疾病判定。
· 不要把一个配置的含义扩张到它不负责的领域(例如用第12宫谈婚姻质量、用第2宫谈职场竞争)。
· 不要因为出现某个吉利组合就宣称结果必然发生;组合只说明可能性的通道,还要看时间层是否激活。
· 不要用星座(太阳/月亮所在星座)替代上升与宫位结构来下判断。
六、语气与禁语
直接、克制、可执行。不用"业力""前世""诅咒""厄运""劫难""注定"。不写"必定/一定/肯定/保证/必然/百分之百"。不做医疗诊断、用药、投资标的、法律裁断、生死与生育结果的断言。不提具体日期与月份,除非已明确允许精确时间表达;否则只写阶段与顺序。不要提及任何计算过程、内部流程或资料来源的名称。`;
@@ -0,0 +1,41 @@
import { GENERAL_INTERPRETATION_PACK } from "./general.ts";
import { THEME_INTERPRETATION_PACKS } from "./themes.ts";
/**
* Static, product-owned interpretation packs for the report writer.
*
* These are the only prose the writer agent receives besides the evidence
* bundle. They constrain wording and reasoning discipline; they never assert a
* fact about a chart, never name a technique's implementation, and never carry
* user data. The writer's fact source stays the bundle (claimCards +
* interpretiveFacts + themeNarrativeSeeds).
*
* Hard bounds enforced by tests:
* - each pack is at most PACK_CHARACTER_LIMIT characters;
* - no pack may contain a file path, an internal module name, a vendor/engine
* name, or the word for a prompt/skill artefact.
*/
export const PACK_CHARACTER_LIMIT = 3_000;
export { GENERAL_INTERPRETATION_PACK, THEME_INTERPRETATION_PACKS };
export function interpretationPackForTheme(theme: string | null | undefined): string | null {
if (!theme) return null;
return THEME_INTERPRETATION_PACKS[theme] ?? null;
}
/**
* The system content that rides in front of the prompt-cache boundary. Stable
* across every section call so the cached prefix is reused; the per-theme pack
* is sent separately, after the boundary.
*/
export function cachedInterpretationPreamble(): string {
return GENERAL_INTERPRETATION_PACK;
}
export function allInterpretationPacks(): readonly Readonly<{ id: string; text: string }>[] {
return [
{ id: "general_method", text: GENERAL_INTERPRETATION_PACK },
...Object.entries(THEME_INTERPRETATION_PACKS).map(([id, text]) => ({ id, text })),
];
}
@@ -0,0 +1,72 @@
/**
* Per-theme interpretation packs. Same contract as the general pack: wording
* and reasoning discipline only, never new astrological claims and never a
* statement about a particular person.
*/
export const THEME_INTERPRETATION_PACKS: Readonly<Record<string, string>> = {
career: `【事业主题·解读要点】
主指标:第10宫(社会角色与可见产出)、第6宫(日常劳务与竞争)、第1宫(自我定位)、事业分盘、以及承接职责的相关星曜。
· 把第10宫说成"别人看得见的你在做什么",把第6宫说成"每天真正在处理的事"。两者强度不一致时,写成"对外形象与日常内容错位",而不是"事业好/不好"。
· yogakaraka 或同时管辖角宫与三方宫的星曜,写成"最值得押注的行动主轴",并说明它对应的现实动作(承担责任、做系统、做交付)。
· 六分力靠前的星曜说明"最容易发力的方式",不是"最赚钱的方向"。
· 事业分盘与本命盘方向不一致时,写成"心里想做的和实际被认可的不是同一件事",两边都要写。
禁止:不承诺升职、录用、薪资数字、创业成败或具体公司;不给出跳槽的确定时点;不把行业名当成命定答案,只给能力形态与适配场景。`,
marriage: `【婚恋与长期合作主题·解读要点】
主指标:第7宫(长期对等关系)、九分盘(关系底层质地)、第2宫与第11宫(关系带来的资源与圈层)、以及关系相关的特殊上升点与配偶星。
· 第7宫既是婚姻也是一切长期对等合作,写作时说明这一点,避免读者以为只在谈结婚。
· 九分盘描述的是关系里长期稳定下来的相处方式,本命盘描述的是相遇与初期吸引;两者不同时,写成"吸引点和长期相处的重点不是同一件事"。
· 土星相关配置写成"关系建立慢、但一旦稳定就耐久",不要写成"婚姻延迟"或"晚婚"。
· 谈伴侣画像时只谈可观察的相处风格、节奏与需求,不谈外貌、身份、国籍、财产。
禁止:不预测结婚年份或分手时点;不判断第三方是否忠诚;不评价现有关系该不该继续;不做生育相关结果的断言。`,
wealth: `【财富主题·解读要点】
主指标:第2宫(可留存的资产与收入结构)、第11宫(收入增量与圈层带来的机会)、第5宫(判断力与投入决策)、财富相关分盘与八分力支持度。
· 必须把"赚到"和"留住"分开写:第11宫偏进项与增量,第2宫偏留存与结构。两者强度差异大时,写成"进得来但留不住"或"进项慢但沉淀好"。
· 八分力分值偏低的宫位写成"这条通路需要更多前置条件",不要写成"破财"。
· 财富组合只说明积累通道的形态(靠专业、靠合作、靠资产、靠周期),不说明金额与时点。
禁止:不推荐任何具体投资标的、资产类别、币种或平台;不预测收益率、涨跌与回本时间;不判断借贷是否应该做;不给出税务或法律意见。`,
education: `【教育与学习主题·解读要点】
主指标:第4宫(基础学习与积累)、第5宫(吸收与创造)、第9宫(高阶与体系化学习)、教育相关分盘。
· 区分"学得快"与"学得深":第5宫偏理解与灵感,第9宫偏体系与长期钻研,第4宫偏基础扎实度。
· 学习困难写成"当前方法与自身吸收方式不匹配",给出可换的方法(结构化、实践导向、讨论导向),不要写成"资质不足"。
· 分盘与本命盘不一致时,写成"考试表现与真实掌握程度不一致"。
禁止:不预测考试通过与否、录取结果、学校名次或具体分数;不判断学历路径必须怎么走。`,
migration_home: `【迁移与居所主题·解读要点】
主指标:第4宫(居所与安定感)、第12宫(远方、独处与消耗)、第3宫(短程移动)、第9宫(长程与跨文化)、相关分盘。
· 第12宫写成"离开熟悉环境后的消耗与独处",不要写成"损失"或"隔离"。
· 第4宫与第12宫同时活跃时,写成"想安定与想离开同时存在",两种需求都要给出安放方式。
· 居所议题谈的是环境适配(节奏、密度、通勤、独处空间),不是房产买卖建议。
禁止:不指定国家、城市、方位或搬迁日期;不给购房、租售或移民法律建议;不预测签证与手续结果。`,
family: `【家庭与子女主题·解读要点】
主指标:第4宫(原生环境与母系)、第9宫(父系与价值观来源)、第2宫(家族共同资源)、第5宫(子女与创造)、相关分盘。
· 家庭议题写成"关系模式与角色分工",不评价家庭成员的人品,也不替当事人判断对错。
· 第5宫谈的是创造力与养育关系的相处方式,不是生育能力。
· 代际张力写成"两代人对安全感的定义不同",并给出可操作的边界设定方式。
禁止:不预测生育时间、性别或数量;不判断亲属健康与寿数;不建议断绝或维持任何具体亲属关系。`,
health_pressure: `【健康压力主题·解读要点(非医疗)】
主指标:第6宫(日常负荷与恢复)、第1宫(体感与精力基线)、第8宫(长期消耗与转化)、压力相关分盘。
· 全篇只谈"压力来源、负荷节奏与恢复方式",不谈器官、疾病、症状或诊断。
· 第6宫写成"每天消耗你的事情的形状",第1宫写成"精力的基线与恢复速度"。
· 给出的建议限于作息节奏、工作负荷分配、压力释放方式与就医提醒("持续不适请交给专业医疗人员判断")。
禁止:不做任何诊断、不点名疾病、不建议用药或停药、不预测健康事件的时间、不评估寿数。`,
timing: `【阶段与时机主题·解读要点】
主指标:主运与副运的层级关系、多套时间系统是否指向同一领域、以及本命结构是否本来就允许该事发生。
· 时间层只能"激活"本命已经存在的结构。本命没有的承诺,时间层再强也不写成会发生。
· 主运定基调,副运定当下重点;两者不一致时写成"大方向与眼前重点不同步"。
· 多套时间系统同时指向同一领域时,写成"该领域是本阶段的主线",仍然不给具体日期。
· 未获准精确时间表达时,只写阶段顺序(现在处于哪一段、下一段的重点会转向哪里),不写年份、月份与日期。
禁止:不给择日建议;不预测具体事件发生的日期;不把阶段特征写成不可改变的命运。`,
general: `【综合基础章·解读要点】
主指标:上升与第1宫(自我定位与出场方式)、功能吉凶星(哪些星曜在本盘天然站在你这边)、六分力顺序(发力方式)、当前阶段。
· 本章负责建立"这个人怎么运作"的整体框架,供后面各主题引用,不抢主题章的具体结论。
· 功能吉凶是按上升推出的角色分工,不是性格好坏;写成"哪几股力量在本盘是助力,哪几股需要方法去驾驭"。
· 先写稳定的结构(上升、角色分工),再写当前阶段,让读者知道哪些是长期的、哪些是这一段时间的。
禁止:不在本章下任何主题性的确定结论;不做性格标签化的定性(如"你是内向的人"),只描述倾向与适配场景。`,
};
+13 -10
View File
@@ -1,13 +1,16 @@
export type ReportTheme =
| "career"
| "marriage"
| "wealth"
| "education"
| "migration_home"
| "family"
| "health_pressure"
| "timing"
| "general";
export const REPORT_THEME_IDS = [
"career",
"marriage",
"wealth",
"education",
"migration_home",
"family",
"health_pressure",
"timing",
"general",
] as const;
export type ReportTheme = (typeof REPORT_THEME_IDS)[number];
export type ReportThemeEvidencePlan = Readonly<{
theme: ReportTheme;
+68 -9
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import type { ResolvedLanguageModel } from "./model";
import type { PersonalReportSectionPlan, ReportSectionPlanEntry } from "@/lib/personal-report-plan";
import { cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage, agentGenerationSettings } from "@/lib/agent-generation-settings";
import { cachedInterpretationPreamble, interpretationPackForTheme } from "@/lib/report-interpretation-packs";
import type {
ReportChartHouse,
ReportDashaPeriod,
@@ -147,8 +148,30 @@ export type PersonalReportAgentTelemetry = Readonly<{
totalTokens: number | null;
finishReason: string | null;
repairAttempted: boolean;
/** Metric only: how many interpretive rows this call's bundle carried. */
interpretiveFactCount: number;
/** Metric only: characters of static knowledge pack sent with this call. */
knowledgePackCharacters: number;
}>;
/**
* Counts the interpretive rows in a bundle. Numbers only — never content.
* Telemetry must never break generation, so a bundle without the block (or a
* malformed one) counts as zero instead of throwing.
*/
export function countInterpretiveFacts(bundle: ReportEvidenceBundleV2): number {
const facts = bundle?.interpretiveFacts as ReportEvidenceBundleV2["interpretiveFacts"] | undefined;
if (!facts) return 0;
const size = (value: unknown) => (Array.isArray(value) ? value.length : 0);
return size(facts.yogas)
+ size(facts.functionalRoles)
+ size(facts.shadbalaRanking)
+ size(facts.savScores)
+ size(facts.convergenceDomains)
+ (facts.currentDasha ? 1 : 0)
+ (typeof facts.savTotal === "number" ? 1 : 0);
}
const personalReportInstructions = `You are the dedicated Personal Report writer for a Vedic astrology product. You write long structured report sections in Simplified Chinese. This is a report, not a chat: do not use chat-style short paragraphs, do not ask follow-up questions, and do not append hidden blocks.
The user message contains the ONLY allowed facts: a server-computed ReportEvidenceBundleV2. Use its claimCards exclusively for narrative conclusions. Its interpretiveFacts (yogas, functionalRoles, shadbalaRanking, savScores, currentDasha, convergenceDomains) and themeNarrativeSeeds are the supporting fact layer: you may quote, order, group and explain them, but they never authorise a conclusion that the matching claimCard does not already state. Never invent, recalculate, or infer planetary positions, house lords, dasha boundaries, divisional charts, shadbala/ashtakavarga values, yogas, or timing windows that are not present in those fields. Never mention server internals, tool names, engine names, providers, skill files, prompts, paths, hashes or methodology details.
@@ -164,7 +187,9 @@ Structure rules:
- executiveSummary.headline is one calm, concise Chinese sentence of at most 200 characters; it must not contain dates, timing windows, or deterministic claims.
- Write only thematic sections whose plan disposition is write. Section id, theme, and evidenceRefs must exactly match the plan entry. Every narrative conclusion must trace to the matching claimCard. Never output a blocked plan entry; the server creates those disclosures deterministically.
- Keep actions concrete and cautious; caveats must state limits honestly.
- Write formal, readable Simplified Chinese for a printed report.`;
- Write formal, readable Simplified Chinese for a printed report.
The system messages also carry a static interpretation guide (wording, reasoning discipline, forbidden phrasing). It governs HOW you explain a fact and how you word it. It is never a source of facts: it must never produce an astrological statement that the bundle does not already contain, and it can never raise certainty.`;
async function readUsage(value: unknown) {
try {
@@ -275,6 +300,18 @@ function sectionPrompt(
return `请只生成以下一个个人报告 thematicNarrative 条目。严格输出单个 JSON 对象,不要数组、Markdown 或额外文字。只使用给定证据;section id、theme、evidenceRefs 必须与 plan 完全一致。已完成章节标题仅用于避免重复,不要复述正文。\n${JSON.stringify({ bundle, plan: section, completedSectionTitles: completedTitles })}`;
}
/**
* System content sent ahead of the report input. Index 0 is the prompt-cache
* prefix and stays byte-identical for every call so the cache is actually
* reused; the per-theme pack follows it and is intentionally not cached.
* Summary calls get the general pack only.
*/
export function buildWriterSystemContents(theme: string | null | undefined): readonly string[] {
const cached = `${cachedInterpretationPreamble()}\n\n【上下文缓存边界】后续内容为本次报告输入。`;
const themePack = theme ? interpretationPackForTheme(theme) : null;
return themePack ? [cached, themePack] : [cached];
}
function summaryPrompt(
sections: readonly Readonly<{ title: string; claimStatus: string }>[],
): string {
@@ -305,6 +342,10 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
schema: z.ZodType<T>;
signal?: AbortSignal;
maxOutputTokens?: number;
/** Theme whose interpretation pack rides after the cache boundary. */
themePack?: string | null;
/** Metric only: interpretive rows in the bundle this call received. */
interpretiveFactCount?: number;
accept: (value: T) => void;
}>): Promise<T> => {
const startedAt = Date.now();
@@ -312,10 +353,21 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
const prompt = input.prompt;
let repairAttempted = false;
let attemptReturned = false;
const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本次报告输入。", model.model);
// The general interpretation pack sits INSIDE the cached system message so
// the cached prefix stays byte-identical across every section call. The
// per-theme pack changes each section, so it goes after the boundary.
const [cachedContent, ...uncachedContents] = buildWriterSystemContents(input.themePack);
const cacheBoundary = cachedSystemMessage(cachedContent, model.model)
?? { role: "system" as const, content: cachedContent };
const metrics = {
interpretiveFactCount: input.interpretiveFactCount ?? 0,
knowledgePackCharacters: cachedContent.length
+ uncachedContents.reduce((total, item) => total + item.length, 0),
};
const runOnce = (content: string) => agent.generate(
[
...(cacheBoundary ? [cacheBoundary] : []),
cacheBoundary,
...uncachedContents.map((item) => ({ role: "system" as const, content: item })),
{ role: "user", content },
],
{
@@ -345,11 +397,11 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
const accepted = accept(first);
if (accepted.ok) {
await recordUsage(first.usage);
await logTelemetry(model.id, startedAt, false, "resolved", first.usage, first.finishReason);
await logTelemetry(model.id, startedAt, false, "resolved", first.usage, first.finishReason, metrics);
return accepted.data;
}
await recordUsage(first.usage);
await logTelemetry(model.id, startedAt, false, "failed", first.usage, first.finishReason);
await logTelemetry(model.id, startedAt, false, "failed", first.usage, first.finishReason, metrics);
repairAttempted = true;
attemptReturned = false;
const repaired = await runOnce(`${prompt}${REPAIR_PROMPT_SUFFIX}`);
@@ -357,18 +409,18 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
const repairedAccepted = accept(repaired);
if (repairedAccepted.ok) {
await recordUsage(repaired.usage);
await logTelemetry(model.id, startedAt, true, "resolved", repaired.usage, repaired.finishReason);
await logTelemetry(model.id, startedAt, true, "resolved", repaired.usage, repaired.finishReason, metrics);
return repairedAccepted.data;
}
await recordUsage(repaired.usage);
await logTelemetry(model.id, startedAt, true, "failed", repaired.usage, repaired.finishReason);
await logTelemetry(model.id, startedAt, true, "failed", repaired.usage, repaired.finishReason, metrics);
if (repairedAccepted.error) throw repairedAccepted.error;
throw new PersonalReportAgentOutputError();
} catch (error) {
if (error instanceof PersonalReportAgentOutputError) throw error;
if (isAbortError(error, input.signal)) throw error;
if (error instanceof Error && error.message.startsWith("report_writer_")) throw error;
if (!attemptReturned) await logTelemetry(model.id, startedAt, repairAttempted, "failed", null, null);
if (!attemptReturned) await logTelemetry(model.id, startedAt, repairAttempted, "failed", null, null, metrics);
throw error;
}
};
@@ -382,6 +434,7 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
prompt: buildReportPrompt(bundle, plan),
schema: personalReportAgentOutputSchema,
signal,
interpretiveFactCount: countInterpretiveFacts(bundle),
accept: (output) => options?.assertWriterOutput?.(output),
});
},
@@ -390,6 +443,8 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
schema: personalReportThematicNarrativeSchema,
signal: options?.signal,
maxOutputTokens: options?.maxOutputTokens,
themePack: section.theme,
interpretiveFactCount: countInterpretiveFacts(bundle),
accept: (output) => options?.assertWriterOutput?.(output),
}),
generateSummary: (sections, options) => runStructured({
@@ -409,6 +464,7 @@ async function logTelemetry(
outcome: "resolved" | "failed",
usage: unknown,
finishReason: unknown,
metrics: Readonly<{ interpretiveFactCount: number; knowledgePackCharacters: number }>,
) {
const [tokens, finishReasonValue] = await Promise.all([
readUsage(usage),
@@ -423,8 +479,11 @@ async function logTelemetry(
totalTokens: tokens.totalTokens,
finishReason: finishReasonValue,
repairAttempted,
interpretiveFactCount: metrics.interpretiveFactCount,
knowledgePackCharacters: metrics.knowledgePackCharacters,
};
// Telemetry must never include the prompt, the packet, birth data or the
// report body.
// report body. interpretiveFactCount and knowledgePackCharacters are counts
// and lengths only — never the facts, the seeds or the pack text.
console.info("[personal-report-agent]", JSON.stringify(telemetry));
}