From 5c65834dda33924820191c2370cfaa4ae41a8a38 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 27 Sep 2026 02:14:37 +0800 Subject: [PATCH] feat(consult): split family into parents and children domains (engine still family) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parents (D12, 4/9 houses, Sun/Moon) and children (D7, 5th house, Jupiter, PK) are plan/card domains with Chinese labels and the brief's aliases. They send the family route contract to the engine and are rejected as a stored session theme, so neither Python nor the chat_sessions.theme CHECK changes. Methodology reports no strict checklist for them; must-use layers follow the plan domain. Tests with 原值/新值/原因 where assertions changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- .../src/lib/chat-session-write-contract.ts | 8 +- .../src/lib/consultation-domain-registry.ts | 43 ++++- frontend/src/lib/consultation-methodology.ts | 4 + frontend/src/lib/consultation-run-timeline.ts | 2 +- .../src/lib/consultation-workflow-request.ts | 7 +- frontend/src/lib/guided-jyotish-topics.ts | 2 + frontend/src/mastra/consultation-tools.ts | 4 +- frontend/src/mastra/consultation-workflow.ts | 22 ++- .../consultation-domain-migration.test.ts | 11 +- ...n-domain-parents-children-20260927.test.ts | 154 ++++++++++++++++++ .../consultation-domain-registry.test.ts | 21 ++- .../consultation-workflow-contract.test.ts | 5 +- tests/test_consult_evidence_card_research.py | 8 +- tests/test_consultation_domain_registry.py | 38 ++++- 14 files changed, 301 insertions(+), 28 deletions(-) create mode 100644 frontend/tests/consultation-domain-parents-children-20260927.test.ts diff --git a/frontend/src/lib/chat-session-write-contract.ts b/frontend/src/lib/chat-session-write-contract.ts index 6dce3a9b..1bc9514d 100644 --- a/frontend/src/lib/chat-session-write-contract.ts +++ b/frontend/src/lib/chat-session-write-contract.ts @@ -5,7 +5,7 @@ import { type AgentExecutionReceipt, type WorkflowReceipt, } from "./consultation-agent-events.ts"; -import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts"; +import { consultationSessionThemeSchema, type ConsultationDomain } from "./consultation-domain-registry.ts"; import { publicThinkingSectionSchema, type PublicThinkingSection } from "./consultation-thinking-plan.ts"; export const CHAT_SESSION_MAX_MESSAGES = 200; @@ -36,7 +36,7 @@ const chartBindingSchema = { export const chatSessionMetadataPatchSchema = z.object({ title: z.string().trim().min(1).max(160).optional(), - theme: consultationDomainSchema.optional(), + theme: consultationSessionThemeSchema.optional(), model_id: z.string().trim().min(1).max(64).optional(), pinned: z.boolean().optional(), ...chartBindingSchema, @@ -52,7 +52,7 @@ export const chatSessionModelPatchSchema = z.object({ export const chatSessionCreateSchema = z.object({ id: z.string().uuid(), title: z.string().trim().min(1).max(160), - theme: consultationDomainSchema, + theme: consultationSessionThemeSchema, model_id: z.string().trim().min(1).max(64), // Former value: `.max(CHAT_SESSION_MAX_MESSAGES)` with transcript contents. // Create is no longer a history-import path; only an empty array is accepted. @@ -66,7 +66,7 @@ export const chatSessionCreateSchema = z.object({ const chatSessionLegacyWriteObjectSchema = z.object({ title: z.string().trim().min(1).max(160), - theme: consultationDomainSchema, + theme: consultationSessionThemeSchema, model_id: z.string().trim().min(1).max(64), messages: z.array(chatMessageSchema).max(CHAT_SESSION_MAX_MESSAGES), session_type: z.enum(["consultation", "birth_time_rectification"]), diff --git a/frontend/src/lib/consultation-domain-registry.ts b/frontend/src/lib/consultation-domain-registry.ts index 65ee5d18..5b750d00 100644 --- a/frontend/src/lib/consultation-domain-registry.ts +++ b/frontend/src/lib/consultation-domain-registry.ts @@ -8,12 +8,41 @@ export const consultationDomainIds = [ "education", "migration", "family", + "parents", + "children", "annual", "timing", "general", ] as const; export type ConsultationDomain = typeof consultationDomainIds[number]; + +/** + * The themes the Python engine and the `chat_sessions.theme` CHECK know. + * + * `parents` and `children` split the engine's `family` theme only for what the + * answer model is shown (the evidence card's domain section). They are sent to + * the engine as `family` (`strictWorkflowRoute` / `workflowThemes`), and a + * stored session theme stays one of these ten, so the split needs no Python or + * database change. + */ +export const consultationEngineDomainIds = [ + "career", + "marriage", + "wealth", + "health", + "education", + "migration", + "family", + "annual", + "timing", + "general", +] as const satisfies readonly ConsultationDomain[]; + +export type ConsultationEngineDomain = typeof consultationEngineDomainIds[number]; + +/** The session-theme vocabulary (`chat_sessions.theme` CHECK). */ +export const consultationSessionThemeSchema = z.enum(consultationEngineDomainIds); export type ConsultationTheme = ConsultationDomain; export const consultationDomainSchema = z.enum(consultationDomainIds); @@ -22,8 +51,9 @@ export type ConsultationDomainDefinition = Readonly<{ id: ConsultationDomain; label: string; aliases: readonly string[]; - strictWorkflowRoute: ConsultationDomain; - workflowThemes: readonly ConsultationDomain[]; + /** The engine route this domain runs as; `parents` / `children` run as `family`. */ + strictWorkflowRoute: ConsultationEngineDomain; + workflowThemes: readonly ConsultationEngineDomain[]; requiredLayers: readonly string[]; claimBoundary: string; prompt: string; @@ -38,7 +68,9 @@ export const consultationDomainRegistry = [ { id: "health", label: "身心压力", aliases: ["health", "health-pressure", "health_pressure", "健康"], strictWorkflowRoute: "health", workflowThemes: ["health"], requiredLayers: ["D1", "D6", "D8", "6th/8th houses", "Dasha", "non-medical boundary"], claimBoundary: "wellbeing_pressure_patterns_not_medical_diagnosis", prompt: "我近期的身心压力模式和调节重点是什么?", evidencePreview: ["D1", "D6", "D8", "6/8宫", "Dasha"], confidenceCap: "low" }, { id: "education", label: "学习", aliases: ["education", "study", "教育", "学习"], strictWorkflowRoute: "education", workflowThemes: ["education"], requiredLayers: ["D1", "D24", "5th/9th houses", "Mercury/Jupiter", "Dasha"], claimBoundary: "learning_pattern_and_broad_timing_only", prompt: "我的学习优势、瓶颈和进阶方向是什么?", evidencePreview: ["D1", "D24", "5/9宫", "Mercury/Jupiter", "Dasha"], confidenceCap: "medium" }, { id: "migration", label: "迁居", aliases: ["migration", "home", "relocation", "foreign", "迁移", "海外"], strictWorkflowRoute: "migration", workflowThemes: ["migration"], requiredLayers: ["D1", "D4", "D12", "4th/12th houses", "Dasha", "Narayana"], claimBoundary: "migration_and_home_direction_broad_window_only", prompt: "迁居、置业或海外发展更适合怎样规划?", evidencePreview: ["D1", "D4", "D12", "4/12宫", "Dasha"], confidenceCap: "medium" }, - { id: "family", label: "家庭", aliases: ["family", "children", "家庭", "子女"], strictWorkflowRoute: "family", workflowThemes: ["family"], requiredLayers: ["D1", "D7", "D12", "4th/5th/9th houses", "Dasha"], claimBoundary: "family_pattern_not_deterministic_event_claim", prompt: "我的家庭关系与责任边界该如何理解?", evidencePreview: ["D1", "D7", "D12", "4/5/9宫", "Dasha"], confidenceCap: "medium" }, + { id: "family", label: "家庭", aliases: ["family", "家庭"], strictWorkflowRoute: "family", workflowThemes: ["family"], requiredLayers: ["D1", "D7", "D12", "4th/5th/9th houses", "Dasha"], claimBoundary: "family_pattern_not_deterministic_event_claim", prompt: "我的家庭关系与责任边界该如何理解?", evidencePreview: ["D1", "D7", "D12", "4/5/9宫", "Dasha"], confidenceCap: "medium" }, + { id: "parents", label: "父母", aliases: ["parents", "父母", "父亲", "母亲", "爸爸", "妈妈", "爸妈"], strictWorkflowRoute: "family", workflowThemes: ["family"], requiredLayers: ["D1", "D12", "4th/9th houses/lords", "Sun/Moon", "Dasha", "Narayana"], claimBoundary: "family_pattern_not_deterministic_event_claim", prompt: "我和父母的关系该怎么理解?", evidencePreview: ["D1", "D12", "4/9宫", "太阳/月亮", "Dasha"], confidenceCap: "medium" }, + { id: "children", label: "子女", aliases: ["children", "子女", "孩子", "儿子", "女儿"], strictWorkflowRoute: "family", workflowThemes: ["family"], requiredLayers: ["D1", "D7", "5th house/lord", "Jupiter", "PK", "Dasha", "Narayana"], claimBoundary: "family_pattern_not_deterministic_event_claim", prompt: "我和子女的缘分与相处该怎么看?", evidencePreview: ["D1", "D7", "5宫", "木星", "Dasha"], confidenceCap: "medium" }, { id: "annual", label: "年运", aliases: ["annual", "yearly", "varshaphala", "年度", "流年"], strictWorkflowRoute: "annual", workflowThemes: ["annual"], requiredLayers: ["D1", "Annual chart boundary", "Dasha", "Transit", "Tajika candidate"], claimBoundary: "annual_report_broad_periods_only", prompt: "未来一年的重点主题和阶段节奏是什么?", evidencePreview: ["D1", "Dasha", "Transit", "Annual chart boundary"], confidenceCap: "low" }, { id: "timing", label: "时运", aliases: ["timing"], strictWorkflowRoute: "timing", workflowThemes: ["timing"], requiredLayers: ["Vimshottari", "Narayana", "Transit", "Varga", "negative holdout gate"], claimBoundary: "candidate_day_month_window_only_until_holdout_passes", prompt: "未来哪些阶段值得把握?", evidencePreview: ["Dasha", "Narayana", "Transit", "Varga"], confidenceCap: "low" }, { id: "general", label: "综合", aliases: ["general"], strictWorkflowRoute: "general", workflowThemes: ["general"], requiredLayers: ["D1", "D9", "D10", "D2", "Dasha", "Narayana", "Transit", "Functional Benefic/Malefic"], claimBoundary: "multi_domain_summary_with_missing_layers_disclosed", prompt: "请综合说明我当前最值得关注的主题。", evidencePreview: ["D1", "D9", "D10", "D2", "Dasha", "Transit"], confidenceCap: "low" }, @@ -87,3 +119,8 @@ export function consultationDomainDefinition(domain: ConsultationDomain): Consul if (!definition) throw new Error("consultation_domain_registry_incomplete"); return definition; } + +/** The engine theme a consultation domain is calculated as. */ +export function consultationEngineDomain(domain: ConsultationDomain): ConsultationEngineDomain { + return consultationDomainDefinition(domain).strictWorkflowRoute; +} diff --git a/frontend/src/lib/consultation-methodology.ts b/frontend/src/lib/consultation-methodology.ts index 531178ce..fbdc7d84 100644 --- a/frontend/src/lib/consultation-methodology.ts +++ b/frontend/src/lib/consultation-methodology.ts @@ -35,6 +35,10 @@ const domainMethodology: Readonly { - const domainLabel = /^(事业|财富|关系|家庭)/.exec(step.label)?.[1]; + const domainLabel = /^(事业|财富|关系|家庭|父母|子女)/.exec(step.label)?.[1]; return domainLabel ? [domainLabel] : []; })).slice(0, 8); } diff --git a/frontend/src/lib/consultation-workflow-request.ts b/frontend/src/lib/consultation-workflow-request.ts index f7770278..8eedf2f8 100644 --- a/frontend/src/lib/consultation-workflow-request.ts +++ b/frontend/src/lib/consultation-workflow-request.ts @@ -38,12 +38,15 @@ export function projectConsultationWorkflowRequest( plan: ConsultationWorkflowPlanMetadata, ) { const requirement = consultationDomainDefinition(theme); + // The engine validates the route contract exactly (consultation_plan_contract.py), + // so a card-only domain (parents, children) sends its engine route's contract. + const engine = consultationDomainDefinition(requirement.strictWorkflowRoute); return { question, themes: requirement.workflowThemes, strictWorkflowRoute: requirement.strictWorkflowRoute, - requiredLayers: requirement.requiredLayers, - claimBoundary: requirement.claimBoundary, + requiredLayers: engine.requiredLayers, + claimBoundary: engine.claimBoundary, plan_version: plan.plan_version, depth: plan.depth, requested_domains: plan.requestedDomains, diff --git a/frontend/src/lib/guided-jyotish-topics.ts b/frontend/src/lib/guided-jyotish-topics.ts index a773a612..b4a97968 100644 --- a/frontend/src/lib/guided-jyotish-topics.ts +++ b/frontend/src/lib/guided-jyotish-topics.ts @@ -31,6 +31,8 @@ const generalPromptByTheme: Partial> = { education: "请帮我看看我更适合怎样学习,以及如何安排下一步。", migration: "请帮我分析现阶段是否适合迁居、置业或考虑海外发展。", family: "请帮我看看家庭关系中最需要处理的问题和责任边界。", + parents: "请帮我看看我和父母之间最需要处理的问题。", + children: "请帮我看看我和孩子相处时最需要留意的地方。", annual: "请帮我看看未来一年的主要趋势、机会和需要留意的阶段。", timing: "请帮我看看目前适合推进什么,哪些事情需要再等等。", general: "请结合我的情况,帮我找出现在最值得优先处理的三个问题。", diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 9b326343..9a008679 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -747,7 +747,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { let calculation: Promise> | null = null; const consultationTool = createTool({ id: "run-jyotish-consultation", - description: `Run one server-validated plan of personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, up to ${MAX_CONSULTATION_DOMAIN_PLAN_VALUES}. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; the server executes as many as that clock can pay for (about ${MAX_CONSULTATION_DOMAINS}) and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with per-domain detail in consultations. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`, + description: `Run one server-validated plan of personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, up to ${MAX_CONSULTATION_DOMAIN_PLAN_VALUES}. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Questions about one's parents (父母) use parents and questions about one's children (子女) use children; family is only for the household as a whole (家里、家庭氛围). Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; the server executes as many as that clock can pay for (about ${MAX_CONSULTATION_DOMAINS}) and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with per-domain detail in consultations. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`, inputSchema: consultationToolInputSchema, execute: async (input, context) => { const requestedDomains = canonicalDomainPlan(input, ctx); @@ -816,7 +816,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { executions.push({ domain, context: agentContext, - modelOutput: toModelOutput(agentContext, domainPlan), + modelOutput: toModelOutput(agentContext, domainPlan, domain), receipt: consultationWorkflowReceipt(guarded), }); } diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index 16de7de5..4ea247bf 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -610,6 +610,11 @@ const DOMAIN_MUST_USE_LAYERS: Readonly> = { timing: ["Narayana Dasha", "Vimshottari sub-periods", "Transits / Sade Sati", "Chara Dasha"], annual: ["Narayana Dasha", "Vimshottari sub-periods", "Chara Dasha"], health: ["Formal Vargas D1–D60", "Shadbala components"], + // D12 (parents), D7 (children) and both for the household question live in + // the formal varga set; Narayana is the second dasha track for all three. + family: ["Formal Vargas D1–D60", "Narayana Dasha"], + parents: ["Formal Vargas D1–D60", "Narayana Dasha"], + children: ["Formal Vargas D1–D60", "Narayana Dasha"], }; function mustUseExecutedLayers(route: string, audit: unknown): string[] { @@ -631,12 +636,17 @@ function mustUseExecutedLayers(route: string, audit: unknown): string[] { return output.slice(0, 24); } -function projectEvidenceContract(contract: ReturnType["evidence_contract"]) { +function projectEvidenceContract( + contract: ReturnType["evidence_contract"], + domain?: string, +) { const policy = record(contract.answer_policy); const audit = projectTechniqueAudit(contract.technique_audit_table); const varga = projectVargaSpectrum(contract.varga_spectrum); const western = projectWesternSpectrum(contract.western_spectrum); - const mustUse = mustUseExecutedLayers(String(contract.route || ""), contract.technique_audit_table); + // The engine reports `family` for parents and children; the plan's own domain + // picks their must-use layers. + const mustUse = mustUseExecutedLayers(domain ?? String(contract.route || ""), contract.technique_audit_table); return { available_layers: stringList(contract.available_layers, 80), missing_route_layers: stringList(contract.missing_route_layers, 40), @@ -667,7 +677,11 @@ function hasModelOutputEvidence(value: unknown) { * bounded, server-selected evidence to the model. This is not a claim generator: * every card is a projection of an existing server result. */ -export function toModelOutput(context: ReturnType, plan?: ConsultationPlan): ConsultationEvidencePacket { +export function toModelOutput( + context: ReturnType, + plan?: ConsultationPlan, + domain?: string, +): ConsultationEvidencePacket { const contract = context.evidence_contract; const requiredCategories = new Set(plan?.requiredEvidenceCategories ?? consultationEvidenceCategoryValues); const cards = [ @@ -700,7 +714,7 @@ export function toModelOutput(context: ReturnType { - assert.deepEqual(themeConstraintValues(migration), [...consultationDomainIds]); + // 原值:CHECK 值 == consultationDomainIds。 + // 新值:CHECK 值 == consultationEngineDomainIds(会话主题 / 引擎领域);parents、children 只在数据卡里区分,不落库。 + // 原因:TASK-consult-evidence-card-20260927 D2,不改数据库。 + assert.deepEqual(themeConstraintValues(migration), [...consultationEngineDomainIds]); + assert.deepEqual( + consultationDomainIds.filter((domain) => !(consultationEngineDomainIds as readonly string[]).includes(domain)), + ["parents", "children"], + ); }); test("chat_sessions does not persist aliases or independent product routes", () => { diff --git a/frontend/tests/consultation-domain-parents-children-20260927.test.ts b/frontend/tests/consultation-domain-parents-children-20260927.test.ts new file mode 100644 index 00000000..eb82180b --- /dev/null +++ b/frontend/tests/consultation-domain-parents-children-20260927.test.ts @@ -0,0 +1,154 @@ +// TASK-consult-evidence-card-20260927 T2 (D2): 「家庭」拆成「父母」「子女」。 +// +// parents / children are consultation domains the model can pick and the card +// can tell apart, while the engine keeps calculating them as `family`: the +// engine validates the route contract exactly (consultation_plan_contract.py), +// and chat_sessions.theme has a CHECK on the original ten themes. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + consultationDomainDefinition, + consultationDomainPlanValues, + consultationEngineDomain, + normalizeConsultationDomain, + validateConsultationDomainPlan, +} from "../src/lib/consultation-domain-registry.ts"; +import { projectConsultationWorkflowRequest } from "../src/lib/consultation-workflow-request.ts"; +import { createConsultationPlan } from "../src/lib/consultation-plan.ts"; +import { consultationMethodologyForDomains } from "../src/lib/consultation-methodology.ts"; +import { natalConsultationThinkingPlan } from "../src/lib/consultation-thinking-plan.ts"; +import { workflowReceiptSchema } from "../src/lib/consultation-agent-events.ts"; +import { chatSessionCreateSchema } from "../src/lib/chat-session-write-contract.ts"; +import { + consultationWorkflowResponseSchema, + toAgentConsultationContext, + toModelOutput, +} from "../src/mastra/consultation-workflow.ts"; +import { + canonicalDomainPlan, + createConsultationRuntimeState, + createConsultationTools, +} from "../src/mastra/consultation-tools.ts"; + +type Json = Record; +const golden = JSON.parse(readFileSync( + new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url), + "utf8", +)) as { charts: Array<{ id: string; workflow: Json }> }; +const jobsWorkflow = golden.charts.find((chart) => chart.id === "steve_jobs")!.workflow; + +test("parents and children are registered with Chinese labels and run as family on the engine", () => { + const parents = consultationDomainDefinition("parents"); + const children = consultationDomainDefinition("children"); + assert.equal(parents.label, "父母"); + assert.equal(children.label, "子女"); + for (const definition of [parents, children]) { + assert.equal(definition.strictWorkflowRoute, "family"); + assert.deepEqual([...definition.workflowThemes], ["family"]); + } + assert.equal(consultationEngineDomain("parents"), "family"); + assert.equal(consultationEngineDomain("children"), "family"); + assert.equal(consultationEngineDomain("career"), "career"); + assert.ok(parents.requiredLayers.includes("D12")); + assert.ok(children.requiredLayers.includes("D7")); +}); + +test("the brief's aliases resolve to parents and children; the household question stays family", () => { + for (const alias of ["父母", "父亲", "母亲", "爸爸", "妈妈", "爸妈", "parents"]) { + assert.equal(normalizeConsultationDomain(alias), "parents", alias); + } + for (const alias of ["子女", "孩子", "儿子", "女儿", "children"]) { + assert.equal(normalizeConsultationDomain(alias), "children", alias); + } + assert.equal(normalizeConsultationDomain("家庭"), "family"); + assert.equal(normalizeConsultationDomain("family"), "family"); + // The model-facing schema enumerates the new ids and aliases. + for (const value of ["parents", "children", "父母", "孩子", "family", "家庭"]) { + assert.ok(consultationDomainPlanValues.includes(value), value); + } +}); + +test("mixed plans keep the two-domain rule: children with education, parents deduplicated", () => { + // 「孩子学业」:the model lists both domains; the server canonicalizes aliases. + assert.deepEqual(validateConsultationDomainPlan(["孩子", "学习"]), ["children", "education"]); + assert.deepEqual(validateConsultationDomainPlan(["爸妈", "父母", "parents"]), ["parents"]); + assert.deepEqual(validateConsultationDomainPlan(["父母", "子女", "家庭"]), ["parents", "children", "family"]); + assert.deepEqual(canonicalDomainPlan({ domains: ["父母"] }, {}), ["parents"]); +}); + +test("the engine request for parents and children is exactly the family route contract", () => { + const familyPlan = createConsultationPlan({ userIntent: "q", theme: "family" }); + const family = projectConsultationWorkflowRequest("q", "family", familyPlan); + for (const domain of ["parents", "children"] as const) { + const plan = createConsultationPlan({ userIntent: "q", theme: domain }); + assert.deepEqual([...plan.requestedDomains], ["family"], domain); + const request = projectConsultationWorkflowRequest("q", domain, plan); + assert.deepEqual(request.themes, family.themes, domain); + assert.equal(request.strictWorkflowRoute, "family", domain); + assert.deepEqual(request.requiredLayers, family.requiredLayers, domain); + assert.equal(request.claimBoundary, family.claimBoundary, domain); + assert.deepEqual(request.requested_domains, family.requested_domains, domain); + } +}); + +test("a stored session theme can never be parents or children (chat_sessions.theme CHECK)", () => { + const base = { id: "00000000-0000-4000-8000-000000000001", title: "t", model_id: "m", messages: [], session_type: "consultation", rectification_case_id: null }; + assert.equal(chatSessionCreateSchema.safeParse({ ...base, theme: "family" }).success, true); + assert.equal(chatSessionCreateSchema.safeParse({ ...base, theme: "parents" }).success, false); + assert.equal(chatSessionCreateSchema.safeParse({ ...base, theme: "children" }).success, false); +}); + +test("methodology names no strict checklist for parents or children instead of substituting one", () => { + 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"]); +}); + +test("receipts and the thinking plan show 父母 / 子女", () => { + assert.equal(workflowReceiptSchema.safeParse({ + route: "multi-domain", status: "ready", preciseTiming: "allowed", missingLayers: [], domains: ["parents", "children"], + }).success, true); + const labels = natalConsultationThinkingPlan({ domains: ["parents", "children"] }) + .flatMap((section) => section.steps.map((step) => step.label)); + assert.ok(labels.some((label) => label.startsWith("父母:"))); + assert.ok(labels.some((label) => label.startsWith("子女:"))); +}); + +test("must-use layers follow the plan's domain, not the engine's family route", () => { + const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(jobsWorkflow))); + assert.equal(context.evidence_contract.route, "family"); + const packet = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: "parents" }), "parents"); + const mustUse = packet.evidence_contract.must_use_layers as string[]; + assert.ok(mustUse.includes("Formal Vargas D1–D60")); + assert.ok(mustUse.includes("Narayana Dasha")); +}); + +test("the calculation tool runs a parents plan as family and reports parents in the receipt", async () => { + const calls: Array<{ theme: string; requested: readonly string[] }> = []; + const state = createConsultationRuntimeState(); + const serverChart = { + name: "测试", + toolInput: { year: 1955, month: 2, day: 24, hour: 19, minute: 15, city: "San Francisco", lat: 37.77, lon: -122.42, tz: -8, ayanamsa: "raman" }, + truth: { birthTimeSource: "reported" }, + }; + const tools = createConsultationTools({ + userId: "u", sessionId: "s", requestId: "r-parents", consultationMode: "verified_chart", + serverChart: serverChart as never, state, + runWorkflow: async (input, options) => { + calls.push({ theme: input.theme, requested: options?.plan?.requestedDomains ?? [] }); + return structuredClone(jobsWorkflow) as never; + }, + }); + await tools["run-jyotish-consultation"].execute!( + { question: "我和父母关系如何", domains: ["父母"] } as never, + { writer: { custom: async () => {} } } as never, + ); + assert.deepEqual(calls, [{ theme: "parents", requested: ["family"] }]); + assert.deepEqual(state.workflowReceipt?.domains, ["parents"]); + assert.equal(state.workflowReceipt?.route, "family"); +}); diff --git a/frontend/tests/consultation-domain-registry.test.ts b/frontend/tests/consultation-domain-registry.test.ts index 055d53f8..5ce0b616 100644 --- a/frontend/tests/consultation-domain-registry.test.ts +++ b/frontend/tests/consultation-domain-registry.test.ts @@ -14,7 +14,11 @@ import { generalGuidedJyotishTopics, } from "../src/lib/guided-jyotish-topics.ts"; -const expected = ["career", "marriage", "wealth", "health", "education", "migration", "family", "annual", "timing", "general"]; +// 原值:十个领域,无 parents / children。 +// 新值:family 后面加 parents、children(数据卡子领域,引擎侧按 family 计算)。 +// 原因:TASK-consult-evidence-card-20260927 D2。会话主题(chat_sessions.theme CHECK)仍是原十个,见 sessionThemes。 +const expected = ["career", "marriage", "wealth", "health", "education", "migration", "family", "parents", "children", "annual", "timing", "general"]; +const sessionThemes = ["career", "marriage", "wealth", "health", "education", "migration", "family", "annual", "timing", "general"]; test("domain registry is the complete allowlist used by UI and workflow input", () => { assert.deepEqual([...consultationDomainIds], expected); @@ -42,7 +46,8 @@ test("server domain plans are allowlisted, ordered and deduplicated", () => { test("default guided topics derive every canonical domain with evidence and claim boundaries", () => { // Moved from starter-questions.test.ts when homepage theme cards were removed. const guidedTopicsSource = readFileSync(new URL("../src/lib/guided-jyotish-topics.ts", import.meta.url), "utf8"); - assert.equal(consultationDomainIds.length, 10); + // 原值:assert.equal(consultationDomainIds.length, 10);新值:12;原因:D2 新增 parents / children。 + assert.equal(consultationDomainIds.length, 12); assert.deepEqual(consultationDomainRegistry.map((domain) => domain.id), [...consultationDomainIds]); assert.match(guidedTopicsSource, /consultationDomainRegistry\.map/); assert.deepEqual(defaultGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]); @@ -78,6 +83,9 @@ test("general guided topics keep user-centered prompts and the same claim bounda "请帮我看看我更适合怎样学习,以及如何安排下一步。", "请帮我分析现阶段是否适合迁居、置业或考虑海外发展。", "请帮我看看家庭关系中最需要处理的问题和责任边界。", + // 新增两行(D2):父母、子女的无出生分钟版问法。 + "请帮我看看我和父母之间最需要处理的问题。", + "请帮我看看我和孩子相处时最需要留意的地方。", "请帮我看看未来一年的主要趋势、机会和需要留意的阶段。", "请帮我看看目前适合推进什么,哪些事情需要再等等。", "请结合我的情况,帮我找出现在最值得优先处理的三个问题。", @@ -97,7 +105,14 @@ test("general guided topics keep user-centered prompts and the same claim bounda }); test("session persistence accepts every canonical domain and rejects product routes", () => { - for (const theme of expected) { + // 原值:遍历 expected(全部领域)都能落库。 + // 新值:遍历 sessionThemes(chat_sessions.theme CHECK 的十个)能落库;parents / children 不能落库。 + // 原因:D2 的两个子领域只作用于数据卡,不改数据库;写入口先挡住,免得撞 CHECK 变成 500。 + for (const theme of ["parents", "children"]) { + const parsed = chatSessionWriteSchema.safeParse({ title: "Test", theme, model_id: "test", messages: [], session_type: "consultation", rectification_case_id: null, updated_at: "2026-08-14T00:00:00.000Z" }); + assert.equal(parsed.success, false, theme); + } + for (const theme of sessionThemes) { const parsed = chatSessionWriteSchema.safeParse({ title: "Test", theme, model_id: "test", messages: [], session_type: "consultation", rectification_case_id: null, updated_at: "2026-08-14T00:00:00.000Z" }); assert.equal(parsed.success, true, theme); } diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index fe3b0198..ff32d6b3 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -147,7 +147,10 @@ test("consultation plans are server-owned and bounded", () => { assert.match(tools, /plan: domainPlan/); assert.match(workflow, /packet_version: "consultation-evidence-packet-v2"/); assert.match(workflow, /claim_cards/); - assert.match(tools, /modelOutput: toModelOutput\(agentContext, domainPlan\)/); + // 原值:/modelOutput: toModelOutput\(agentContext, domainPlan\)/ + // 新值:多传执行领域 domain(parents / children 的必用层按计划领域取,引擎路由是 family)。 + // 原因:TASK-consult-evidence-card-20260927 T2。 + assert.match(tools, /modelOutput: toModelOutput\(agentContext, domainPlan, domain\)/); assert.match(tools, /modelOutput: toModelOutput[\s\S]*ctx\.state\.consultationToolCompleted = true/); assert.match(plan, /precisionBoundary/); assert.match(plan, /consultationPlanMaxCreditCost = 1/); diff --git a/tests/test_consult_evidence_card_research.py b/tests/test_consult_evidence_card_research.py index f2c895df..f5af4db5 100644 --- a/tests/test_consult_evidence_card_research.py +++ b/tests/test_consult_evidence_card_research.py @@ -109,7 +109,13 @@ def test_source_parsers_keep_family_split_gap(): assert "D12" in layers["family"] and "D7" in layers["family"] assert "父母" not in "".join(layers["family"]) assert routes["family"] is None - assert "family" not in must_use + # 原值:assert "family" not in must_use(调研时前端必用层没有 family)。 + # 新值:family / parents / children 都有必用层,父母、子女有独立方法学条目(无命名清单)。 + # 原因:TASK-consult-evidence-card-20260927 T2 按调研结论补齐;调研快照的「缺口」已修。 + assert must_use["family"] == ["Formal Vargas D1–D60", "Narayana Dasha"] + assert must_use["parents"] == must_use["children"] == must_use["family"] + assert routes["parents"] is None and routes["children"] is None + assert "D12" in layers["parents"] and "D7" in layers["children"] assert CARD_SPECS["parents"]["vargas"] == ["D12"] assert CARD_SPECS["children"]["vargas"] == ["D7"] diff --git a/tests/test_consultation_domain_registry.py b/tests/test_consultation_domain_registry.py index cd22b453..30ef601e 100644 --- a/tests/test_consultation_domain_registry.py +++ b/tests/test_consultation_domain_registry.py @@ -70,16 +70,44 @@ def test_typescript_registry_matches_python_canonical_domains_and_aliases() -> N from pathlib import Path source = (Path(__file__).resolve().parents[1] / "frontend/src/lib/consultation-domain-registry.ts").read_text() + # 原值:TS 领域 id == Python CANONICAL_DOMAINS,TS 别名表 == DOMAIN_ALIASES。 + # 新值:TS 的引擎领域(consultationEngineDomainIds)== CANONICAL_DOMAINS;TS 另有两个 + # 只作用于数据卡的子领域 parents / children,引擎侧一律按 family 计算 + # (strictWorkflowRoute / workflowThemes 都是 family)。别名比较按「引擎领域」进行: + # Python 认识的每个别名,在 TS 里落到的领域的引擎路由必须等于 Python 的归一结果; + # TS 独有的别名(父母、孩子等)只能落在 parents / children。 + # 原因:TASK-consult-evidence-card-20260927 D2,家庭拆父母 / 子女,但不改 Python 引擎。 + engine_match = re.search(r"consultationEngineDomainIds = \[([\s\S]*?)\] as const", source) + assert engine_match is not None + assert tuple(re.findall(r'"([^"\n]+)"', engine_match.group(1))) == CANONICAL_DOMAINS + canonical_match = re.search(r"consultationDomainIds = \[([\s\S]*?)\] as const", source) assert canonical_match is not None typescript_domains = tuple(re.findall(r'"([^"\n]+)"', canonical_match.group(1))) - assert typescript_domains == CANONICAL_DOMAINS + card_only = ("parents", "children") + assert tuple(domain for domain in typescript_domains if domain not in card_only) == CANONICAL_DOMAINS + assert set(typescript_domains) - set(CANONICAL_DOMAINS) == set(card_only) - definitions = re.findall(r'\{ id: "([a-z]+)"[^\n]*?aliases: (\[[^\]]*\])', source) - assert tuple(domain for domain, _aliases in definitions) == CANONICAL_DOMAINS + definitions = re.findall( + r'\{ id: "([a-z]+)"[^\n]*?aliases: (\[[^\]]*\]), strictWorkflowRoute: "([a-z]+)", workflowThemes: (\[[^\]]*\])', + source, + ) + assert tuple(domain for domain, _aliases, _route, _themes in definitions) == typescript_domains + engine_route: dict[str, str] = {} typescript_aliases: dict[str, str] = {} - for domain, raw_aliases in definitions: + for domain, raw_aliases, route, raw_themes in definitions: + engine_route[domain] = route + if domain in card_only: + assert route == "family" and json.loads(raw_themes) == ["family"], domain + else: + assert route == domain and json.loads(raw_themes) == [domain], domain for alias in json.loads(raw_aliases): if alias != domain: typescript_aliases[alias] = domain - assert typescript_aliases == DOMAIN_ALIASES + for alias, python_domain in DOMAIN_ALIASES.items(): + assert alias in typescript_aliases or alias in typescript_domains, alias + ts_domain = typescript_aliases.get(alias, alias) + assert engine_route[ts_domain] == python_domain, alias + for alias, ts_domain in typescript_aliases.items(): + if alias not in DOMAIN_ALIASES: + assert ts_domain in card_only, alias