feat(consult): split family into parents and children domains (engine still family)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
75a1844cdf
commit
5c65834dda
@@ -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"]),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ const domainMethodology: Readonly<Record<ConsultationDomain, { strictRoute: stri
|
||||
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 },
|
||||
};
|
||||
|
||||
const ROUTER_FILE = "references/strict-workflow-router.md";
|
||||
|
||||
@@ -258,7 +258,7 @@ function queriesFromSection(section: PublicThinkingSection): string[] {
|
||||
}
|
||||
return uniqueLabels(section.steps
|
||||
.flatMap((step) => {
|
||||
const domainLabel = /^(事业|财富|关系|家庭)/.exec(step.label)?.[1];
|
||||
const domainLabel = /^(事业|财富|关系|家庭|父母|子女)/.exec(step.label)?.[1];
|
||||
return domainLabel ? [domainLabel] : [];
|
||||
})).slice(0, 8);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,8 @@ const generalPromptByTheme: Partial<Record<ConsultationTheme, string>> = {
|
||||
education: "请帮我看看我更适合怎样学习,以及如何安排下一步。",
|
||||
migration: "请帮我分析现阶段是否适合迁居、置业或考虑海外发展。",
|
||||
family: "请帮我看看家庭关系中最需要处理的问题和责任边界。",
|
||||
parents: "请帮我看看我和父母之间最需要处理的问题。",
|
||||
children: "请帮我看看我和孩子相处时最需要留意的地方。",
|
||||
annual: "请帮我看看未来一年的主要趋势、机会和需要留意的阶段。",
|
||||
timing: "请帮我看看目前适合推进什么,哪些事情需要再等等。",
|
||||
general: "请结合我的情况,帮我找出现在最值得优先处理的三个问题。",
|
||||
|
||||
@@ -747,7 +747,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
let calculation: Promise<ReturnType<typeof toModelDomainPlanContext>> | 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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -610,6 +610,11 @@ const DOMAIN_MUST_USE_LAYERS: Readonly<Record<string, readonly string[]>> = {
|
||||
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<typeof toAgentConsultationContext>["evidence_contract"]) {
|
||||
function projectEvidenceContract(
|
||||
contract: ReturnType<typeof toAgentConsultationContext>["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<typeof toAgentConsultationContext>, plan?: ConsultationPlan): ConsultationEvidencePacket {
|
||||
export function toModelOutput(
|
||||
context: ReturnType<typeof toAgentConsultationContext>,
|
||||
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<typeof toAgentConsultationCont
|
||||
question: context.question,
|
||||
route: contract.route,
|
||||
status: contract.core_status,
|
||||
evidence_contract: projectEvidenceContract(contract),
|
||||
evidence_contract: projectEvidenceContract(contract, domain),
|
||||
claim_cards: cards,
|
||||
presentation: {
|
||||
template: "skill_level_2",
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { consultationDomainIds } from "../src/lib/consultation-domain-registry.ts";
|
||||
import { consultationDomainIds, consultationEngineDomainIds } from "../src/lib/consultation-domain-registry.ts";
|
||||
|
||||
const migration = readFileSync(
|
||||
new URL("../supabase/migrations/20260814050000_consultation_domain_registry.sql", import.meta.url),
|
||||
@@ -16,7 +16,14 @@ function themeConstraintValues(sql: string) {
|
||||
}
|
||||
|
||||
test("chat_sessions persists exactly the canonical consultation domains", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user