Files
Jyotisha/frontend/src/lib/consultation-methodology.ts
T
jesse-ux 3874a328a5 feat(consult): copy the full-data packet into a per-domain judgment table
Dialogue cards now take numbers from the same packet as the full-data report. Each of the twelve domains gets one table the card builder assembles. Lookups allow three calls a turn. further_reading is a per-domain technique index. BUG-1293, BUG-1294, BUG-1295.
2026-10-09 10:00:45 +08:00

393 lines
17 KiB
TypeScript

import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { z } from "zod";
import {
consultationDomainDefinition,
consultationDomainIds,
type ConsultationDomain,
} from "./consultation-domain-registry.ts";
import { resolveLiveJyotishSkill } from "./skill-package-registry.ts";
import {
CONDENSED_CHECKLIST_SOURCE,
CONDENSED_SHARED_READING_SOURCE,
CONDENSED_SHARED_READING_TITLE,
CONSULT_BRIEF_CHECKLIST_SOURCE,
condensedConsultationChecklist,
sharedConsultationReading,
} from "./consultation-condensed-checklist.ts";
import type { ProfileGender } from "./profile-gender.ts";
/**
* The strict checklist each consultation domain must be read against, named in
* the vocabulary of the skill's own router rather than this codebase's.
*
* Activating the skill hands the model its instructions plus a flat list of
* every file in the package: on the Jyotish package that is over 1500 paths and
* about 99KB of undifferentiated filenames, against 30KB of actual method. The
* one line telling it to open `references/strict-workflow-router.md` is inside
* that method, so in practice no reference was ever opened and every answer was
* composed from the model's own background knowledge over server evidence.
*
* The route is already decided server-side, and the skill already states which
* checklist each route requires, so the selection needs no model turn: read the
* mandated sections here and deliver them with the evidence.
*
* Career, marriage and wealth deliver the condensed consultation checklist
* (consultation-condensed-checklist.ts, TASK-consult-evidence-card-v2-20260927
* T4) in place of the full strict route plus event-judgment file; the table
* below still names the route those lists were condensed from. This function
* serves the web consultation only; the router file itself is unchanged.
*
* TASK-consult-affliction-reading-20261001 decision D6 overturns the older rule
* "a checklist the router does not declare is reported as absent instead of
* substituted" for the web consultation: every domain now has a list. The
* `consult-<domain>` 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<Record<ConsultationDomain, { strictRoute: string | null; eventJudgment: string | null }>> = {
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 },
// 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 },
};
// Annual and timing single-domain turns used to skip the shared reading and the
// annual list on a 12,000-character ceiling (BUG-1160/1161). Product decision
// 2026-10-02: they take the career / marriage / wealth budget instead (contract
// + card <= 12,000, total with the checklist <= 18,000), so every turn gets both.
// 2026-10-03 (BUG-1221): every card carries the Rath running MD; the budget is
// now 12,500 / 18,500.
/** Domains whose list was written by the affliction-reading brief rather than condensed from the router. */
const BRIEF_WRITTEN_DOMAINS: ReadonlySet<ConsultationDomain> = 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";
/** Per-section and whole-block ceilings, so one long section cannot crowd out the evidence. */
export const METHODOLOGY_SECTION_MAX_CHARS = 6_000;
export const METHODOLOGY_TOTAL_MAX_CHARS = 24_000;
/** Per-domain technique index: up to 8 files times the six-domain turn cap. */
export const METHODOLOGY_INDEX_MAX_ENTRIES = 48;
const DOMAIN_TECHNIQUE_PATHS: Readonly<Record<ConsultationDomain, readonly string[]>> = {
career: [
"references/event_judgment_career.md",
"references/house-domain-planet-mapping.md",
"references/strict-workflow-router.md",
"references/shadbala-complete-methodology.md",
"references/ashtakavarga-complete-system.md",
"references/argala-complete-guide.md",
"references/jaimini-complete-system.md",
"references/varga-divisional-charts-quick-reference.md",
],
marriage: [
"references/event_judgment_marriage.md",
"references/navamsa-marriage-deep-analysis.md",
"references/darakaraka-complete-guide.md",
"references/navamsa-d9-interpretation-template.md",
"references/strict-workflow-router.md",
"references/jaimini-complete-system.md",
"references/house-domain-planet-mapping.md",
"references/shadbala-complete-methodology.md",
],
wealth: [
"references/event_judgment_wealth.md",
"references/house-domain-planet-mapping.md",
"references/strict-workflow-router.md",
"references/ashtakavarga-complete-system.md",
"references/shadbala-complete-methodology.md",
"references/varga-divisional-charts-quick-reference.md",
"references/deep-analysis-complete-workflow.md",
"references/promise-assessment-templates.md",
],
health: [
"references/strict-workflow-router.md",
"references/shodasavarga-complete-guide.md",
"references/badhaka-obstacle-planet-guide.md",
"references/house-domain-planet-mapping.md",
"references/shadbala-complete-methodology.md",
"references/ashtakavarga-complete-system.md",
"references/varga-divisional-charts-quick-reference.md",
"references/deep-varga-avastha-execution-guide.md",
],
education: [
"references/strict-workflow-router.md",
"references/house-domain-planet-mapping.md",
"references/varga-divisional-charts-quick-reference.md",
"references/shodasavarga-complete-guide.md",
"references/shadbala-complete-methodology.md",
"references/jaimini-complete-system.md",
"references/deep-analysis-complete-workflow.md",
"references/event_judgment_skeleton.md",
],
migration: [
"references/birth-time-rectification-decision-tree.md",
"references/house-domain-planet-mapping.md",
"references/strict-workflow-router.md",
"references/varga-divisional-charts-quick-reference.md",
"references/shadbala-complete-methodology.md",
"references/jaimini-complete-system.md",
"references/ashtakavarga-complete-system.md",
"references/event_judgment_skeleton.md",
],
family: [
"references/strict-workflow-router.md",
"references/house-domain-planet-mapping.md",
"references/varga-divisional-charts-quick-reference.md",
"references/jaimini-complete-system.md",
"references/shadbala-complete-methodology.md",
"references/event_judgment_skeleton.md",
"references/deep-analysis-complete-workflow.md",
"references/argala-complete-guide.md",
],
parents: [
"references/strict-workflow-router.md",
"references/house-domain-planet-mapping.md",
"references/jaimini-complete-system.md",
"references/varga-divisional-charts-quick-reference.md",
"references/shadbala-complete-methodology.md",
"references/shodasavarga-complete-guide.md",
"references/event_judgment_skeleton.md",
"references/deep-varga-avastha-execution-guide.md",
],
children: [
"references/strict-workflow-router.md",
"references/house-domain-planet-mapping.md",
"references/jaimini-complete-system.md",
"references/varga-divisional-charts-quick-reference.md",
"references/shadbala-complete-methodology.md",
"references/event_judgment_skeleton.md",
"references/ashtakavarga-complete-system.md",
"references/deep-analysis-complete-workflow.md",
],
annual: [
"references/varshaphala-annual-chart-guide.md",
"references/tajika-yoga-complete-guide.md",
"references/vimshottari_dasha_guide.md",
"references/bphs-ch48-narayana-dasha.md",
"references/strict-workflow-router.md",
"references/multi-dasha-convergence-protocol.md",
"references/alternative-dasha-systems.md",
"references/event_judgment_skeleton.md",
],
timing: [
"references/bphs-ch48-narayana-dasha.md",
"references/vimshottari_dasha_guide.md",
"references/multi-dasha-convergence-protocol.md",
"references/alternative-dasha-systems.md",
"references/varshaphala-annual-chart-guide.md",
"references/strict-workflow-router.md",
"references/event_judgment_skeleton.md",
"references/tajika-yoga-complete-guide.md",
],
general: [
"references/strict-workflow-router.md",
"references/house-domain-planet-mapping.md",
"references/event_judgment_skeleton.md",
"references/shadbala-complete-methodology.md",
"references/ashtakavarga-complete-system.md",
"references/jaimini-complete-system.md",
"references/pdf-chart-reading-guide.md",
"references/varga-divisional-charts-quick-reference.md",
],
};
export const consultationMethodologySectionSchema = z.object({
title: z.string().max(200),
source: z.string().max(300),
text: z.string().max(METHODOLOGY_SECTION_MAX_CHARS),
}).strict();
export const consultationMethodologySchema = z.object({
skill: z.string().max(120),
version: z.string().max(60),
/** Domains whose strict checklist the router does not declare; nothing was substituted for them. */
domains_without_strict_checklist: z.array(z.enum(consultationDomainIds)).max(6),
sections: z.array(consultationMethodologySectionSchema).max(12),
/** The references SKILL.md itself names, for reading on purpose with skill_read. */
further_reading: z.array(z.string().max(300)).max(METHODOLOGY_INDEX_MAX_ENTRIES),
truncated: z.boolean(),
}).strict();
export type ConsultationMethodology = z.infer<typeof consultationMethodologySchema>;
const skill = resolveLiveJyotishSkill();
/** Live skill files are reread only after process restart; consult requests share one cache. */
const fileCache = new Map<string, string | null>();
/** A Windows checkout may store a skill symlink as a one-line relative path. */
function resolvePackageFile(relativePath: string): string {
const parts = relativePath.split(/[\\/]/).filter(Boolean);
let current = skill.resolvedPath;
for (let index = 0; index < parts.length; index += 1) {
const next = resolve(current, parts[index]!);
if (index < parts.length - 1 && existsSync(next)) {
const stats = statSync(next);
if (stats.isFile() && stats.size <= 512) {
const text = readFileSync(next, "utf8").replace(/^\uFEFF/, "").trim();
if (text.startsWith(".") && !/[\r\n\0]/.test(text)) {
current = resolve(current, text);
continue;
}
}
}
current = next;
}
return current;
}
function packageFile(relativePath: string): string | null {
const hit = fileCache.get(relativePath);
if (hit !== undefined) return hit;
let content: string | null = null;
try {
content = readFileSync(resolvePackageFile(relativePath), "utf8");
} catch {
content = null;
}
fileCache.set(relativePath, content);
return content;
}
function domainTechniqueIndex(domains: readonly ConsultationDomain[]): string[] {
const rows: string[] = [];
for (const domain of domains) {
let kept = 0;
for (const path of DOMAIN_TECHNIQUE_PATHS[domain]) {
if (kept >= 8) break;
if (path.includes("oracle/") || path.toLowerCase().includes("governance")) continue;
if (packageFile(path) === null) continue;
rows.push(`${domain}: ${path}`);
kept += 1;
if (rows.length >= METHODOLOGY_INDEX_MAX_ENTRIES) return rows;
}
}
return rows;
}
/**
* The body of one `## ...` section, found by a heading substring so the section
* numbering in the router can be renumbered without breaking the lookup.
*/
export function markdownSection(source: string, headingContains: string): string | null {
const lines = source.split("\n");
const start = lines.findIndex((line) => line.startsWith("## ") && line.includes(headingContains));
if (start === -1) return null;
const rest = lines.slice(start + 1);
const end = rest.findIndex((line) => line.startsWith("## "));
const body = (end === -1 ? rest : rest.slice(0, end)).join("\n").trim();
return body.length > 0 ? `${lines[start]}\n\n${body}` : null;
}
function clamp(text: string) {
return text.length > METHODOLOGY_SECTION_MAX_CHARS
? `${text.slice(0, METHODOLOGY_SECTION_MAX_CHARS - 3)}...`
: text;
}
const planCache = new Map<string, ConsultationMethodology>();
/**
* The strict method for one domain plan, read from the live skill tree.
*/
export function consultationMethodologyForDomains(
domains: readonly ConsultationDomain[],
options: Readonly<{ gender?: ProfileGender | null }> = {},
): ConsultationMethodology | null {
const unique = [...new Set(domains)];
// The marriage checklist depends on the current subject's gender, so the
// cache is per gender too: one person's value never reaches another's turn.
const gender = options.gender ?? null;
const key = `${unique.join(",")}|${gender ?? "unknown"}`;
const hit = planCache.get(key);
if (hit) return hit;
const router = packageFile(ROUTER_FILE);
const instructions = packageFile("SKILL.md");
if (!router || !instructions) return null;
const sections: z.infer<typeof consultationMethodologySectionSchema>[] = [];
let budget = METHODOLOGY_TOTAL_MAX_CHARS;
let truncated = false;
const push = (title: string, source: string, text: string | null) => {
if (!text) return;
const bounded = clamp(text);
if (bounded.length > budget) {
truncated = true;
return;
}
budget -= bounded.length;
sections.push({ title, source, text: bounded });
};
// Once per turn, before anything else (T1), however many domains it plans.
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 });
if (condensed) {
push(
`${consultationDomainDefinition(domain).label} · ${strictRoute} · 普通对话精简清单`,
BRIEF_WRITTEN_DOMAINS.has(domain) ? CONSULT_BRIEF_CHECKLIST_SOURCE : CONDENSED_CHECKLIST_SOURCE,
condensed,
);
continue;
}
if (!strictRoute || BRIEF_WRITTEN_DOMAINS.has(domain)) {
withoutChecklist.push(domain);
} else {
const section = markdownSection(router, strictRoute);
if (section) {
push(`${consultationDomainDefinition(domain).label} · ${strictRoute}`, ROUTER_FILE, section);
} else {
withoutChecklist.push(domain);
}
}
if (eventJudgment) {
push(`${consultationDomainDefinition(domain).label} · event judgment`, `references/${eventJudgment}`, packageFile(`references/${eventJudgment}`));
}
}
const methodology = consultationMethodologySchema.parse({
skill: skill.name,
version: skill.version,
domains_without_strict_checklist: withoutChecklist,
sections,
further_reading: domainTechniqueIndex(unique),
truncated,
});
planCache.set(key, methodology);
return methodology;
}
/** Cross-domain method that belongs in the cached system block, not the tool result. */
export function sharedConsultationMethodMarkdown(): string {
const router = packageFile(ROUTER_FILE);
const fullSpectrum = router ? markdownSection(router, "Full-Spectrum Invocation Contract") : null;
const skeleton = packageFile(SKELETON_FILE);
return [fullSpectrum, skeleton].filter((part): part is string => Boolean(part)).join("\n\n");
}