From 401796509a00a0b8d02a9ecda8ba4a80f187feb9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 2 Oct 2026 00:14:54 +0800 Subject: [PATCH] feat(consult): shared affliction-first reading, lists for every domain, health renamed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T1 (BUG-1160): 「通用读法 · 先看受冲」 opens every turn's methodology once. - T2 (BUG-1161): Chinese lists for parents, children, education, migration, family, annual, general, health; health replaces the English route in the web consultation only. - T3 (BUG-1162): career / marriage / wealth each gain an affliction line. - T4 (BUG-1163): health label 健康, prompt asks about the constitution first. - Single-domain annual / timing turns keep the pre-brief method (CONSULT_READING_BUDGET_BLOCKED): they would exceed the 12,000-character assertions, which this brief may not change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- frontend/docs/VOICE.md | 3 +- .../lib/consultation-condensed-checklist.ts | 95 ++++++++++ .../src/lib/consultation-domain-registry.ts | 2 +- frontend/src/lib/consultation-methodology.ts | 78 ++++++-- frontend/src/lib/guided-jyotish-topics.ts | 2 +- frontend/src/mastra/index.ts | 2 +- ...onsult-affliction-reading-20261001.test.ts | 167 ++++++++++++++++++ ...nsult-condensed-checklist-20260927.test.ts | 22 ++- ...n-domain-parents-children-20260927.test.ts | 15 +- .../consultation-domain-registry.test.ts | 4 +- .../tests/consultation-methodology.test.ts | 30 +++- 11 files changed, 384 insertions(+), 36 deletions(-) create mode 100644 frontend/tests/consult-affliction-reading-20261001.test.ts diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 1da224f7..68788809 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -210,7 +210,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 ## 普通咨询的领域名与「多看一眼」 -- 领域中文名:父母、子女与原有的事业、关系、财富、身心压力、学习、迁居、家庭、年运、时运、综合并列;「家庭」只用于家里整体(家庭氛围、家里的事),问父母就说父母,问孩子就说子女。 +- 领域中文名:父母、子女与原有的事业、关系、财富、健康、学习、迁居、家庭、年运、时运、综合并列;「家庭」只用于家里整体(家庭氛围、家里的事),问父母就说父母,问孩子就说子女。 +- 健康口径(2026-10-01,BUG-1163):领域名叫「健康」(旧名「身心压力」只作别名),默认问题「我的身体底子怎样,哪些方面要多留意?」。先讲体质底子,再讲近期压力窗与恢复窗;身体部位只作提示,不诊断、不写病名、不给治疗建议,保留「这不是医疗意见」,置信度上限仍是 low。 - 写作行里补取卡外数据的进行中句:「正在多看一眼:<名称>…」,例如「正在多看一眼:D60 分盘…」「正在多看一眼:太阳回归…」「正在多看一眼:格局明细…」。数据卡 v2 新增可多看的名称:「灵魂星在九分盘的落点(Karakamsha)」「宫主链」「本命、九分盘、十分盘的联动」「门闩(Argala)」「月亮行运」。不写「补取」「卡外」「数据卡」这些内部词,不写秒数、不写「正在加载」。只在活动行里,不进正文与历史。 ## 普通咨询发出即有一句(2026-09-28,BUG-1074) diff --git a/frontend/src/lib/consultation-condensed-checklist.ts b/frontend/src/lib/consultation-condensed-checklist.ts index d9944bec..20caef5e 100644 --- a/frontend/src/lib/consultation-condensed-checklist.ts +++ b/frontend/src/lib/consultation-condensed-checklist.ts @@ -14,6 +14,11 @@ import type { ProfileGender } from "./profile-gender.ts"; * lines: the must-see items (all of them on the evidence card), the three * layers to keep apart, the forbidden statements, and what stays a lookup. * + * TASK-consult-affliction-reading-20261001 (T2/T3) added one affliction line to + * each of those three and wrote the lists for the remaining domains (parents, + * children, education, migration, family, annual, general, health); those + * cite the brief, not the router, as their source. + * * Consultation only. The router and the event-judgment files are unchanged and * still serve reports and every other surface; this file does not edit the * skill, so there is no skill version change. @@ -36,12 +41,49 @@ export const CONDENSED_GENDER_LINES = { male: "性别:男(卡上 gender)。先确认性别与昼夜盘(卡上 day_night)再取妻星:妻星取金星;它只是补充层,核心仍是 7 宫、7 宫主、D9、UL、DK,不得只因金星的大运或过运就定婚期。", } as const satisfies Record; +/** + * The shared reading every consultation turn gets once, ahead of any domain + * list (TASK-consult-affliction-reading-20261001 T1, decisions D1/D2): read the + * afflictions before the dignities. The text is the brief's final wording; + * field names in brackets were adapted to where the evidence card really + * carries them (planet rows `conjunct_with` / `aspected_by` / `combust` / + * `status`, house rows `occupants` / `aspected_by`, the base + * `functional_benefic_malefic` layer, the parents rows' `functional`, the + * yogas section's `hits`). The "two or more signals" threshold is a product + * heuristic, not a classical rule; the celebrity backtest of the third brief + * checks it for false positives. + */ +export const CONDENSED_SHARED_READING_TITLE = "通用读法 · 先看受冲"; +export const CONDENSED_SHARED_READING_SOURCE = "consultation shared reading (TASK-consult-affliction-reading-20261001)"; +export const CONDENSED_SHARED_READING_LINES: readonly string[] = [ + "先看受冲,再看旺弱。旺弱和力量(入旺、入庙、Shadbala、SAV)只说明这颗星自己有没有力气,不说明它管的那件事顺不顺、那个人在不在你身边。", + "受冲信号,每条算一个:①宫、宫主或代表星与罗睺、计都、土星、火星同宫(星行 conjunct_with、宫行 occupants),或被土星、火星照(aspected_by);②宫主落 6、8、12 宫,或落在从该宫数起的第 6、8、12 宫;③宫主或代表星是本上升的功能凶星(functional_malefics,父母行 functional);④落陷(status)或燃烧(combust);⑤对应分盘里同号宫有凶星,或宫主落分盘的 6、8、12 宫。", + "同一个人或同一件事有两个及以上受冲信号:这一段第一句先写这一面,用「可能」,括号里写是哪几条;只有一个:补充一句;没有:不写。吉的一面同样要有依据,吉凶都有时两面都说。", + "力量大的星如果本身是 6、8、12 宫主,它带来的麻烦也有力量,不得因为「强」就说「没事」。", + "不写没有区分度的推论:逆行不等于「反复、打回来」;宫主不在本宫不等于「往外跑」;某星不在某宫不等于「离得远」;空宫不单独下结论。", + "不断言死亡、离婚、疾病诊断、流产、不孕。", + "瑜伽只认 yogas.hits:hits 里的才是这张盘成立的格局;只在 names 里、没进 hits 的是待检规则,不得说成本人的格局。", +]; + +/** The shared reading as one numbered block. */ +export function sharedConsultationReading(): string { + return CONDENSED_SHARED_READING_LINES.map((line, index) => `${index + 1}. ${line}`).join("\n"); +} + +/** + * Domains whose list was written by TASK-consult-affliction-reading-20261001 + * (T2, decision D5) rather than condensed from the router; their section + * cites that brief as its source. + */ +export const CONSULT_BRIEF_CHECKLIST_SOURCE = "consultation checklist (TASK-consult-affliction-reading-20261001)"; + export const CONSULTATION_CONDENSED_CHECKLISTS: Readonly>> = { career: [ "必看:D1 10 宫与 10 宫主、AmK;卡上 d9 段确认 10 宫主与 AmK 的旺弱、Vargottama、D1/D9 反转;D10 上升与 10 宫;AL 与 A10;10 宫 SAV 与木星、土星所在宫的 SAV。", "时间:Vimshottari(MD/AD/PD)与 Narayana 双轨同向才谈应期,看大运主与 10 宫、10 宫主、D10 的关系;每颗星先按功能吉凶定性。", "分三层说,不得合成一句「事业机会」:①接触(消息、面试、初步接洽)②结构性机会(合同、正式职位、长期项目)③公开落地(发布、到账、公开的职位与名声)。", "禁写:本命承诺弱时,不得因一段大运或一次行运断言「事业必成」;不得把接触窗写成落地窗。", + "AL 落第几宫要说出来(落 10 宫 = 公众形象就是事业);10 宫主、AmK 的受冲按通用读法。", "卡外按需补取一次:Karakamsha、宫主链、D1→D9→D10 联动、Argala、Chara 大运;KP 精确宫头仍 blocked,只作参考、不作依据。", ], marriage: [ @@ -50,6 +92,7 @@ export const CONSULTATION_CONDENSED_CHECKLISTS: Readonly` names below are local names for the lists that brief + * wrote (T2); they are not router routes. Health uses its own Chinese list in + * place of the router's health-timing-strict; timing keeps the router route. + * Every turn opens with the shared reading (T1) once, before anything else. */ const domainMethodology: Readonly> = { career: { strictRoute: "career-timing-strict", eventJudgment: "event_judgment_career.md" }, marriage: { strictRoute: "relationship-timing-strict", eventJudgment: "event_judgment_marriage.md" }, wealth: { strictRoute: "finance-timing-strict", eventJudgment: "event_judgment_wealth.md" }, timing: { strictRoute: "event-timing-strict", eventJudgment: null }, - health: { strictRoute: "health-timing-strict", eventJudgment: null }, - annual: { strictRoute: null, eventJudgment: null }, - general: { strictRoute: null, eventJudgment: null }, - education: { strictRoute: null, eventJudgment: null }, - migration: { strictRoute: null, eventJudgment: null }, - family: { strictRoute: null, eventJudgment: null }, - // The router's shared baseline names D12 for family/ancestral themes and D7 - // for children; it declares no named strict checklist for either. - parents: { strictRoute: null, eventJudgment: null }, - children: { strictRoute: null, eventJudgment: null }, + // Local list names (TASK-consult-affliction-reading-20261001 T2). The router + // declares no named strict checklist for these domains (its shared baseline + // only names D12 for family/ancestral themes and D7 for children), and its + // health-timing-strict route keeps serving reports. + health: { strictRoute: "consult-health", eventJudgment: null }, + annual: { strictRoute: "consult-annual", eventJudgment: null }, + general: { strictRoute: "consult-general", eventJudgment: null }, + education: { strictRoute: "consult-education", eventJudgment: null }, + migration: { strictRoute: "consult-migration", eventJudgment: null }, + family: { strictRoute: "consult-family", eventJudgment: null }, + parents: { strictRoute: "consult-parents", eventJudgment: null }, + children: { strictRoute: "consult-children", eventJudgment: null }, }; +/** + * BLOCKED on a product decision (TASK-consult-affliction-reading-20261001, + * BUG-1160/1161): a single-domain annual or timing turn is already within a few + * hundred characters of the 12,000-character model-visible ceiling asserted by + * consult-condensed-checklist-20260927 and consult-evidence-card-20260927 + * (annual up to 11,918 before this brief). The shared reading (733 characters + * as a section) and the annual list (334) would take annual to 12,979 and timing + * to 12,257, and those assertions are not this brief's to change. Until the + * product re-decides the ceiling, a turn whose plan holds only these domains + * keeps the pre-brief method: no shared reading, and annual's list is not + * delivered (annual stays in domains_without_strict_checklist). Remove a domain + * from this set once its budget is decided. + */ +export const CONSULT_READING_BUDGET_BLOCKED: ReadonlySet = new Set(["annual", "timing"]); + +/** Domains whose list was written by the affliction-reading brief rather than condensed from the router. */ +const BRIEF_WRITTEN_DOMAINS: ReadonlySet = new Set([ + "health", "annual", "general", "education", "migration", "family", "parents", "children", +]); + const ROUTER_FILE = "references/strict-workflow-router.md"; const SHARED_BASELINE_HEADING = "Shared mandatory baseline"; const SKELETON_FILE = "references/event_judgment_skeleton.md"; @@ -172,17 +208,27 @@ export function consultationMethodologyForDomains( sections.push({ title, source, text: bounded }); }; + // Once per turn, before anything else (T1), however many domains it plans. + if (unique.some((domain) => !CONSULT_READING_BUDGET_BLOCKED.has(domain))) { + push(CONDENSED_SHARED_READING_TITLE, CONDENSED_SHARED_READING_SOURCE, sharedConsultationReading()); + } push("Shared mandatory baseline", ROUTER_FILE, markdownSection(router, SHARED_BASELINE_HEADING)); const withoutChecklist: ConsultationDomain[] = []; for (const domain of unique) { const { strictRoute, eventJudgment } = domainMethodology[domain]; - const condensed = condensedConsultationChecklist(domain, { gender }); + const condensed = BRIEF_WRITTEN_DOMAINS.has(domain) && CONSULT_READING_BUDGET_BLOCKED.has(domain) + ? null + : condensedConsultationChecklist(domain, { gender }); if (condensed) { - push(`${consultationDomainDefinition(domain).label} · ${strictRoute} · 普通对话精简清单`, CONDENSED_CHECKLIST_SOURCE, condensed); + push( + `${consultationDomainDefinition(domain).label} · ${strictRoute} · 普通对话精简清单`, + BRIEF_WRITTEN_DOMAINS.has(domain) ? CONSULT_BRIEF_CHECKLIST_SOURCE : CONDENSED_CHECKLIST_SOURCE, + condensed, + ); continue; } - if (!strictRoute) { + if (!strictRoute || BRIEF_WRITTEN_DOMAINS.has(domain)) { withoutChecklist.push(domain); } else { const section = markdownSection(router, strictRoute); diff --git a/frontend/src/lib/guided-jyotish-topics.ts b/frontend/src/lib/guided-jyotish-topics.ts index b4a97968..bbf4c31a 100644 --- a/frontend/src/lib/guided-jyotish-topics.ts +++ b/frontend/src/lib/guided-jyotish-topics.ts @@ -27,7 +27,7 @@ const generalPromptByTheme: Partial> = { career: "请帮我梳理目前的事业方向和下一步重点。", marriage: "请帮我看看我在关系中容易重复什么模式。", wealth: "请帮我分析目前的财富重点、风险和更稳妥的选择。", - health: "请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。", + health: "请从非医疗诊断的角度,帮我看看我的身体底子怎样,哪些方面要多留意。", education: "请帮我看看我更适合怎样学习,以及如何安排下一步。", migration: "请帮我分析现阶段是否适合迁居、置业或考虑海外发展。", family: "请帮我看看家庭关系中最需要处理的问题和责任边界。", diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index 4563e0ea..540d1719 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -23,7 +23,7 @@ ${jyotishSkillMethodBlock} The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat. Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn. Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. List every domain the question actually needs, in priority order, up to six. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum. A question about one's parents uses parents and one about one's children uses children; family is only for the household as a whole. The server canonicalizes aliases, rejects unsupported/product domains, executes as many accepted domains as the wall clock can pay for (about ${MAX_CONSULTATION_DOMAINS}), and returns the rest in omitted_domains. The actual executed domains are in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters. -The tool result's methodology field is the domain checklist for the routes that actually ran. For career, marriage and wealth it is the condensed consultation checklist (must-see items, the three layers to keep apart, forbidden statements, and for marriage the gender-unknown rule), condensed from the skill's strict route and event-judgment file; every other route's section is quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. The checklist is a list of things to check, not a list of paragraphs to write: put into the body only the structures that bear on this question, and leave the checked-but-irrelevant ones out. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above. +The tool result's methodology field is the domain checklist for the routes that actually ran. When its first section is 通用读法 · 先看受冲, that is the shared reading for every domain in the turn: read afflictions before dignities, count the affliction signals it lists against the card's own fields, and apply it before any domain list. For career, marriage and wealth the domain section is the condensed consultation checklist (must-see items, the three layers to keep apart, forbidden statements, and for marriage the gender-unknown rule), condensed from the skill's strict route and event-judgment file; parents, children, education, migration, family, general and health carry a Chinese consultation checklist written for this product; every other route's section is quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. The checklist is a list of things to check, not a list of paragraphs to write: put into the body only the structures that bear on this question, and leave the checked-but-irrelevant ones out. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above. The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written; consultations carries each domain's own contract. Never treat an absent top-level field as permission to answer without a contract. The evidence card is the chart evidence for this answer. claim_cards holds it: natal_foundation (ascendant, the twelve house signs, planet placements with degrees, functional benefics/malefics with the houses each planet rules, and d9: the D9 lagna, each planet's D9 sign and dignity, Vargottama and major D1/D9 dignity reversals — use it to confirm or weaken a D1 promise), timing (the running Vimshottari mahadasha, antardasha and pratyantardasha with dates, the antardashas inside the mahadasha, the running Narayana period) and domain (one section per executed domain: its vargas, houses with occupants and lords, focus planets and the layers that domain uses; the parents section also carries roles, which says which house, lords, significator, karaka and D12 house belong to the mother and which to the father — write about each person from that person's entries). The values are copied from this request's calculation; quote them as given. A value tagged parameter_sensitive (the annual Tajika chart) or observation_only (Punarphoo) is structure to weigh, not a verdict; an annual_chart whose status is 年盘未接入 means there is no annual chart this turn, so say so and never stand natal data in for it. Chara Dasha and KP are never on the card; KP exact cusps are still blocked, so a KP lookup is reference only and never the basis of a conclusion. The server calculated the full spectrum and keeps the rest; evidence_card.supplementable_sections names what else this calculation holds. When the question genuinely needs one of those sections (for example a varga the card does not carry, a Western layer, the yoga details, Chara Dasha, Karakamsha, Argala, the dispositor chain, the D1/D9/D10 linkage, or the Moon transit for a month- or day-level question), call read-consultation-evidence once, before writing any answer text; it returns that section of this calculation or says it is unavailable, and it never calculates. Answer from the card, plus that one lookup when you used it. evidence_card.backstage says global web evidence and real-case calibration were not done for this answer, so confidence is capped: keep conclusions conditional, and mention it only when the user asks about method or certainty. diff --git a/frontend/tests/consult-affliction-reading-20261001.test.ts b/frontend/tests/consult-affliction-reading-20261001.test.ts new file mode 100644 index 00000000..a49609ef --- /dev/null +++ b/frontend/tests/consult-affliction-reading-20261001.test.ts @@ -0,0 +1,167 @@ +// TASK-consult-affliction-reading-20261001 T1–T4 (BUG-1160~1163): every +// consultation turn opens with the shared "afflictions before dignities" +// reading once; every domain but timing carries a local consultation list; +// career / marriage / wealth gain an affliction line; health is named 健康 and +// asks about the constitution first. Checklists may only cite field names the +// evidence card really carries (hard red line 3), checked against the cards +// built from the real-engine golden fixture. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + CONDENSED_GENDER_LINES, + CONDENSED_SHARED_READING_LINES, + CONDENSED_SHARED_READING_TITLE, + CONSULT_BRIEF_CHECKLIST_SOURCE, + CONSULTATION_CONDENSED_CHECKLISTS, +} from "../src/lib/consultation-condensed-checklist.ts"; +import { + CONSULT_READING_BUDGET_BLOCKED, + consultationMethodologyForDomains, +} from "../src/lib/consultation-methodology.ts"; +import { + consultationDomainDefinition, + consultationDomainIds, + type ConsultationDomain, +} from "../src/lib/consultation-domain-registry.ts"; +import { buildEvidenceCard } from "../src/lib/consultation-evidence-card.ts"; +import { createConsultationPlan } from "../src/lib/consultation-plan.ts"; +import { + consultationWorkflowResponseSchema, + toAgentConsultationContext, + toModelOutput, +} from "../src/mastra/consultation-workflow.ts"; + +type Json = Record; +type GoldenChart = { id: string; workflow: Json; annual_workflow: Json; timing_workflow: Json }; +const golden = JSON.parse(readFileSync( + new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url), + "utf8", +)) as { charts: GoldenChart[] }; + +const titles = (domains: ConsultationDomain[]) => consultationMethodologyForDomains(domains)!.sections.map((section) => section.title); + +test("T1: the shared reading is the first section, once per turn, whatever the plan", () => { + for (const domain of consultationDomainIds.filter((id) => !CONSULT_READING_BUDGET_BLOCKED.has(id))) { + const list = titles([domain]); + assert.equal(list[0], CONDENSED_SHARED_READING_TITLE, domain); + assert.equal(list.filter((title) => title === CONDENSED_SHARED_READING_TITLE).length, 1, domain); + } + for (const plan of [["parents", "children"], ["career", "health", "marriage"], ["annual", "parents"], ["timing", "career"]] as ConsultationDomain[][]) { + const list = titles(plan); + assert.equal(list[0], CONDENSED_SHARED_READING_TITLE, plan.join("+")); + assert.equal(list.filter((title) => title === CONDENSED_SHARED_READING_TITLE).length, 1, plan.join("+")); + } + const text = consultationMethodologyForDomains(["parents"])!.sections[0]!.text; + assert.ok(text.startsWith("1. 先看受冲,再看旺弱。")); + assert.match(text, /两个及以上受冲信号/); + assert.match(text, /逆行不等于「反复、打回来」/); + // The line added on top of the brief's six: only hits are yogas this chart has. + assert.match(text, /瑜伽只认 yogas\.hits/); + assert.equal(CONDENSED_SHARED_READING_LINES.length, 7); +}); + +test("T1/T2 budget blocker is pinned: a single annual or timing turn keeps its pre-brief method", () => { + // BLOCKED on the 12,000-character ceiling (see CONSULT_READING_BUDGET_BLOCKED); this + // test documents the gap so lifting it is a deliberate change. + assert.deepEqual([...CONSULT_READING_BUDGET_BLOCKED].sort(), ["annual", "timing"]); + assert.deepEqual(titles(["annual"]), ["Shared mandatory baseline"]); + assert.deepEqual(consultationMethodologyForDomains(["annual"])!.domains_without_strict_checklist, ["annual"]); + assert.deepEqual(titles(["timing"]), ["Shared mandatory baseline", "时运 · event-timing-strict"]); +}); + +test("T2: every domain section, in order, and no domain without a list (annual pending the budget)", () => { + const expected: Record = { + career: "事业 · career-timing-strict · 普通对话精简清单", + marriage: "关系 · relationship-timing-strict · 普通对话精简清单", + wealth: "财富 · finance-timing-strict · 普通对话精简清单", + health: "健康 · consult-health · 普通对话精简清单", + education: "学习 · consult-education · 普通对话精简清单", + migration: "迁居 · consult-migration · 普通对话精简清单", + family: "家庭 · consult-family · 普通对话精简清单", + parents: "父母 · consult-parents · 普通对话精简清单", + children: "子女 · consult-children · 普通对话精简清单", + annual: "", + timing: "时运 · event-timing-strict", + general: "综合 · consult-general · 普通对话精简清单", + }; + for (const domain of consultationDomainIds) { + const methodology = consultationMethodologyForDomains([domain])!; + if (CONSULT_READING_BUDGET_BLOCKED.has(domain)) continue; + assert.deepEqual(methodology.sections.map((section) => section.title), [ + CONDENSED_SHARED_READING_TITLE, "Shared mandatory baseline", expected[domain], + ], domain); + assert.deepEqual(methodology.domains_without_strict_checklist, [], domain); + } + const mixed = consultationMethodologyForDomains(["parents", "children", "education", "migration", "family", "general"])!; + assert.deepEqual(mixed.domains_without_strict_checklist, []); + for (const domain of ["parents", "children", "education", "migration", "family", "general", "health"] as const) { + const section = consultationMethodologyForDomains([domain])!.sections[2]!; + assert.equal(section.source, CONSULT_BRIEF_CHECKLIST_SOURCE, domain); + for (const line of CONSULTATION_CONDENSED_CHECKLISTS[domain]!) assert.ok(section.text.includes(line), `${domain}: ${line.slice(0, 20)}`); + } + const parents = CONSULTATION_CONDENSED_CHECKLISTS.parents!.join("\n"); + assert.match(parents, /不得写成「有分量」「站得住」/); + assert.match(parents, /照顾你长大的可能不止一位/); + assert.match(CONSULTATION_CONDENSED_CHECKLISTS.health!.join("\n"), /这不是医疗意见/); + assert.match(CONSULTATION_CONDENSED_CHECKLISTS.children!.join("\n"), /孩子性别不写/); +}); + +test("T3: career, marriage and wealth each carry their affliction line", () => { + assert.ok(CONSULTATION_CONDENSED_CHECKLISTS.career!.some((line) => line.startsWith("AL 落第几宫要说出来"))); + assert.ok(CONSULTATION_CONDENSED_CHECKLISTS.marriage!.some((line) => line.includes("感情可能不止一段 / 可能有断续") && line.includes("不预设用户现在有伴侣或单身"))); + assert.ok(CONSULTATION_CONDENSED_CHECKLISTS.wealth!.some((line) => line.startsWith("8 宫也管版税、继承、意外之财"))); + for (const domain of ["career", "marriage", "wealth"] as const) { + const lines = CONSULTATION_CONDENSED_CHECKLISTS[domain]!; + assert.ok(lines.length >= 5 && lines.length <= 8, domain); + } +}); + +function cardKeys(): Set { + const keys = new Set(); + const walk = (value: unknown) => { + if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === "object") { + for (const [key, child] of Object.entries(value)) { + keys.add(key); + walk(child); + } + } + }; + for (const chart of golden.charts) { + for (const domain of consultationDomainIds) { + const workflow = domain === "annual" ? chart.annual_workflow : domain === "timing" ? chart.timing_workflow : chart.workflow; + const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(workflow))); + const packet = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: domain }), domain); + walk(buildEvidenceCard([{ domain, packet, context }])); + } + } + return keys; +} + +test("hard red line 3: every field name the lists cite is a key on a real card", () => { + const keys = cardKeys(); + const texts = [ + ...CONDENSED_SHARED_READING_LINES, + ...Object.values(CONSULTATION_CONDENSED_CHECKLISTS).flat(), + ...Object.values(CONDENSED_GENDER_LINES), + ].join("\n"); + // Lower-case identifiers in these Chinese texts are field names; "blocked" is the + // only prose word (KP 精确宫头仍 blocked). Static text scan, not intent detection. + const prose = new Set(["blocked"]); + const cited = new Set([...texts.matchAll(/(? match[0]).filter((word) => !prose.has(word))); + for (const required of ["conjunct_with", "aspected_by", "combust", "lord_rows", "significator_rows", "saturn_from_moon_house", "hits", "functional"]) { + assert.ok(cited.has(required), `the lists cite ${required}`); + } + for (const word of cited) assert.ok(keys.has(word), `cited field ${word} is on a card`); +}); + +test("T4: health is 健康 and asks about the constitution first; its boundary and cap are unchanged", () => { + const health = consultationDomainDefinition("health"); + assert.equal(health.label, "健康"); + assert.equal(health.prompt, "我的身体底子怎样,哪些方面要多留意?"); + assert.ok(health.aliases.includes("身心压力")); + assert.equal(health.claimBoundary, "wellbeing_pressure_patterns_not_medical_diagnosis"); + assert.equal(health.confidenceCap, "low"); +}); diff --git a/frontend/tests/consult-condensed-checklist-20260927.test.ts b/frontend/tests/consult-condensed-checklist-20260927.test.ts index a4495dc8..d6c02709 100644 --- a/frontend/tests/consult-condensed-checklist-20260927.test.ts +++ b/frontend/tests/consult-condensed-checklist-20260927.test.ts @@ -13,6 +13,7 @@ import test from "node:test"; import { CONDENSED_CHECKLIST_SOURCE, + CONSULT_BRIEF_CHECKLIST_SOURCE, CONSULTATION_CONDENSED_CHECKLISTS, } from "../src/lib/consultation-condensed-checklist.ts"; import { consultationMethodologyForDomains, sharedConsultationMethodMarkdown } from "../src/lib/consultation-methodology.ts"; @@ -75,10 +76,19 @@ async function writerPrompt(domain: "career" | "marriage" | "wealth") { } test("each condensed list is 5–8 lines", () => { - for (const [domain, lines] of Object.entries(CONSULTATION_CONDENSED_CHECKLISTS)) { - assert.ok(lines!.length >= 5 && lines!.length <= 8, `${domain}: ${lines!.length} lines`); + // 原值:全部清单 5–8 行,且只有 career / marriage / wealth 三份; + // 新值:这三份仍 5–8 行;另有 TASK-consult-affliction-reading-20261001 定稿的 8 份(2–6 行,按任务书原文,不凑行数), + // 只有 timing 没有本地清单;原因:该任务书 T2 / 决策 D5 补齐清单,定稿文本不得改意思。 + for (const domain of ["career", "marriage", "wealth"] as const) { + const lines = CONSULTATION_CONDENSED_CHECKLISTS[domain]!; + assert.ok(lines.length >= 5 && lines.length <= 8, `${domain}: ${lines.length} lines`); } - assert.deepEqual(Object.keys(CONSULTATION_CONDENSED_CHECKLISTS).sort(), ["career", "marriage", "wealth"]); + for (const [domain, lines] of Object.entries(CONSULTATION_CONDENSED_CHECKLISTS)) { + assert.ok(lines!.length >= 2 && lines!.length <= 8, `${domain}: ${lines!.length} lines`); + } + assert.deepEqual(Object.keys(CONSULTATION_CONDENSED_CHECKLISTS).sort(), [ + "annual", "career", "children", "education", "family", "general", "health", "marriage", "migration", "parents", "wealth", + ]); }); test("the writer's prompt carries the condensed list with the three layers and the forbidden statements, not the long checklist", async () => { @@ -114,8 +124,12 @@ test("other routes still quote the live skill, and the bound system method is un const eventTiming = timing.sections.find((section) => section.title.includes("event-timing-strict")); assert.ok(eventTiming); assert.match(eventTiming.source, /references\/strict-workflow-router\.md$/); + // 原值:health 段标题含 health-timing-strict、来源是 strict-workflow-router.md; + // 新值:普通对话的 health 段是 consult-health 中文清单,来源是任务书,不再发路由英文段; + // 原因:TASK-consult-affliction-reading-20261001 决策 D4/D5(报告面仍用路由,不受影响)。 const health = consultationMethodologyForDomains(["health"])!; - assert.match(health.sections.find((section) => section.title.includes("health-timing-strict"))!.source, /strict-workflow-router\.md$/); + assert.equal(health.sections.some((section) => section.title.includes("health-timing-strict")), false); + assert.equal(health.sections.find((section) => section.title.includes("consult-health"))!.source, CONSULT_BRIEF_CHECKLIST_SOURCE); for (const domain of consultationDomainIds.filter((id) => !(id in CONSULTATION_CONDENSED_CHECKLISTS))) { const methodology = consultationMethodologyForDomains([domain])!; assert.equal(methodology.sections.some((section) => section.source === CONDENSED_CHECKLIST_SOURCE), false, domain); diff --git a/frontend/tests/consultation-domain-parents-children-20260927.test.ts b/frontend/tests/consultation-domain-parents-children-20260927.test.ts index eb82180b..54cde6ce 100644 --- a/frontend/tests/consultation-domain-parents-children-20260927.test.ts +++ b/frontend/tests/consultation-domain-parents-children-20260927.test.ts @@ -100,13 +100,18 @@ test("a stored session theme can never be parents or children (chat_sessions.the assert.equal(chatSessionCreateSchema.safeParse({ ...base, theme: "children" }).success, false); }); -test("methodology names no strict checklist for parents or children instead of substituting one", () => { +test("methodology gives parents and children their own consultation lists after the shared reading", () => { const methodology = consultationMethodologyForDomains(["parents"]); assert.ok(methodology); - assert.deepEqual(methodology.domains_without_strict_checklist, ["parents"]); - assert.equal(methodology.sections[0]?.title, "Shared mandatory baseline"); - assert.match(methodology.sections[0]?.text ?? "", /D7 for children, D12 for family/); - assert.deepEqual(consultationMethodologyForDomains(["children"])?.domains_without_strict_checklist, ["children"]); + // 原值:domains_without_strict_checklist = ["parents"] / ["children"],sections[0] 是 Shared mandatory baseline; + // 新值:两者为 [],sections[0] 是「通用读法 · 先看受冲」,baseline 顺延到 sections[1]; + // 原因:TASK-consult-affliction-reading-20261001 T1(通用读法每轮最前)与决策 D6(父母、子女有本产品清单)。 + assert.deepEqual(methodology.domains_without_strict_checklist, []); + assert.equal(methodology.sections[0]?.title, "通用读法 · 先看受冲"); + assert.equal(methodology.sections[1]?.title, "Shared mandatory baseline"); + assert.match(methodology.sections[1]?.text ?? "", /D7 for children, D12 for family/); + assert.ok(methodology.sections.some((section) => section.title.includes("consult-parents"))); + assert.deepEqual(consultationMethodologyForDomains(["children"])?.domains_without_strict_checklist, []); }); test("receipts and the thinking plan show 父母 / 子女", () => { diff --git a/frontend/tests/consultation-domain-registry.test.ts b/frontend/tests/consultation-domain-registry.test.ts index 5ce0b616..bdf3bb7e 100644 --- a/frontend/tests/consultation-domain-registry.test.ts +++ b/frontend/tests/consultation-domain-registry.test.ts @@ -79,7 +79,9 @@ test("general guided topics keep user-centered prompts and the same claim bounda "请帮我梳理目前的事业方向和下一步重点。", "请帮我看看我在关系中容易重复什么模式。", "请帮我分析目前的财富重点、风险和更稳妥的选择。", - "请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。", + // 原值「请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。」→ 新值如下; + // 原因:TASK-consult-affliction-reading-20261001 D4 健康改口径,先讲体质底子。 + "请从非医疗诊断的角度,帮我看看我的身体底子怎样,哪些方面要多留意。", "请帮我看看我更适合怎样学习,以及如何安排下一步。", "请帮我分析现阶段是否适合迁居、置业或考虑海外发展。", "请帮我看看家庭关系中最需要处理的问题和责任边界。", diff --git a/frontend/tests/consultation-methodology.test.ts b/frontend/tests/consultation-methodology.test.ts index 2605e43f..7e2bd139 100644 --- a/frontend/tests/consultation-methodology.test.ts +++ b/frontend/tests/consultation-methodology.test.ts @@ -6,7 +6,11 @@ import { METHODOLOGY_SECTION_MAX_CHARS, METHODOLOGY_TOTAL_MAX_CHARS, } from "../src/lib/consultation-methodology.ts"; -import { CONDENSED_CHECKLIST_SOURCE } from "../src/lib/consultation-condensed-checklist.ts"; +import { + CONDENSED_CHECKLIST_SOURCE, + CONDENSED_SHARED_READING_TITLE, + CONSULT_BRIEF_CHECKLIST_SOURCE, +} from "../src/lib/consultation-condensed-checklist.ts"; import { consultationDomainIds } from "../src/lib/consultation-domain-registry.ts"; import { resolveLiveJyotishSkill } from "../src/lib/skill-package-registry.ts"; @@ -37,16 +41,25 @@ test("the route's own strict checklist reaches the answer, not just the package )).length, 0, ); - const domainSections = methodology.sections.filter((section) => !section.title.includes("Shared mandatory baseline")); + // 原值:只排除 Shared mandatory baseline;新值:同时排除每轮一次的「通用读法 · 先看受冲」; + // 原因:TASK-consult-affliction-reading-20261001 T1 把通用读法放在 sections 最前面,它不是领域清单。 + const domainSections = methodology.sections.filter((section) => ( + !section.title.includes("Shared mandatory baseline") && section.title !== CONDENSED_SHARED_READING_TITLE + )); // Former: 2 (strict route + event judgment). New: 1 (condensed list). Reason: as above. assert.equal(domainSections.length, 1); assert.deepEqual(methodology.domains_without_strict_checklist, []); }); -test("a route the skill declares no checklist for is reported, not filled in with another route's", () => { +test("a route the skill declares no checklist for gets this product's own list, never another route's", () => { const methodology = consultationMethodologyForDomains(["education"]); assert.ok(methodology); - assert.deepEqual(methodology.domains_without_strict_checklist, ["education"]); + // 原值:["education"](路由没声明清单就报缺、不替代);新值:[],学业发本产品自己的中文清单; + // 原因:TASK-consult-affliction-reading-20261001 决策 D6 对普通对话推翻「报缺不替代」。 + assert.deepEqual(methodology.domains_without_strict_checklist, []); + const education = methodology.sections.find((section) => section.title.includes("consult-education")); + assert.ok(education); + assert.equal(education.source, CONSULT_BRIEF_CHECKLIST_SOURCE); assert.ok( !methodology.sections.some((section) => section.title.includes("timing-strict") || section.title.includes("verification-strict")), "no strict section may be substituted for a route that has none", @@ -63,12 +76,17 @@ test("a route the skill declares no checklist for is reported, not filled in wit ); }); -test("health uses the skill's health-timing-strict checklist", () => { +test("health uses the consultation's Chinese health list in place of health-timing-strict", () => { const methodology = consultationMethodologyForDomains(["health"]); assert.ok(methodology); assert.deepEqual(methodology.domains_without_strict_checklist, []); - const health = methodology.sections.find((section) => section.title.includes("health-timing-strict")); + // 原值:标题含 health-timing-strict(路由英文清单);新值:consult-health 中文清单,来源是任务书; + // 原因:TASK-consult-affliction-reading-20261001 决策 D4/D5,普通对话健康改口径(报告面不变)。 + assert.equal(methodology.sections.some((section) => section.title.includes("health-timing-strict")), false); + const health = methodology.sections.find((section) => section.title.includes("consult-health")); assert.ok(health); + assert.equal(health.source, CONSULT_BRIEF_CHECKLIST_SOURCE); + assert.match(health.text, /体质底子/); assert.match(health.text, /D6/); assert.match(health.text, /D30/); });