From 7f39c9346d95253f0812b0b0347900d42941c4ad Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 1 Oct 2026 09:24:47 +0800 Subject: [PATCH] feat(consult): adopted segment charts that cannot be determined reach the model as policy and one plain sentence (BUG-1138, T2) The shared range loader attaches unreliableVargas for segment-v1 adoptions; the consultation tool input carries it, runConsultationWorkflow strips it from the engine payload and writes answer_policy.unreliable_vargas, deterministic_claims_forbidden_for and a user-facing limitation. Legacy and unrectified profiles are byte-identical (request, context, packet). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- .../src/lib/consultation-birth-time-mode.ts | 55 +++++++ .../src/lib/consultation-route-service.ts | 2 + frontend/src/lib/report-candidate-range.ts | 35 ++++- frontend/src/mastra/consultation-workflow.ts | 11 +- ...ication-chart-tier-caveats-consult.test.ts | 135 ++++++++++++++++++ 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 frontend/tests/rectification-chart-tier-caveats-consult.test.ts diff --git a/frontend/src/lib/consultation-birth-time-mode.ts b/frontend/src/lib/consultation-birth-time-mode.ts index 808481ea..6402e99d 100644 --- a/frontend/src/lib/consultation-birth-time-mode.ts +++ b/frontend/src/lib/consultation-birth-time-mode.ts @@ -67,3 +67,58 @@ export function applyBirthTimeModeToWorkflowContext< } + +/** Labels follow VOICE.md (「婚恋(D9)会随分钟变」). */ +export const UNRELIABLE_VARGA_LABELS: Readonly> = { + D1: "本命盘(D1)", + D9: "婚恋(D9)", + D10: "事业(D10)", +}; + +/** + * One plain sentence for charts a segment-v1 adoption could not determine (BUG-1138). + * Server-written, so the model repeats it rather than inventing its own caveat. + */ +export function unreliableVargasNotice(vargas: readonly ("D1" | "D9" | "D10")[]): string { + const names = vargas.map((varga) => UNRELIABLE_VARGA_LABELS[varga]).join("、"); + const these = vargas.length > 1 ? "这几张盘" : "这张盘"; + const it = vargas.length > 1 ? "它们" : "它"; + const fallback = vargas.includes("D1") ? `涉及${it}的部分不按上升和宫位下结论` : `涉及${it}的部分只看本命盘和大运,不按${it}下结论`; + return `你的出生时间范围里,${names}的上升会变,${these}判断不了;${fallback}。`; +} + +/** + * Carry the adopted chart tiers into the workflow context the model reads: + * `answer_policy.unreliable_vargas` / `deterministic_claims_forbidden_for` and a + * user-facing limitation sentence. A context without unreliable charts is returned + * as-is, so legacy and unrectified profiles are byte-for-byte unchanged. + */ +export function applyUnreliableVargasToWorkflowContext< + T extends { + consumer_context: { + answer_policy: Record; + [key: string]: unknown; + }; + [key: string]: unknown; + }, +>(context: T, vargas: readonly ("D1" | "D9" | "D10")[] | undefined): T { + if (!vargas?.length) return context; + const notice = unreliableVargasNotice(vargas); + const policy = context.consumer_context.answer_policy; + const forbidden = Array.isArray(policy.deterministic_claims_forbidden_for) ? policy.deterministic_claims_forbidden_for : []; + const existing = typeof context.consumer_context.user_facing_limitation === "string" + ? context.consumer_context.user_facing_limitation.trim() + : ""; + return { + ...context, + consumer_context: { + ...context.consumer_context, + user_facing_limitation: existing ? `${existing} ${notice}` : notice, + answer_policy: { + ...policy, + unreliable_vargas: [...vargas], + deterministic_claims_forbidden_for: [...new Set([...forbidden.map(String), ...vargas])], + }, + }, + }; +} diff --git a/frontend/src/lib/consultation-route-service.ts b/frontend/src/lib/consultation-route-service.ts index a4d7e42e..304271c8 100644 --- a/frontend/src/lib/consultation-route-service.ts +++ b/frontend/src/lib/consultation-route-service.ts @@ -57,6 +57,7 @@ type ServerChartToolInput = Readonly<{ time_source: string; birth_time_accuracy?: "provisional"; candidate_range?: Readonly<{ start_time: string; end_time: string }>; + unreliable_vargas?: readonly ("D1" | "D9" | "D10")[]; }>; export type ServerChartConsultation = Readonly<{ @@ -594,6 +595,7 @@ export async function prepareConsultationRoute( end_time: range.endTime, ...(range.candidate_intervals ? { candidate_intervals: range.candidate_intervals } : {}), }), + ...(range.unreliableVargas?.length ? { unreliable_vargas: [...range.unreliableVargas] } : {}), }), }); } diff --git a/frontend/src/lib/report-candidate-range.ts b/frontend/src/lib/report-candidate-range.ts index 23a73914..d23e8564 100644 --- a/frontend/src/lib/report-candidate-range.ts +++ b/frontend/src/lib/report-candidate-range.ts @@ -1,3 +1,6 @@ +import { loadAdoptedChartTiers } from "./rectification-adopted-chart-tiers.ts"; +import type { TargetChart } from "./rectification-agentic/core/segment-summary.ts"; + export const READ_REPORT_CANDIDATE_RANGE_RPC = "read_report_candidate_range"; export const ADOPTED_CREDIBLE_RANGE_SOURCE = "inference_credible_range"; @@ -5,6 +8,8 @@ export type ReportCandidateClockRange = Readonly<{ startTime: string; endTime: string; candidate_intervals?: readonly Readonly<{ start_at: string; end_at: string }>[]; + /** Charts a segment-v1 adoption could not determine (BUG-1138/1139); absent otherwise. */ + unreliableVargas?: readonly TargetChart[]; }>; export type AdoptedCredibleRangePayload = Readonly<{ @@ -106,7 +111,10 @@ export async function loadReportCandidateRange( }); return null; } - return parseReportCandidateRange(result.data); + const range = parseReportCandidateRange(result.data); + if (!range) return null; + const unreliable = await loadUnreliableVargas(client, input.userId); + return unreliable.length ? { ...range, unreliableVargas: unreliable } : range; } catch (error) { warn({ event: "report_candidate_range_unavailable", @@ -116,6 +124,31 @@ export async function loadReportCandidateRange( } } +type ProfileProvenanceQuery = Readonly<{ + select: (columns: string) => Readonly<{ + eq: (column: string, value: string) => Readonly<{ maybeSingle: () => PromiseLike<{ data: unknown; error: unknown }> }>; + }>; +}>; + +/** + * Report routes and the consultation route share this loader (the consultation + * route wires it by name), so the adopted chart tiers ride on the range it + * already returns. Only the service client has `from`; a failure here never + * drops the range itself. + */ +async function loadUnreliableVargas(client: ReportCandidateRangeRpcClient, userId: string): Promise { + const from = (client as { from?: (table: string) => ProfileProvenanceQuery }).from; + if (typeof from !== "function") return []; + try { + const { data, error } = await from.call(client, "profiles").select("active_birth_provenance").eq("id", userId).maybeSingle(); + if (error || !data || typeof data !== "object") return []; + const tiers = await loadAdoptedChartTiers(client, { userId, provenance: (data as Record).active_birth_provenance }); + return tiers?.unreliable ?? []; + } catch { + return []; + } +} + function matchClock(value: unknown): string | null { if (typeof value !== "string") return null; return value.trim().match(candidateClockPattern)?.[1] ?? null; diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index 1dd57d9a..f98b4997 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { consultationEvidenceCategoryValues, createConsultationPlan, type ConsultationPlan } from "../lib/consultation-plan.ts"; import { consultationThemeValues, projectConsultationWorkflowRequest } from "../lib/consultation-workflow-request.ts"; import { AYANAMSA_VALUES, DEFAULT_AYANAMSA } from "../lib/ayanamsa.ts"; +import { applyUnreliableVargasToWorkflowContext } from "../lib/consultation-birth-time-mode.ts"; export const consultationInputSchema = z.object({ year: z.number().int().min(1900).max(2100), @@ -26,6 +27,9 @@ export const consultationInputSchema = z.object({ declared_window_end: z.string().optional(), uncertainty_before_minutes: z.number().int().min(0).max(720).optional(), uncertainty_after_minutes: z.number().int().min(0).max(720).optional(), + // Charts a segment-v1 adoption could not determine (BUG-1138). Server-owned, + // never sent to the engine; it only shapes the context the model reads. + unreliable_vargas: z.array(z.enum(["D1", "D9", "D10"])).min(1).optional(), }); export type ConsultationInput = z.infer; type JsonRecord = Record; @@ -133,7 +137,7 @@ export async function runConsultationWorkflow( entrypoint?: string; }, ) { - const { entryMode, question, theme, ...workflowInput } = input; + const { entryMode, question, theme, unreliable_vargas: unreliableVargas, ...workflowInput } = input; const plan = options?.plan ?? createConsultationPlan({ userIntent: question, theme }); const workflowRequest = projectConsultationWorkflowRequest(question, theme, plan); const timeout = AbortSignal.timeout(90_000); @@ -177,7 +181,7 @@ export async function runConsultationWorkflow( "Jyotish API returned an incomplete consultation contract", ); } - return attachMinuteSensitiveThemes(parsed.data); + return applyUnreliableVargasToWorkflowContext(attachMinuteSensitiveThemes(parsed.data), unreliableVargas); } export function minuteSensitiveThemesFromBirthTimeSensitivity(value: unknown): string[] { @@ -723,6 +727,9 @@ function projectEvidenceContract( ...(typeof policy.should_lead_with_limitations === "boolean" ? { should_lead_with_limitations: policy.should_lead_with_limitations } : {}), + ...(Array.isArray(policy.unreliable_vargas) && policy.unreliable_vargas.length + ? { unreliable_vargas: stringList(policy.unreliable_vargas, 3), deterministic_claims_forbidden_for: stringList(policy.unreliable_vargas, 3) } + : {}), }, ...(typeof contract.user_facing_limitation === "string" ? { user_facing_limitation: boundedPrimitive(contract.user_facing_limitation) } diff --git a/frontend/tests/rectification-chart-tier-caveats-consult.test.ts b/frontend/tests/rectification-chart-tier-caveats-consult.test.ts new file mode 100644 index 00000000..5e58661f --- /dev/null +++ b/frontend/tests/rectification-chart-tier-caveats-consult.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts"; +import { applyUnreliableVargasToWorkflowContext, unreliableVargasNotice } from "../src/lib/consultation-birth-time-mode.ts"; +import { loadReportCandidateRange } from "../src/lib/report-candidate-range.ts"; +import { clearAdoptedChartTierCache } from "../src/lib/rectification-adopted-chart-tiers.ts"; +import { buildSegmentSummary, type SegmentMinute } from "../src/lib/rectification-agentic/core/segment-summary.ts"; +import { runConsultationWorkflow, toAgentConsultationContext, toModelOutput, type ConsultationInput } from "../src/mastra/consultation-workflow.ts"; + +// Real engine consultation_workflow response for a public Rodden-AA chart. +const golden = JSON.parse(readFileSync(new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url), "utf8")); +const realWorkflow = golden.charts[0].workflow; + +const userId = "11111111-1111-4111-8111-111111111111"; +const caseId = "22222222-2222-4222-8222-222222222222"; +const resultId = "33333333-3333-4333-8333-333333333333"; + +// Explicitly fictional profile (not a real person). +const profile = Object.freeze({ + name: "虚构测试", birth_date: "2000-06-14", reported_birth_time: "09:10:00", active_birth_time: "09:05:00", + birth_time_source: "approximate", birth_time_status: "accepted", country_code: "CN", province_code: "130000", + city_code: "130400", district_code: "130406", latitude: 36.420487, longitude: 114.209936, timezone_offset: 8, +}); + +// Explicitly fictional ±60 summary: a 121-minute window blocks D9/D10 by width. +function wideSummary() { + const minutes: SegmentMinute[] = Array.from({ length: 121 }, (_, offset) => ({ + offset, date: "2000-06-14", time: `${String(8 + Math.floor(offset / 60)).padStart(2, "0")}:${String(offset % 60).padStart(2, "0")}`, + signs: { D1: 4, D9: Math.floor(offset / 13) % 12, D10: Math.floor(offset / 12) % 12 }, + })); + return buildSegmentSummary({ minutes, candidates: [{ time: minutes[60]!.time, score: 10 }], windowMinutes: 121, scanComplete: true }); +} + +function accountingClient(provenance: unknown, calls: string[] = []) { + return { + rpc: async (fn: string) => { + calls.push(fn); + if (fn === "read_report_candidate_range") return { data: { start_time: "08:00", end_time: "10:00" }, error: null }; + if (fn === "get_agentic_rectification_case_dossier") return { data: { case: { case_id: caseId }, + latest_result: { result_id: resultId, decision_receipt: { inference_state: { segment_summary: wideSummary() } } } }, error: null }; + throw new Error(`unexpected RPC ${fn}`); + }, + from(table: string) { + assert.equal(table, "profiles"); + calls.push("profiles"); + return { select: (columns: string) => { assert.equal(columns, "active_birth_provenance"); return { + eq: (column: string, value: string) => { assert.equal(column, "id"); assert.equal(value, userId); return { + maybeSingle: async () => ({ data: { active_birth_provenance: provenance }, error: null }) }; } }; } }; + }, + }; +} + +const segmentV1 = { contract: "segment-v1", case_id: caseId, result_id: resultId }; +const legacyAdoption = { contract: "candidate-v2", case_id: caseId, result_id: resultId }; + +test("range loader: segment-v1 adds unreliable charts; legacy returns the identical range object", async () => { + clearAdoptedChartTierCache(); + const segment = await loadReportCandidateRange(accountingClient(segmentV1), { userId }); + assert.deepEqual(segment, { startTime: "08:00", endTime: "10:00", unreliableVargas: ["D9", "D10"] }); + const legacy = await loadReportCandidateRange(accountingClient(legacyAdoption), { userId }); + assert.equal(JSON.stringify(legacy), JSON.stringify({ startTime: "08:00", endTime: "10:00" })); + // An rpc-only client (no profile access) and a failing profile read never drop the range. + const rpcOnly = await loadReportCandidateRange({ rpc: accountingClient(segmentV1).rpc }, { userId }); + assert.equal(JSON.stringify(rpcOnly), JSON.stringify({ startTime: "08:00", endTime: "10:00" })); + const broken = { ...accountingClient(segmentV1), from: () => { throw new Error("boom"); } }; + assert.equal(JSON.stringify(await loadReportCandidateRange(broken, { userId })), JSON.stringify({ startTime: "08:00", endTime: "10:00" })); +}); + +test("chat tool input: segment-v1 carries unreliable_vargas; legacy tool input is byte-identical", async () => { + clearAdoptedChartTierCache(); + const prepare = (provenance: unknown) => prepareConsultationRoute({ + userId, mode: "verified_chart", + loadProfile: async () => profile, + loadCandidateRange: (id) => loadReportCandidateRange(accountingClient(provenance), { userId: id }), + reserve: async () => "reserved", + }); + const segment = await prepare(segmentV1); + const legacy = await prepare(legacyAdoption); + assert.deepEqual(segment.serverChart?.toolInput.unreliable_vargas, ["D9", "D10"]); + assert.equal("unreliable_vargas" in (legacy.serverChart?.toolInput ?? {}), false); + const { unreliable_vargas: _dropped, ...rest } = segment.serverChart!.toolInput; + assert.equal(JSON.stringify(rest), JSON.stringify(legacy.serverChart!.toolInput)); + // Byte-identical snapshot of the legacy accepted tool input (red line 1). + assert.equal(JSON.stringify(legacy.serverChart!.toolInput), + '{"year":2000,"month":6,"day":14,"hour":9,"minute":5,"city":"中国 · 河北省 · 邯郸市 · 峰峰矿区","lat":36.420487,"lon":114.209936,"tz":8,"ayanamsa":"raman","declared_accuracy":"rectified","time_source":"rectified","birth_time_accuracy":"provisional","candidate_range":{"start_time":"08:00","end_time":"10:00"}}'); +}); + +async function runWith(input: ConsultationInput) { + let body = ""; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_url, init) => { + body = typeof init?.body === "string" ? init.body : ""; + return new Response(JSON.stringify(realWorkflow), { status: 200 }); + }; + try { + return { context: await runConsultationWorkflow(input), body: JSON.parse(body) as Record }; + } finally { + globalThis.fetch = originalFetch; + } +} + +test("workflow: unreliable_vargas never reaches the engine; the model packet gets the policy and one sentence", async () => { + const base: ConsultationInput = { + year: 2000, month: 6, day: 14, hour: 9, minute: 5, lat: 39.9, lon: 116.4, tz: 8, ayanamsa: "raman", + city: "北京", question: "我的婚姻怎么样?", theme: "marriage", entryMode: "direct_chart", + declared_accuracy: "rectified", time_source: "rectified", birth_time_accuracy: "provisional", + candidate_range: { start_time: "08:00", end_time: "10:00" }, + }; + const legacy = await runWith(base); + const segment = await runWith({ ...base, unreliable_vargas: ["D9", "D10"] }); + // The engine payload is identical with or without the server-owned field. + assert.equal(JSON.stringify(segment.body), JSON.stringify(legacy.body)); + assert.equal("unreliable_vargas" in segment.body, false); + // Legacy context equals the untouched engine projection. + assert.equal(JSON.stringify(legacy.context), JSON.stringify(applyUnreliableVargasToWorkflowContext(legacy.context, undefined))); + const policy = segment.context.consumer_context.answer_policy as Record; + assert.deepEqual(policy.unreliable_vargas, ["D9", "D10"]); + assert.ok((policy.deterministic_claims_forbidden_for as string[]).includes("D9")); + const notice = unreliableVargasNotice(["D9", "D10"]); + assert.equal(notice, "你的出生时间范围里,婚恋(D9)、事业(D10)的上升会变,这几张盘判断不了;涉及它们的部分只看本命盘和大运,不按它们下结论。"); + assert.ok(String(segment.context.consumer_context.user_facing_limitation).endsWith(notice)); + + const legacyPacket = toModelOutput(toAgentConsultationContext(legacy.context)); + const segmentPacket = toModelOutput(toAgentConsultationContext(segment.context)); + assert.deepEqual((segmentPacket.evidence_contract.answer_policy as Record).unreliable_vargas, ["D9", "D10"]); + assert.ok(String(segmentPacket.evidence_contract.user_facing_limitation).includes("婚恋(D9)")); + assert.equal("unreliable_vargas" in (legacyPacket.evidence_contract.answer_policy as Record), false); + assert.ok(!String(legacyPacket.evidence_contract.user_facing_limitation ?? "").includes("婚恋(D9)")); +}); + +test("notice wording for one chart and for D1", () => { + assert.equal(unreliableVargasNotice(["D9"]), "你的出生时间范围里,婚恋(D9)的上升会变,这张盘判断不了;涉及它的部分只看本命盘和大运,不按它下结论。"); + assert.match(unreliableVargasNotice(["D1", "D9"]), /不按上升和宫位下结论/); +});