From a3196584d2457c310b98d4ca7faaf58de51bf501 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 20 Aug 2026 07:47:10 +0800 Subject: [PATCH] feat(rectification): add refinement packet without unique-minute claims Surface D9/D10 change minutes, event-dasha match copy, dual-dasha conflict, and a post-adopt consult handoff so users can keep narrowing or start a reading from a representative time. Co-authored-by: Cursor --- frontend/src/app/globals.css | 39 ++ frontend/src/app/page.tsx | 7 + ...onversational-birth-time-rectification.tsx | 1 + .../components/rectification-agentic-chat.tsx | 107 ++++- .../v9/method-followup.ts | 105 ++++- .../v9/refinement-packet.ts | 300 +++++++++++++ .../v9/varga-observations.ts | 31 +- .../src/lib/rectification-candidate-result.ts | 37 ++ frontend/src/mastra/agentic-rectification.ts | 3 +- frontend/src/mastra/rectification-v9-tools.ts | 20 + .../tests/rectification-agentic-entry.test.ts | 12 + .../rectification-candidate-result.test.ts | 44 ++ .../tests/rectification-eight-method.test.ts | 57 +++ scripts/rectification/decision_policy.py | 24 + scripts/rectification/diagnostics_service.py | 29 +- scripts/rectification/house_table.py | 15 + scripts/rectification/refinement_packet.py | 412 ++++++++++++++++++ ...test_rectification_diagnostics_clusters.py | 5 + tests/test_rectification_refinement_packet.py | 176 ++++++++ 19 files changed, 1384 insertions(+), 40 deletions(-) create mode 100644 frontend/src/lib/rectification-agentic/v9/refinement-packet.ts create mode 100644 scripts/rectification/refinement_packet.py create mode 100644 tests/test_rectification_refinement_packet.py diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 122df77f..f3e2e7bd 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1922,6 +1922,45 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink); font-size: var(--type-body-sm); } +.rectification-saved-wrap { + display: grid; + gap: var(--space-3); + width: calc(100% - var(--assistant-content-inset)); + margin: 8px 0 16px; + margin-inline-start: var(--assistant-content-inset); +} +.rectification-saved-wrap .rectification-saved { + width: auto; + margin: 0; + margin-inline-start: 0; +} +.rectification-refinement { + display: grid; + gap: var(--space-3); + width: calc(100% - var(--assistant-content-inset)); + margin: 8px 0 16px; + margin-inline-start: var(--assistant-content-inset); + padding: 18px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas-soft); + color: var(--color-ink); + font-size: var(--type-body-sm); + line-height: 1.55; +} +.rectification-refinement strong { + font-size: var(--type-title-sm); + font-family: var(--font-display); + font-weight: 600; + letter-spacing: -.2px; +} +.rectification-refinement ul { + margin: 0; + padding-inline-start: 1.1em; +} +.rectification-refinement p, +.rectification-refinement__stage { margin: 0; color: var(--color-ink-secondary); } +.rectification-consult-handoff { display: flex; } .rectification-house-table { display: grid; gap: var(--space-3); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index fdd410de..8c6cd2f5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2060,6 +2060,12 @@ export default function Home() { } } + async function startConsultationAfterRectification() { + await startNewChat(); + setDraft("请用刚才采用的代表性出生时间看盘。"); + setComposerNotice("已用刚才采用的时间作为当前排盘。这还不是唯一分钟确认。"); + } + function selectSession(sessionId: string) { const nextSession = sessions.find((session) => session.id === sessionId); setActiveSessionId(sessionId); @@ -3789,6 +3795,7 @@ export default function Home() { onPendingChange={setRectificationMutationPending} onProfileIncomplete={handleRectificationProfileIncomplete} onSaved={() => void refreshAccount()} + onStartConsultation={() => void startConsultationAfterRectification()} pendingConsultationQuestion={rectificationPendingQuestion} onRestart={() => void startNewRectification()} /> diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 0063a974..f6456aed 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -33,6 +33,7 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{ onPendingChange?: (pending: boolean) => void; onProfileIncomplete?: () => void; onSaved?: (time: string, status: "accepted" | "confirmed") => void; + onStartConsultation?: () => void; pendingConsultationQuestion?: string | null; onRestart?: () => void; }>; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index a2c46c83..7aefd018 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -101,6 +101,92 @@ function RectificationCandidateCards({ ); } +function RectificationRefinementPanel({ + result, +}: Readonly<{ result: RectificationCandidateResult }>) { + const transitions = result.windowTransitions; + const ledger = result.eventDashaLedger; + const agreement = result.dashaAgreement; + const contrast = result.lagnaContrast; + const nakshatra = result.nakshatraBoundary; + const stage = result.precisionStage; + if ( + transitions.length === 0 + && ledger.length === 0 + && !agreement + && !contrast + && !nakshatra?.near_boundary + && !stage + ) return null; + return ( +
+ {stage && ( +

{stage.user_meaning}

+ )} + {transitions.length > 0 && ( +
+ 换升时刻 +
    + {transitions.map((item) => ( +
  • {item.user_meaning}
  • + ))} +
+
+ )} + {contrast && ( +
+ 两段本命上升 +

{contrast.user_meaning}

+
    + {contrast.intervals.map((interval) => ( +
  • + {interval.start}-{interval.end}:{interval.lagna} + {interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""} +
  • + ))} +
+
+ )} + {ledger.length > 0 && ( +
+ 经历与大运对照 +
    + {ledger.map((item) => ( +
  • {item.user_meaning}
  • + ))} +
+
+ )} + {agreement && ( +

{agreement.user_meaning}

+ )} + {nakshatra?.near_boundary && nakshatra.user_meaning && ( +
+ 交界节奏 +

{nakshatra.user_meaning}

+
+ )} +
+ ); +} + +function RectificationOosPanel({ + prompts, +}: Readonly<{ prompts: RectificationCandidateResult["oosBlindPrompts"] }>) { + if (prompts.length === 0) return null; + return ( +
+ 盘外对照 +

这些经历没有进入刚才的评分。若记得大概时间,可以补一条用来核对;不补也可以先看盘。

+
    + {prompts.map((item) => ( +
  • {item.user_meaning}
  • + ))} +
+
+ ); +} + type RectificationAgenticChatProps = Readonly<{ caseId: string; sessionId: string; @@ -115,6 +201,7 @@ type RectificationAgenticChatProps = Readonly<{ onPendingChange?: (pending: boolean) => void; onProfileIncomplete?: () => void; onSaved?: (time: string, status: "accepted" | "confirmed") => void; + onStartConsultation?: () => void; pendingConsultationQuestion?: string | null; onRestart?: () => void; }>; @@ -212,6 +299,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onPendingChange, onProfileIncomplete, onSaved, + onStartConsultation, pendingConsultationQuestion, onRestart, } = props; @@ -637,10 +725,23 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { )} + {candidateResult && } {savedTime && ( -

- {savedStatus === "confirmed" ? "已确认校正时间" : "当前排盘时间(代表性候选,还不能确认唯一分钟)"}:{savedTime}。后续排盘将使用该时间;你仍可继续补充事件或改选其他候选。 -

+
+

+ {savedStatus === "confirmed" ? "已确认校正时间" : "当前排盘时间(代表性候选,还不能确认唯一分钟)"}:{savedTime}。后续排盘将使用该时间;你仍可继续补充事件或改选其他候选。 +

+ {savedStatus === "accepted" && candidateResult && ( + + )} + {savedStatus === "accepted" && onStartConsultation && ( +
+ +
+ )} +
)} {error &&

{error}

} {readonly && ( diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index c7f6d7ac..da0a9cef 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -18,6 +18,11 @@ */ import type { SessionOutcomeKind } from "./confirmation-gate.ts"; +import type { + NakshatraBoundary, + OosBlindPrompt, + PrecisionStageId, +} from "./refinement-packet"; import type { InternalVargaObservation } from "./varga-observations"; export const METHOD_FOLLOWUP_IDS = [ @@ -40,14 +45,14 @@ export type MethodCoverage = Readonly<{ }>; export type MethodFollowup = Readonly<{ - method_id: "dasha_events" | "d9_relationship" | "d10_career" | "relatives" | "active_focus"; + method_id: "dasha_events" | "d9_relationship" | "d10_career" | "relatives" | "active_focus" | "nakshatra_boundary" | "oos_blind"; intent: string; - ask_theme: "dated_event" | "relationship_style" | "career_style" | "family_event" | "active_focus"; + ask_theme: "dated_event" | "relationship_style" | "career_style" | "family_event" | "active_focus" | "nakshatra_trait" | "oos_blind"; domain: string | null; kind_hint: string | null; user_prompt_hint: string; must_not_label: true; - source: "active_focus" | "method_coverage" | "varga_observation"; + source: "active_focus" | "method_coverage" | "varga_observation" | "precision_stage" | "nakshatra_boundary" | "oos_blind"; }>; export type MethodFollowupPlan = Readonly<{ @@ -120,7 +125,8 @@ export type NextUserActionId = | "score_now" | "record_stated_events" | "ask_method_followup" - | "explain_current_window"; + | "explain_current_window" + | "start_consultation"; export type NextUserAction = Readonly<{ id: NextUserActionId; @@ -156,10 +162,18 @@ export function buildNextUserAction(input: { sessionOutcome: SessionOutcomeKind; nextFollowup: MethodFollowup | null; workingTime: string | null; + accepted?: boolean; }): NextUserAction { const working = input.workingTime ? `当前排盘时间是 ${input.workingTime}` : "当前还没有可采用的校正时间"; + if (input.accepted) { + const consult = action( + "start_consultation", + "代表性时间已写入当前排盘。请用户用这个时间看盘;不要声称已确认唯一分钟。盘外对照问句由界面展示,本轮不要再当必须补证据。", + ); + return { id: consult.id, user_meaning: consult.user_meaning, on_user_stop: consult }; + } const adopt = action( "adopt_representative", "本轮已有代表性候选时间。说明还不能确认唯一分钟,请用户采用下方时间卡片;采用后才用该时间看盘。不要只说记下了以后再说。", @@ -207,6 +221,10 @@ export function buildMethodFollowupPlan(input: { declinedTopics?: readonly Readonly>[]; observations?: readonly InternalVargaObservation[]; sessionOutcome?: SessionOutcomeKind; + precisionStage?: PrecisionStageId | null; + nakshatraBoundary?: NakshatraBoundary | null; + oosBlindPrompts?: readonly OosBlindPrompt[]; + accepted?: boolean; }): MethodFollowupPlan { const declined = declinedDomains(input.declinedTopics ?? []); const dashaCovered = input.evidence.some(isConfirmedDated); @@ -246,7 +264,21 @@ export function buildMethodFollowupPlan(input: { }; } + if (input.accepted) { + const oos = oosFollowup(input.oosBlindPrompts); + return { + methods, + next_followup: null, + deferred_followup: oos, + session_outcome: sessionOutcome, + stop_domain_rotation: true, + do_not_poll: DO_NOT_POLL, + not_in_rotation: NOT_IN_ROTATION, + }; + } + let next: MethodFollowup | null = null; + const stage = input.precisionStage ?? null; if (!dashaCovered) { next = followup({ method_id: "dasha_events", @@ -257,6 +289,46 @@ export function buildMethodFollowupPlan(input: { user_prompt_hint: "可以先从最容易想起的一件带大概时间的经历开始。", source: "method_coverage", }); + } else if (stage === "lagna_frame") { + next = followup({ + method_id: "dasha_events", + intent: "distinguish_candidates", + ask_theme: "dated_event", + domain: null, + kind_hint: null, + user_prompt_hint: "窗口里本命上升还可能落在两段。请再补一件记得大概时间的经历,用来分开这两段;不要用性格或类型标签来选。", + source: "precision_stage", + }); + } else if (stage === "d9_refine" && !declined.has("relationship")) { + next = followup({ + method_id: "d9_relationship", + intent: "distinguish_candidates", + ask_theme: "relationship_style", + domain: "relationship", + kind_hint: "relationship_change", + user_prompt_hint: "关系盘仍会换升。可以再补一件记得大概时间的感情或关系变化;不要描述星座或类型标签。", + source: "precision_stage", + }); + } else if (stage === "d10_refine" && !declined.has("career")) { + next = followup({ + method_id: "d10_career", + intent: "distinguish_candidates", + ask_theme: "career_style", + domain: "career", + kind_hint: "career_change", + user_prompt_hint: "事业盘仍会换升。可以再补一件记得大概时间的工作变化;不要描述类型标签。", + source: "precision_stage", + }); + } else if (stage === "theme_refine" && !declined.has("family")) { + next = followup({ + method_id: "relatives", + intent: "distinguish_candidates", + ask_theme: "family_event", + domain: "family", + kind_hint: "family_event", + user_prompt_hint: "若还想继续收窄,可以再补一件记得大概时间的家人变化;也可以先采用代表性时间。", + source: "precision_stage", + }); } else if (!relationshipCovered && !declined.has("relationship")) { next = followup({ method_id: "d9_relationship", @@ -310,6 +382,17 @@ export function buildMethodFollowupPlan(input: { user_prompt_hint: "当前候选在事业主题上仍分不开,可以再补一件记得大概时间的工作变化;不要描述类型标签。", source: "varga_observation", }); + } else if (input.nakshatraBoundary?.near_boundary) { + next = followup({ + method_id: "nakshatra_boundary", + intent: "distinguish_candidates", + ask_theme: "nakshatra_trait", + domain: null, + kind_hint: null, + user_prompt_hint: input.nakshatraBoundary.user_meaning + ?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。", + source: "nakshatra_boundary", + }); } } @@ -324,3 +407,17 @@ export function buildMethodFollowupPlan(input: { not_in_rotation: NOT_IN_ROTATION, }; } + +function oosFollowup(prompts: readonly OosBlindPrompt[] | undefined): MethodFollowup | null { + const prompt = prompts?.[0]; + if (!prompt) return null; + return followup({ + method_id: "oos_blind", + intent: "out_of_sample_check", + ask_theme: "oos_blind", + domain: prompt.domain, + kind_hint: null, + user_prompt_hint: prompt.user_meaning, + source: "oos_blind", + }); +} diff --git a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts new file mode 100644 index 00000000..68518fea --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts @@ -0,0 +1,300 @@ +/** + * Public-safe P0/P1 refinement fields from the decision receipt. + * + * D9/D10 type labels, scores, weights and event IDs are dropped. D1 sign + * names may appear on lagna contrast, matching the natal house table. + */ + +const TIME = /^(?:[01]\d|2[0-3]):[0-5]\d$/; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const DENIED = /lon|latitude|longitude|degree|score|weight|fingerprint|热情冲动|配偶类型|事业特质/i; +const LAYER_LABEL: Record = { + d1: "本命上升", + d9: "D9", + d10: "D10", + d4: "D4", +}; + +export type WindowScanLayer = "d1" | "d9" | "d10" | "d4"; + +export type WindowScanTransition = Readonly<{ + layer: WindowScanLayer; + at: string; + user_meaning: string; +}>; + +export type EventDashaLedgerRow = Readonly<{ + summary: string; + match: "strong" | "medium" | "weak" | "none"; + match_label: string; + user_meaning: string; +}>; + +export type DashaAgreement = Readonly<{ + status: "agree" | "conflict" | "partial" | "unavailable"; + vimshottari_top: string | null; + narayana_top: string | null; + user_meaning: string; +}>; + +export type LagnaContrastInterval = Readonly<{ + start: string; + end: string; + lagna: string; + lords: Readonly<{ l1: string | null; l4: string | null; l7: string | null; l10: string | null }>; +}>; + +export type LagnaContrast = Readonly<{ + intervals: readonly LagnaContrastInterval[]; + user_meaning: string; + unique_minute_claim: false; +}>; + +export type NakshatraOption = Readonly<{ + key: "A" | "B"; + time_bias: "earlier" | "later"; + traits: readonly string[]; +}>; + +export type NakshatraBoundary = Readonly<{ + near_boundary: boolean; + user_meaning: string | null; + options: readonly NakshatraOption[]; +}>; + +export type PrecisionStageId = + | "collect_events" + | "lagna_frame" + | "d9_refine" + | "d10_refine" + | "theme_refine" + | "ready_to_adopt"; + +export type PrecisionStage = Readonly<{ + current: PrecisionStageId; + can_stop: boolean; + user_meaning: string; + unique_minute_claim: false; +}>; + +export type OosBlindPrompt = Readonly<{ + domain: string; + user_meaning: string; + used_for_scoring: false; +}>; + +function asRecord(value: unknown): Readonly> | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Readonly> + : null; +} + +function asTime(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.slice(0, 5); + return TIME.test(normalized) ? normalized : null; +} + +function asText(value: unknown, maximum = 160): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > maximum || DENIED.test(trimmed) || UUID.test(trimmed)) return null; + return trimmed; +} + +export function parseWindowScanTransitions(value: unknown): readonly WindowScanTransition[] { + if (!Array.isArray(value)) return []; + const rows: WindowScanTransition[] = []; + const seen = new Set(); + for (const item of value) { + const row = asRecord(item); + const layer = row?.layer; + const at = asTime(row?.at); + if ( + !row + || (layer !== "d1" && layer !== "d9" && layer !== "d10" && layer !== "d4") + || !at + ) continue; + const key = `${layer}:${at}`; + if (seen.has(key)) continue; + seen.add(key); + rows.push({ + layer, + at, + user_meaning: `${LAYER_LABEL[layer]} 在 ${at} 发生变化`, + }); + } + return rows; +} + +export function parseEventDashaLedger(value: unknown): readonly EventDashaLedgerRow[] { + if (!Array.isArray(value)) return []; + const rows: EventDashaLedgerRow[] = []; + for (const item of value) { + const row = asRecord(item); + const match = row?.match; + if ( + !row + || (match !== "strong" && match !== "medium" && match !== "weak" && match !== "none") + ) continue; + const summary = asText(row.summary, 80); + if (!summary) continue; + const matchLabel = match === "strong" ? "强相关" + : match === "medium" ? "有关联" + : match === "weak" ? "弱关联" + : "未见对应"; + rows.push({ + summary, + match, + match_label: matchLabel, + user_meaning: `${summary}:${matchLabel}`, + }); + } + return rows; +} + +export function parseDashaAgreement(value: unknown): DashaAgreement | null { + const row = asRecord(value); + const status = row?.status; + if ( + !row + || (status !== "agree" && status !== "conflict" && status !== "partial" && status !== "unavailable") + ) return null; + const meaning = asText(row.user_meaning, 220) + ?? (status === "conflict" + ? "主限和分盘大运偏向不同时间。冲突时不能按更高把握收口。" + : status === "agree" + ? "主限和分盘大运都更支持同一段代表性时间。这仍不是唯一分钟确认。" + : "还没有足够的大运对照。"); + return { + status, + vimshottari_top: asTime(row.vimshottari_top), + narayana_top: asTime(row.narayana_top), + user_meaning: meaning, + }; +} + +function parseLords(value: unknown): LagnaContrastInterval["lords"] | null { + const row = asRecord(value); + if (!row) return null; + const read = (key: "l1" | "l4" | "l7" | "l10"): string | null => { + const label = asText(row[key], 12); + return label; + }; + return { l1: read("l1"), l4: read("l4"), l7: read("l7"), l10: read("l10") }; +} + +export function parseLagnaContrast(value: unknown): LagnaContrast | null { + const row = asRecord(value); + if (!row || !Array.isArray(row.intervals) || row.intervals.length < 2) return null; + const intervals: LagnaContrastInterval[] = []; + for (const item of row.intervals.slice(0, 3)) { + const interval = asRecord(item); + const start = asTime(interval?.start); + const end = asTime(interval?.end); + const lagna = asText(interval?.lagna, 12); + const lords = parseLords(interval?.lords); + if (!interval || !start || !end || !lagna || !lords) return null; + intervals.push({ start, end, lagna, lords }); + } + if (intervals.length < 2) return null; + const meaning = asText(row.user_meaning, 220) + ?? `窗口里出现两段本命上升:${intervals[0].start}-${intervals[0].end} 为${intervals[0].lagna},${intervals[1].start}-${intervals[1].end} 为${intervals[1].lagna}。`; + return { + intervals, + user_meaning: meaning, + unique_minute_claim: false, + }; +} + +export function parseNakshatraBoundary(value: unknown): NakshatraBoundary | null { + const row = asRecord(value); + if (!row || typeof row.near_boundary !== "boolean") return null; + if (!row.near_boundary) { + return { near_boundary: false, user_meaning: null, options: [] }; + } + const options: NakshatraOption[] = []; + if (Array.isArray(row.options)) { + for (const item of row.options) { + const option = asRecord(item); + const key = option?.key; + const bias = option?.time_bias; + if ( + !option + || (key !== "A" && key !== "B") + || (bias !== "earlier" && bias !== "later") + || !Array.isArray(option.traits) + ) continue; + const traits = option.traits + .map((trait) => asText(trait, 40)) + .filter((trait): trait is string => Boolean(trait)) + .slice(0, 2); + if (traits.length === 0) continue; + options.push({ key, time_bias: bias, traits }); + } + } + if (options.length < 2) return { near_boundary: false, user_meaning: null, options: [] }; + return { + near_boundary: true, + user_meaning: asText(row.user_meaning, 400) ?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。", + options: options.slice(0, 2), + }; +} + +export function parsePrecisionStage(value: unknown): PrecisionStage | null { + const row = asRecord(value); + const current = row?.current; + if ( + !row + || ( + current !== "collect_events" + && current !== "lagna_frame" + && current !== "d9_refine" + && current !== "d10_refine" + && current !== "theme_refine" + && current !== "ready_to_adopt" + ) + ) return null; + const meaning = asText(row.user_meaning, 220) ?? "继续用带日期的经历缩小窗口。"; + return { + current, + can_stop: row.can_stop === true || current === "ready_to_adopt", + user_meaning: meaning, + unique_minute_claim: false, + }; +} + +export function parseOosBlindPrompts(value: unknown): readonly OosBlindPrompt[] { + if (!Array.isArray(value)) return []; + const rows: OosBlindPrompt[] = []; + const allowed = new Set(["relationship", "career", "family", "education"]); + for (const item of value) { + const row = asRecord(item); + const domain = typeof row?.domain === "string" ? row.domain : ""; + const meaning = asText(row?.user_meaning, 180); + if (!row || !allowed.has(domain) || !meaning) continue; + rows.push({ domain, user_meaning: meaning, used_for_scoring: false }); + if (rows.length === 3) break; + } + return rows; +} + +export function refinementFromDecisionReceipt( + receipt: Readonly> | null | undefined, +): { + event_dasha_ledger: readonly EventDashaLedgerRow[]; + dasha_agreement: DashaAgreement | null; + lagna_contrast: LagnaContrast | null; + nakshatra_boundary: NakshatraBoundary | null; + precision_stage: PrecisionStage | null; + oos_blind_prompts: readonly OosBlindPrompt[]; +} { + return { + event_dasha_ledger: parseEventDashaLedger(receipt?.event_dasha_ledger), + dasha_agreement: parseDashaAgreement(receipt?.dasha_agreement), + lagna_contrast: parseLagnaContrast(receipt?.lagna_contrast), + nakshatra_boundary: parseNakshatraBoundary(receipt?.nakshatra_boundary), + precision_stage: parsePrecisionStage(receipt?.precision_stage), + oos_blind_prompts: parseOosBlindPrompts(receipt?.oos_blind_prompts), + }; +} diff --git a/frontend/src/lib/rectification-agentic/v9/varga-observations.ts b/frontend/src/lib/rectification-agentic/v9/varga-observations.ts index 018abd1e..c8d66b8e 100644 --- a/frontend/src/lib/rectification-agentic/v9/varga-observations.ts +++ b/frontend/src/lib/rectification-agentic/v9/varga-observations.ts @@ -2,18 +2,28 @@ * D9/D10 observations for follow-up routing only. * * The engine may know Navamsa / Dasamsa lagna indices. This module projects - * booleans and ask themes. It never emits sign names, spouse types, career - * archetypes, or any other user-facing label. + * counts, differ flags and change minutes. It never emits sign names, spouse + * types, career archetypes, or any other user-facing D9/D10 label. */ +import { + parseWindowScanTransitions, + type WindowScanTransition, +} from "./refinement-packet"; + export type WindowScan = Readonly<{ scanned: boolean; confirmation_allowed: false; unique_minute_claim: false; + d1_lagna_count: number; d9_lagna_count: number; d10_lagna_count: number; + d4_lagna_count: number; + d1_candidates_differ: boolean; d9_candidates_differ: boolean; d10_candidates_differ: boolean; + d4_candidates_differ: boolean; + transitions: readonly WindowScanTransition[]; }>; export type InternalVargaObservation = Readonly<{ @@ -37,9 +47,13 @@ function asCount(value: unknown): number | null { return null; } +function optionalCount(value: unknown): number { + return asCount(value) ?? 0; +} + /** - * Keep only index counts and differ flags. Extra keys (sign names, lagna - * lists, type tables) are dropped and never forwarded to the Agent. + * Keep only index counts, differ flags and reconstructed change minutes. + * Extra keys (sign names, lagna lists, type tables) are dropped. */ export function parseWindowScan(value: unknown): WindowScan | null { const row = asRecord(value); @@ -47,16 +61,25 @@ export function parseWindowScan(value: unknown): WindowScan | null { const d9Count = asCount(row.d9_lagna_count); const d10Count = asCount(row.d10_lagna_count); if (d9Count === null || d10Count === null) return null; + const d1Count = optionalCount(row.d1_lagna_count); + const d4Count = optionalCount(row.d4_lagna_count); const d9Differ = row.d9_candidates_differ === true || d9Count > 1; const d10Differ = row.d10_candidates_differ === true || d10Count > 1; + const d1Differ = row.d1_candidates_differ === true || d1Count > 1; + const d4Differ = row.d4_candidates_differ === true || d4Count > 1; return { scanned: true, confirmation_allowed: false, unique_minute_claim: false, + d1_lagna_count: d1Count, d9_lagna_count: d9Count, d10_lagna_count: d10Count, + d4_lagna_count: d4Count, + d1_candidates_differ: d1Differ, d9_candidates_differ: d9Differ, d10_candidates_differ: d10Differ, + d4_candidates_differ: d4Differ, + transitions: parseWindowScanTransitions(row.transitions), }; } diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index 074e2b17..c1dab020 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -1,3 +1,18 @@ +import type { + DashaAgreement, + EventDashaLedgerRow, + LagnaContrast, + NakshatraBoundary, + OosBlindPrompt, + PrecisionStage, + WindowScanTransition, +} from "./rectification-agentic/v9/refinement-packet"; +import { + parseWindowScanTransitions, + refinementFromDecisionReceipt, +} from "./rectification-agentic/v9/refinement-packet"; +import { parseWindowScan } from "./rectification-agentic/v9/varga-observations"; + export type RectificationCandidate = Readonly<{ candidateId: string; rank: number; @@ -28,6 +43,13 @@ export type RectificationCandidateResult = Readonly<{ selectedTime: string | null; selectionKind: string | null; houseTable: RectificationHouseTable | null; + windowTransitions: readonly WindowScanTransition[]; + eventDashaLedger: readonly EventDashaLedgerRow[]; + dashaAgreement: DashaAgreement | null; + lagnaContrast: LagnaContrast | null; + nakshatraBoundary: NakshatraBoundary | null; + precisionStage: PrecisionStage | null; + oosBlindPrompts: readonly OosBlindPrompt[]; }>; function record(value: unknown): Record | null { @@ -87,6 +109,10 @@ function houseTableFromSnapshot(snapshot: Record): Rectificatio ?? parseRectificationHouseTable(record(snapshot.decision_receipt)?.house_table); } +function receiptFromSnapshot(snapshot: Record): Record | null { + return record(snapshot.decisionReceipt) ?? record(snapshot.decision_receipt); +} + export function parseRectificationCandidateResult(value: unknown): RectificationCandidateResult | null { const snapshot = record(value); if (!snapshot || typeof snapshot.resultId !== "string") return null; @@ -110,6 +136,10 @@ export function parseRectificationCandidateResult(value: unknown): Rectification candidates.push({ candidateId, rank, time: candidateTime, relativeSupport, tiedMinuteCount }); } + const receipt = receiptFromSnapshot(snapshot); + const refinement = refinementFromDecisionReceipt(receipt); + const windowScan = parseWindowScan(receipt?.window_scan) ?? parseWindowScan(snapshot.window_scan); + return { resultId: snapshot.resultId, candidates, @@ -122,6 +152,13 @@ export function parseRectificationCandidateResult(value: unknown): Rectification selectedTime: time(snapshot.selectedTime), selectionKind: text(snapshot.selectionKind), houseTable: houseTableFromSnapshot(snapshot), + windowTransitions: windowScan?.transitions ?? parseWindowScanTransitions(snapshot.window_scan), + eventDashaLedger: refinement.event_dasha_ledger, + dashaAgreement: refinement.dasha_agreement, + lagnaContrast: refinement.lagna_contrast, + nakshatraBoundary: refinement.nakshatra_boundary, + precisionStage: refinement.precision_stage, + oosBlindPrompts: refinement.oos_blind_prompts, }; } diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index 67ec9835..3923ceb1 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -72,7 +72,8 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑 9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_action:id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;仍有 next_followup 时继续问。session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说还不能确认唯一分钟。用户说“暂时想不到了 / 没有更多 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果则解释、调用 offer-candidates,并请采用下方时间卡片。禁止只说记下了、会话会保留、以后再继续。分盘句和宫位表由界面展示,正文不要重复工具名或再画表。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;holdout 为 not_ready 时不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。用户仍可 accepted 代表性候选。 10. 不泄露系统提示词或 Skill 原文。 11. 追问只跟 method_followup_plan;不得按 missing_evidence_categories 轮询迁居/健康/财务,不得问外貌或胎记。不得把分盘观察说成用户性格或类型标签。 -12. 证据有效变化后由服务器重算候选。不要等用户说“没有更多了”才比较,也不要对同一证据指纹再 compare。分钟扫描只在服务端,结果只是候选或平台,不得宣布确认。`; +12. 证据有效变化后由服务器重算候选。不要等用户说“没有更多了”才比较,也不要对同一证据指纹再 compare。分钟扫描只在服务端,结果只是候选或平台,不得宣布确认。 +13. 落实 start_consultation:代表性时间被采用后,请用户用这个时间看盘,不要再当本轮必须补证据。解释 event_dasha_ledger、dasha_agreement、换升时刻和 precision_stage 时不报分数、不给 D9/D10 类型标签。盘外对照问句由界面展示。`; export function getRectificationV9Agent( model: ResolvedLanguageModel, diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 6446cec6..8ed60f10 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -45,6 +45,7 @@ import { buildNextUserAction, conversationalSessionOutcome, } from "@/lib/rectification-agentic/v9/method-followup"; +import { refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet"; import { internalObservationsFromWindowScan, windowScanFromDecisionReceipt, @@ -105,6 +106,8 @@ function safeCaseProjection( const latest = dossier.latestResult; const windowScan = windowScanFromDecisionReceipt(latest?.decisionReceipt ?? null); const observations = internalObservationsFromWindowScan(windowScan); + const refinement = refinementFromDecisionReceipt(latest?.decisionReceipt ?? null); + const accepted = Boolean(caseRow.acceptedTime); const latestProjection = latest ? latestResultToolProjection(latest) : null; const collectingPlan = buildMethodFollowupPlan({ evidence: dossier.evidence, @@ -112,6 +115,10 @@ function safeCaseProjection( declinedTopics: dossier.conversationSummary.declinedSkippedTopics, observations, sessionOutcome: "collect_evidence", + precisionStage: refinement.precision_stage?.current, + nakshatraBoundary: refinement.nakshatra_boundary, + oosBlindPrompts: refinement.oos_blind_prompts, + accepted, }); const selectionAllowed = latest?.selectionAllowed === true; const sessionOutcome = conversationalSessionOutcome({ @@ -127,6 +134,10 @@ function safeCaseProjection( declinedTopics: dossier.conversationSummary.declinedSkippedTopics, observations, sessionOutcome, + precisionStage: refinement.precision_stage?.current, + nakshatraBoundary: refinement.nakshatra_boundary, + oosBlindPrompts: refinement.oos_blind_prompts, + accepted, }); const birthContext = safeBirthContext(compute); const nextUserAction = buildNextUserAction({ @@ -139,6 +150,7 @@ function safeCaseProjection( workingTime: caseRow.acceptedTime ?? (typeof birthContext.active_birth_time === "string" ? birthContext.active_birth_time : null) ?? (typeof birthContext.reported_birth_time === "string" ? birthContext.reported_birth_time : null), + accepted, }); const latestResult = latestProjection ? { @@ -258,6 +270,7 @@ export function latestResultToolProjection( confirmationAllowed: confirmationGate.confirmation_allowed, }); const houseTable = parseRectificationHouseTable(latest.decisionReceipt?.house_table); + const refinement = refinementFromDecisionReceipt(latest.decisionReceipt ?? null); return { result_id: latest.resultId, candidates: latest.candidates, @@ -271,6 +284,13 @@ export function latestResultToolProjection( window_scan: windowScan, confirmation_gate: confirmationGate, session_outcome: sessionOutcome, + event_dasha_ledger: refinement.event_dasha_ledger, + dasha_agreement: refinement.dasha_agreement, + lagna_contrast: refinement.lagna_contrast, + nakshatra_boundary: refinement.nakshatra_boundary, + precision_stage: refinement.precision_stage, + oos_blind_prompts: refinement.oos_blind_prompts, + unique_minute_claim: false, ...(houseTable ? { house_table: houseTable } : {}), }; } diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index b0b2173d..57468393 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -434,6 +434,18 @@ test("the Agent prompt cannot offer candidates while asking for more evidence", assert.doesNotMatch(tools, /offer_selection/); }); +test("adopted time offers a consultation handoff without unique-minute copy", () => { + assert.match(chat, /用这个时间看盘/); + assert.match(chat, /onStartConsultation/); + assert.match(chat, /换升时刻/); + assert.match(chat, /经历与大运对照/); + assert.match(chat, /盘外对照/); + assert.match(page, /startConsultationAfterRectification/); + assert.match(page, /createSession\(modelCatalog\.defaultModelId\)/); + assert.match(agent, /start_consultation/); + assert.match(agent, /还不能确认唯一分钟/); +}); + test("stream failures remove empty assistant placeholders", () => { assert.match(chat, /streamFailed = true/); assert.match(chat, /const succeeded = completed && !streamFailed && Boolean\(parsed\.text\)/); diff --git a/frontend/tests/rectification-candidate-result.test.ts b/frontend/tests/rectification-candidate-result.test.ts index efc34293..458677c6 100644 --- a/frontend/tests/rectification-candidate-result.test.ts +++ b/frontend/tests/rectification-candidate-result.test.ts @@ -109,3 +109,47 @@ test("drops a house table that still carries longitudes", () => { }); assert.equal(result?.houseTable, null); }); + +test("reads dasha ledger and change minutes without scores or D9 labels", () => { + const result = parseRectificationCandidateResult({ + ...camelCaseSnapshot, + decisionReceipt: { + window_scan: { + scanned: true, + d9_lagna_count: 2, + d10_lagna_count: 1, + transitions: [{ + layer: "d9", + at: "05:14", + user_meaning: "白羊座在 05:14 换成天蝎", + }], + }, + event_dasha_ledger: [{ + summary: "入职", + match: "strong", + match_label: "强相关", + user_meaning: "入职:强相关 12.5 points", + }], + dasha_agreement: { + status: "conflict", + vimshottari_top: "05:07", + narayana_top: "05:08", + user_meaning: "主限更偏向 05:07,分盘大运更偏向 05:08。冲突时不能按更高把握收口。", + }, + precision_stage: { + current: "d9_refine", + can_stop: true, + user_meaning: "关系盘仍会换升。", + unique_minute_claim: false, + }, + }, + }); + assert.equal(result?.windowTransitions[0]?.user_meaning, "D9 在 05:14 发生变化"); + assert.equal(result?.eventDashaLedger[0]?.match_label, "强相关"); + assert.equal(result?.eventDashaLedger[0]?.user_meaning, "入职:强相关"); + assert.equal(result?.dashaAgreement?.status, "conflict"); + assert.equal(result?.precisionStage?.current, "d9_refine"); + assert.doesNotMatch(JSON.stringify(result?.windowTransitions), /白羊|天蝎|points/); + assert.doesNotMatch(result?.eventDashaLedger[0]?.user_meaning ?? "", /points/); +}); + diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 6342f234..acfb22cf 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -235,11 +235,21 @@ test("D9 differ becomes an internal ask theme without sign labels", () => { d10_candidates_differ: false, d9_sign_names: ["白羊座", "天蝎"], type_table: "热情冲动", + transitions: [{ + layer: "d9", + at: "05:14", + user_meaning: "白羊座在 05:14 换成天蝎", + }], }); assert.ok(scan); assert.equal(scan.confirmation_allowed, false); assert.equal(scan.unique_minute_claim, false); assert.equal(scan.d9_candidates_differ, true); + assert.deepEqual(scan.transitions, [{ + layer: "d9", + at: "05:14", + user_meaning: "D9 在 05:14 发生变化", + }]); const observations = internalObservationsFromWindowScan(scan); assert.deepEqual(observations, [ { layer: "d9", candidates_differ: true, ask_theme: "relationship_style" }, @@ -517,3 +527,50 @@ test("public tool surface stays at 13 and new cases bind 10.0.3", () => { assert.match(skill, /不得给用户贴 D9\/D10 星座或类型标签/); assert.doesNotMatch(skill, /±5 分钟确定性/); }); + +test("precision stage lagna_frame asks another dated event instead of rotating domains", () => { + const plan = buildMethodFollowupPlan({ + evidence: [ + { status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null }, + { status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2018-01-01", occurredTo: null }, + ], + precisionStage: "lagna_frame", + }); + assert.equal(plan.next_followup?.source, "precision_stage"); + assert.equal(plan.next_followup?.ask_theme, "dated_event"); + assert.doesNotMatch(JSON.stringify(plan), FORBIDDEN_LABELS); +}); + +test("accepted representative time hands off to consultation", () => { + const plan = buildMethodFollowupPlan({ + evidence: [{ + status: "confirmed", + domain: "education", + datePrecision: "month", + occurredFrom: "2016-06-01", + occurredTo: null, + }], + accepted: true, + oosBlindPrompts: [{ + domain: "family", + user_meaning: "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?", + used_for_scoring: false, + }], + }); + assert.equal(plan.next_followup, null); + assert.equal(plan.deferred_followup?.method_id, "oos_blind"); + const action = buildNextUserAction({ + scorableCount: 3, + evidenceCount: 3, + hasLatestResult: true, + selectionAllowed: true, + sessionOutcome: "adopt_representative", + nextFollowup: null, + workingTime: "05:07", + accepted: true, + }); + assert.equal(action.id, "start_consultation"); + assert.match(action.user_meaning, /看盘/); + assert.doesNotMatch(JSON.stringify({ plan, action }), FORBIDDEN_LABELS); +}); + diff --git a/scripts/rectification/decision_policy.py b/scripts/rectification/decision_policy.py index 68a1c4df..98b51d58 100644 --- a/scripts/rectification/decision_policy.py +++ b/scripts/rectification/decision_policy.py @@ -12,6 +12,7 @@ from scripts.rectification.contracts import ( is_scoreable_event, ) from scripts.rectification.house_table import compact_house_table_from_contexts +from scripts.rectification.refinement_packet import build_refinement_packet from scripts.rectification.scoring_service import precision_weight POLICY_VERSION = "rectification-candidate-policy-v2" @@ -204,6 +205,19 @@ def build_decision_receipt( reasons = [*acceptance_reasons, *confirmation_reasons] representative = candidate_decisions[0] if candidate_decisions else None + packet = build_refinement_packet( + request, + built, + representative_time=representative["time"] if representative else None, + candidate_times=[item["time"] for item in candidate_decisions], + ) + if packet["dasha_agreement"]["status"] == "conflict": + if overall_confidence == "high": + overall_confidence = "medium" + elif overall_confidence == "medium": + overall_confidence = "low" + reasons.append("vimshottari_narayana_conflict") + confirmation_reasons.append("vimshottari_narayana_conflict") exact_confirmation = { "passed": False, "fail_closed": True, @@ -254,6 +268,16 @@ def build_decision_receipt( ) if house_table: receipt["house_table"] = house_table + receipt.update({ + "window_scan": packet["window_scan"], + "event_dasha_ledger": packet["event_dasha_ledger"], + "dasha_agreement": packet["dasha_agreement"], + "lagna_contrast": packet["lagna_contrast"], + "nakshatra_boundary": packet["nakshatra_boundary"], + "precision_stage": packet["precision_stage"], + "oos_blind_prompts": packet["oos_blind_prompts"], + "unique_minute_claim": False, + }) return receipt diff --git a/scripts/rectification/diagnostics_service.py b/scripts/rectification/diagnostics_service.py index 5e0695fe..50c3672f 100644 --- a/scripts/rectification/diagnostics_service.py +++ b/scripts/rectification/diagnostics_service.py @@ -6,6 +6,7 @@ from typing import Any, Sequence from scripts.active_rectification_events import CandidateScoreRow from scripts.rectification.contracts import RectificationRequest +from scripts.rectification.refinement_packet import window_scan def _winner(rows: Sequence[CandidateScoreRow]) -> str | None: @@ -68,34 +69,6 @@ def _candidate_feature_contrast(built: dict[str, Any], primary_time: str, second return sorted(set(layers))[:8] -def window_scan(built: dict[str, Any]) -> dict[str, Any]: - """Minute-window D9/D10 lagna diversity. Indices only; never sign names.""" - d9: set[int] = set() - d10: set[int] = set() - for context in built.get("static_contexts") or []: - feature = context.get("feature") if isinstance(context, dict) else None - if not isinstance(feature, dict): - continue - vargas = feature.get("varga_ascendants") or {} - if not isinstance(vargas, dict): - continue - d9_value = vargas.get("D9") - d10_value = vargas.get("D10") - if isinstance(d9_value, int): - d9.add(d9_value) - if isinstance(d10_value, int): - d10.add(d10_value) - return { - "scanned": True, - "confirmation_allowed": False, - "unique_minute_claim": False, - "d9_lagna_count": len(d9), - "d10_lagna_count": len(d10), - "d9_candidates_differ": len(d9) > 1, - "d10_candidates_differ": len(d10) > 1, - } - - def _candidate_contrast(built: dict[str, Any], primary_time: str, secondary_time: str) -> tuple[list[str], list[str]]: event_deltas: list[tuple[float, str]] = [] for event_id, candidates in built["matrix"].items(): diff --git a/scripts/rectification/house_table.py b/scripts/rectification/house_table.py index ada8d21d..37c16d72 100644 --- a/scripts/rectification/house_table.py +++ b/scripts/rectification/house_table.py @@ -34,6 +34,21 @@ PLANET_ZH = { "Ketu": "计都", } +SIGN_LORDS = { + "Aries": "Mars", + "Taurus": "Venus", + "Gemini": "Mercury", + "Cancer": "Moon", + "Leo": "Sun", + "Virgo": "Mercury", + "Libra": "Venus", + "Scorpio": "Mars", + "Sagittarius": "Jupiter", + "Capricorn": "Saturn", + "Aquarius": "Saturn", + "Pisces": "Jupiter", +} + def _sign_cn(sign: str | None) -> str | None: if not isinstance(sign, str) or sign not in SIGNS_CN: diff --git a/scripts/rectification/refinement_packet.py b/scripts/rectification/refinement_packet.py new file mode 100644 index 00000000..d7a287db --- /dev/null +++ b/scripts/rectification/refinement_packet.py @@ -0,0 +1,412 @@ +"""Server-owned P0/P1 refinement packet for birth-time rectification. + +Produces candidate-narrowing structure only. Never grants a unique minute, +never emits D9/D10 type labels, and never copies raw scores into public copy. +""" + +from __future__ import annotations + +from typing import Any, Sequence + +from scripts.rectification.house_table import PLANET_ZH, SIGN_LORDS, SIGNS, SIGNS_CN + +NAKSHATRA_SPAN = 40.0 / 3.0 +NAKSHATRA_BOUNDARY_DEGREES = 2.0 +MATCH_LABELS = { + "strong": "强相关", + "medium": "有关联", + "weak": "弱关联", + "none": "未见对应", +} +# Everyday A/B traits only. No Sanskrit names and no D9/D10 personality tables. +NAKSHATRA_TRAITS: tuple[tuple[str, str], ...] = ( + ("起步快、敢先动手", "更愿意把第一步走完再看"), + ("事情来了会立刻表态", "先把感受压一压再开口"), + ("喜欢把节奏拉开、自己掌握步调", "更在意别人是否跟得上"), + ("照顾身边的人会放在前面", "需要先把自己安顿好"), + ("愿意站到台前把话说清楚", "更习惯在旁边把事情理顺"), + ("对细节和次序很敏感", "更看重大方向有没有走偏"), + ("希望两边都能说得过去", "必要时会直接选边"), + ("碰到转折会往深处想", "更想尽快回到能做事的状态"), + ("愿意把视野拉远一点再决定", "更盯着眼前能落地的一步"), + ("愿意为长期结果多熬一阵", "更怕把时间耗在看不见的地方"), + ("想法一多就想换条路试试", "更想把一条路走稳"), + ("情绪来了会先自己消化", "更需要说出来才过得去"), + ("新开始会让人兴奋", "新开始会让人先观察一阵"), + ("承诺一旦出口就很难收回", "承诺前会反复确认自己是不是真想"), + ("变化来时先问值不值得", "变化来时先问自己扛不扛得住"), + ("家里的事会牵动判断", "更想把家里的事和工作分开"), + ("被看见会更有劲", "被看见反而会先退半步"), + ("计划乱了会先整理清单", "计划乱了会先找一个人商量"), + ("两边关系都想维持住", "维持不住时会干脆拉开距离"), + ("压力大时会往内部找原因", "压力大时会先改外部条件"), + ("愿意把决定放到更大的时间尺度", "更相信眼前这一段就够判断"), + ("愿意为结构稳定让步", "稳定如果太闷就会想拆掉重来"), + ("对规则和例外都很敏感", "更想先有一个能用的规则"), + ("说不清的感受会先放着", "说不清就会反复确认"), + ("一有机会就想动手试", "会先把退路看清楚再动"), + ("对人的反应比对事情本身更敏感", "对事情进度比对气氛更敏感"), + ("收尾时会想把未完成的交代清", "收尾时会想尽快开始下一件"), +) + + +def _clock(value: str) -> int: + return int(value[:2]) * 60 + int(value[3:5]) + + +def _feature_time(feature: dict[str, Any]) -> str | None: + raw = feature.get("time") + if isinstance(raw, str) and len(raw) >= 5: + return raw[:5] + return None + + +def _features(built: dict[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for context in built.get("static_contexts") or []: + if not isinstance(context, dict): + continue + feature = context.get("feature") + if not isinstance(feature, dict): + continue + time = _feature_time(feature) + if time: + rows.append(feature) + rows.sort(key=lambda item: _clock(str(_feature_time(item)))) + return rows + + +def match_level(rule_ids: Sequence[str]) -> str: + ids = [str(item) for item in rule_ids] + if not ids or ids == ["no_domain_activation"]: + return "none" + if any( + item.startswith("vim_md_domain") or item.startswith("narayana_md_domain") + for item in ids + ): + return "strong" + if any( + item.startswith("vim_ad_") or item.startswith("narayana_ad_") + for item in ids + ): + return "medium" + if any(item.startswith("vim_") or item.startswith("narayana_") for item in ids): + return "weak" + return "none" + + +def _tracks(rule_ids: Sequence[str]) -> list[str]: + tracks: list[str] = [] + if any(str(item).startswith("vim_") for item in rule_ids): + tracks.append("vimshottari") + if any(str(item).startswith("narayana_") for item in rule_ids): + tracks.append("narayana") + return tracks + + +def _split_track_points(rule_ids: Sequence[str], points: float) -> tuple[float, float]: + vim = sum(str(item).startswith("vim_") for item in rule_ids) + narayana = sum(str(item).startswith("narayana_") for item in rule_ids) + total = vim + narayana + if total == 0: + return 0.0, 0.0 + return points * vim / total, points * narayana / total + + +_LAYER_LABEL = {"d1": "本命上升", "d9": "D9", "d10": "D10", "d4": "D4"} + + +def window_scan(built: dict[str, Any]) -> dict[str, Any]: + """D1/D9/D10/D4 diversity plus change minutes. Indices only; never sign names.""" + counts: dict[str, set[int]] = {layer: set() for layer in _LAYER_LABEL} + transitions: list[dict[str, Any]] = [] + previous: dict[str, int | None] | None = None + for feature in _features(built): + vargas = feature.get("varga_ascendants") if isinstance(feature.get("varga_ascendants"), dict) else {} + current = { + "d1": feature.get("ascendant_sign_index") if isinstance(feature.get("ascendant_sign_index"), int) else None, + "d9": vargas.get("D9") if isinstance(vargas.get("D9"), int) else None, + "d10": vargas.get("D10") if isinstance(vargas.get("D10"), int) else None, + "d4": vargas.get("D4") if isinstance(vargas.get("D4"), int) else None, + } + for layer, bucket in counts.items(): + value = current[layer] + if isinstance(value, int): + bucket.add(value) + time = _feature_time(feature) + if previous and time: + for layer, label in _LAYER_LABEL.items(): + before = previous[layer] + after = current[layer] + if isinstance(before, int) and isinstance(after, int) and before != after: + transitions.append({ + "layer": layer, + "at": time, + "user_meaning": f"{label} 在 {time} 发生变化", + }) + previous = current + return { + "scanned": True, + "confirmation_allowed": False, + "unique_minute_claim": False, + "d1_lagna_count": len(counts["d1"]), + "d9_lagna_count": len(counts["d9"]), + "d10_lagna_count": len(counts["d10"]), + "d4_lagna_count": len(counts["d4"]), + "d1_candidates_differ": len(counts["d1"]) > 1, + "d9_candidates_differ": len(counts["d9"]) > 1, + "d10_candidates_differ": len(counts["d10"]) > 1, + "d4_candidates_differ": len(counts["d4"]) > 1, + "transitions": transitions, + } + + +def event_dasha_ledger( + request: dict[str, Any], + built: dict[str, Any], + representative_time: str | None, +) -> list[dict[str, Any]]: + if not representative_time: + return [] + matrix = built.get("matrix") or {} + rows: list[dict[str, Any]] = [] + for event in request.get("events") or []: + if not isinstance(event, dict): + continue + contribution = (matrix.get(event.get("id")) or {}).get(representative_time) + if not isinstance(contribution, dict): + continue + rule_ids = contribution.get("rule_ids") or [] + level = match_level(rule_ids) + summary = str(event.get("summary") or "").strip() or "这条经历" + tracks = _tracks(rule_ids) + track_text = "、".join( + "主限" if track == "vimshottari" else "分盘大运" for track in tracks + ) or "现有大运层" + rows.append({ + "summary": summary[:80], + "match": level, + "match_label": MATCH_LABELS[level], + "tracks": tracks, + "user_meaning": f"{summary[:40]}:{MATCH_LABELS[level]}({track_text})", + }) + return rows + + +def dasha_agreement(built: dict[str, Any], candidate_times: Sequence[str]) -> dict[str, Any]: + times = [str(item)[:5] for item in candidate_times if isinstance(item, str) and len(str(item)) >= 5] + if not times: + return { + "status": "unavailable", + "vimshottari_top": None, + "narayana_top": None, + "user_meaning": "还没有足够的大运对照。", + } + vim_scores = {time: 0.0 for time in times} + narayana_scores = {time: 0.0 for time in times} + for contributions in (built.get("matrix") or {}).values(): + if not isinstance(contributions, dict): + continue + for time in times: + cell = contributions.get(time) + if not isinstance(cell, dict): + continue + vim_points, narayana_points = _split_track_points( + cell.get("rule_ids") or [], + float(cell.get("points") or 0), + ) + vim_scores[time] += vim_points + narayana_scores[time] += narayana_points + if all(value == 0 for value in vim_scores.values()) or all(value == 0 for value in narayana_scores.values()): + return { + "status": "partial", + "vimshottari_top": max(times, key=lambda time: vim_scores[time]) if any(vim_scores.values()) else None, + "narayana_top": max(times, key=lambda time: narayana_scores[time]) if any(narayana_scores.values()) else None, + "user_meaning": "主限和分盘大运还不能做成完整对照,只作观察。", + } + vim_top = max(times, key=lambda time: (vim_scores[time], -_clock(time))) + narayana_top = max(times, key=lambda time: (narayana_scores[time], -_clock(time))) + if vim_top == narayana_top: + return { + "status": "agree", + "vimshottari_top": vim_top, + "narayana_top": narayana_top, + "user_meaning": "主限和分盘大运都更支持同一段代表性时间。这仍不是唯一分钟确认。", + } + return { + "status": "conflict", + "vimshottari_top": vim_top, + "narayana_top": narayana_top, + "user_meaning": f"主限更偏向 {vim_top},分盘大运更偏向 {narayana_top}。冲突时不能按更高把握收口。", + } + + +def _house_lord_zh(asc_idx: int, house: int) -> str | None: + sign = SIGNS[(asc_idx + house - 1) % 12] + lord = SIGN_LORDS.get(sign) + return PLANET_ZH.get(lord) if lord else None + + +def lagna_contrast(built: dict[str, Any]) -> dict[str, Any] | None: + features = _features(built) + if not features: + return None + intervals: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + for feature in features: + time = _feature_time(feature) + index = feature.get("ascendant_sign_index") + if not time or not isinstance(index, int) or index < 0 or index > 11: + continue + if current and current["d1_lagna_index"] == index: + current["end"] = time + continue + if current: + intervals.append(current) + sign = SIGNS_CN.get(SIGNS[index]) + current = { + "start": time, + "end": time, + "d1_lagna_index": index, + "lagna": sign, + "lords": { + "l1": _house_lord_zh(index, 1), + "l4": _house_lord_zh(index, 4), + "l7": _house_lord_zh(index, 7), + "l10": _house_lord_zh(index, 10), + }, + } + if current: + intervals.append(current) + if len(intervals) < 2: + return None + left, right = intervals[0], intervals[1] + return { + "intervals": intervals[:3], + "user_meaning": ( + f"窗口里出现两段本命上升:{left['start']}-{left['end']} 为{left['lagna']}," + f"{right['start']}-{right['end']} 为{right['lagna']}。" + "只比较宫主结构,不给性格或类型标签。" + ), + "unique_minute_claim": False, + } + + +def nakshatra_boundary(built: dict[str, Any], representative_time: str | None) -> dict[str, Any] | None: + features = { + _feature_time(feature): feature + for feature in _features(built) + if _feature_time(feature) + } + feature = features.get(representative_time or "") or (list(features.values())[0] if features else None) + if not isinstance(feature, dict): + return None + longitude = feature.get("ascendant_degree") + if not isinstance(longitude, (int, float)): + return None + wrapped = float(longitude) % 360.0 + index = int(wrapped / NAKSHATRA_SPAN) % 27 + position = wrapped % NAKSHATRA_SPAN + distance = min(position, NAKSHATRA_SPAN - position) + if distance > NAKSHATRA_BOUNDARY_DEGREES: + return { + "near_boundary": False, + "distance_degrees": round(distance, 4), + "user_meaning": None, + "options": [], + } + earlier_index = index if position <= NAKSHATRA_SPAN / 2 else (index - 1) % 27 + later_index = (earlier_index + 1) % 27 + earlier = NAKSHATRA_TRAITS[earlier_index] + later = NAKSHATRA_TRAITS[later_index] + return { + "near_boundary": True, + "distance_degrees": round(distance, 4), + "options": [ + { + "key": "A", + "time_bias": "earlier", + "traits": list(earlier), + }, + { + "key": "B", + "time_bias": "later", + "traits": list(later), + }, + ], + "user_meaning": ( + "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?" + f"A:{earlier[0]};{earlier[1]}。" + f"B:{later[0]};{later[1]}。" + "这只用来偏置时间窗,不能确认唯一分钟。" + ), + } + + +def precision_stage(scan: dict[str, Any], event_count: int) -> dict[str, Any]: + if event_count <= 0: + current = "collect_events" + meaning = "还需要带大概时间的经历,才能开始缩小窗口。" + elif scan.get("d1_candidates_differ"): + current = "lagna_frame" + meaning = "本命上升还可能落在两段里。先补能分开这两段的带日期经历。" + elif scan.get("d9_candidates_differ"): + current = "d9_refine" + meaning = "本命上升已较稳,关系盘仍会换升。可再补一件记得时间的感情或关系变化。" + elif scan.get("d10_candidates_differ"): + current = "d10_refine" + meaning = "关系盘已较稳,事业盘仍会换升。可再补一件记得时间的工作变化。" + elif scan.get("d4_candidates_differ"): + current = "theme_refine" + meaning = "核心分盘已较稳。若还想收窄,可再补一件记得时间的家人或住处变化;也可以先采用代表性时间。" + else: + current = "ready_to_adopt" + meaning = "核心分盘已不再换升。可以采用代表性时间看盘,也可以再补主题经历。" + return { + "current": current, + "can_stop": current in {"d9_refine", "d10_refine", "theme_refine", "ready_to_adopt"}, + "user_meaning": meaning, + "unique_minute_claim": False, + } + + +def oos_blind_prompts(request: dict[str, Any]) -> list[dict[str, Any]]: + covered = { + str(event.get("domain")) + for event in request.get("events") or [] + if isinstance(event, dict) and event.get("domain") + } + catalog = ( + ("relationship", "校时还没用过感情这条线。有没有一件没提过、但记得大概时间的关系变化?"), + ("career", "校时还没用过事业这条线。有没有一件没提过、但记得大概时间的工作变化?"), + ("family", "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?"), + ("education", "校时还没用过学习这条线。有没有一件没提过、但记得大概时间的学业变化?"), + ) + prompts = [ + {"domain": domain, "user_meaning": meaning, "used_for_scoring": False} + for domain, meaning in catalog + if domain not in covered + ] + return prompts[:3] + + +def build_refinement_packet( + request: dict[str, Any], + built: dict[str, Any], + *, + representative_time: str | None, + candidate_times: Sequence[str], +) -> dict[str, Any]: + scan = window_scan(built) + agreement = dasha_agreement(built, candidate_times) + return { + "window_scan": scan, + "event_dasha_ledger": event_dasha_ledger(request, built, representative_time), + "dasha_agreement": agreement, + "lagna_contrast": lagna_contrast(built), + "nakshatra_boundary": nakshatra_boundary(built, representative_time), + "precision_stage": precision_stage(scan, len(request.get("events") or [])), + "oos_blind_prompts": oos_blind_prompts(request), + "unique_minute_claim": False, + "confirmation_allowed": False, + } diff --git a/tests/test_rectification_diagnostics_clusters.py b/tests/test_rectification_diagnostics_clusters.py index 01a75853..dbbd44fd 100644 --- a/tests/test_rectification_diagnostics_clusters.py +++ b/tests/test_rectification_diagnostics_clusters.py @@ -116,6 +116,11 @@ class RectificationDiagnosticsClustersTest(unittest.TestCase): self.assertEqual(scan["d10_lagna_count"], 1) self.assertEqual(scan["d9_candidates_differ"], True) self.assertEqual(scan["d10_candidates_differ"], False) + self.assertEqual(scan["transitions"], [{ + "layer": "d9", + "at": "05:14", + "user_meaning": "D9 在 05:14 发生变化", + }]) encoded = str(scan) self.assertNotIn("白羊", encoded) self.assertNotIn("天蝎", encoded) diff --git a/tests/test_rectification_refinement_packet.py b/tests/test_rectification_refinement_packet.py new file mode 100644 index 00000000..e835b02f --- /dev/null +++ b/tests/test_rectification_refinement_packet.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import unittest + +from scripts.rectification.decision_policy import build_candidate_decisions, build_decision_receipt +from scripts.rectification.refinement_packet import ( + build_refinement_packet, + dasha_agreement, + match_level, + precision_stage, + window_scan, +) + + +def feature(time: str, *, d1: int | None = None, d9: int | None = None, d10: int | None = None, d4: int | None = None, degree: float | None = None) -> dict: + vargas = {} + if d9 is not None: + vargas["D9"] = d9 + if d10 is not None: + vargas["D10"] = d10 + if d4 is not None: + vargas["D4"] = d4 + row: dict = {"time": time, "varga_ascendants": vargas} + if d1 is not None: + row["ascendant_sign_index"] = d1 + if degree is not None: + row["ascendant_degree"] = degree + return {"feature": row} + + +def request_events() -> dict: + return { + "events": [ + {"id": "00000000-0000-4000-8000-000000000001", "domain": "career", "summary": "入职", "event_kind": "career_entry", "precision": "day", "date_start": "2016-09-15", "date_end": "2016-09-15"}, + {"id": "00000000-0000-4000-8000-000000000002", "domain": "relationship", "summary": "开始一段关系", "event_kind": "relationship_start", "precision": "day", "date_start": "2018-03-01", "date_end": "2018-03-01"}, + {"id": "00000000-0000-4000-8000-000000000003", "domain": "education", "summary": "毕业", "event_kind": "education_completion", "precision": "day", "date_start": "2015-06-01", "date_end": "2015-06-01"}, + ] + } + + +class RefinementPacketTest(unittest.TestCase): + def test_window_scan_emits_change_minutes_without_sign_names(self): + built = { + "static_contexts": [ + feature("05:13", d1=1, d9=1, d10=4), + feature("05:14", d1=1, d9=7, d10=4), + ] + } + scan = window_scan(built) + self.assertEqual(scan["d9_lagna_count"], 2) + self.assertEqual(scan["d10_lagna_count"], 1) + self.assertEqual(scan["d1_lagna_count"], 1) + self.assertEqual(scan["transitions"], [{ + "layer": "d9", + "at": "05:14", + "user_meaning": "D9 在 05:14 发生变化", + }]) + encoded = str(scan) + self.assertNotIn("白羊", encoded) + self.assertNotIn("Aries", encoded) + self.assertFalse(scan["unique_minute_claim"]) + self.assertFalse(scan["confirmation_allowed"]) + + def test_match_level_and_dual_dasha_conflict(self): + self.assertEqual(match_level(["vim_md_domain_house"]), "strong") + self.assertEqual(match_level(["vim_ad_domain_lord"]), "medium") + self.assertEqual(match_level(["no_domain_activation"]), "none") + built = { + "matrix": { + "00000000-0000-4000-8000-000000000001": { + "05:13": {"points": 8, "rule_ids": ["vim_md_domain_house"]}, + "05:14": {"points": 1, "rule_ids": ["vim_ad_domain_house"]}, + }, + "00000000-0000-4000-8000-000000000002": { + "05:13": {"points": 1, "rule_ids": ["narayana_ad_domain_house"]}, + "05:14": {"points": 9, "rule_ids": ["narayana_md_domain_house"]}, + }, + } + } + agreement = dasha_agreement(built, ["05:13", "05:14"]) + self.assertEqual(agreement["status"], "conflict") + self.assertEqual(agreement["vimshottari_top"], "05:13") + self.assertEqual(agreement["narayana_top"], "05:14") + self.assertIn("冲突", agreement["user_meaning"]) + + def test_precision_stage_walks_d1_then_d9_then_ready(self): + self.assertEqual(precision_stage({"d1_candidates_differ": True}, 2)["current"], "lagna_frame") + self.assertEqual(precision_stage({"d9_candidates_differ": True}, 2)["current"], "d9_refine") + self.assertEqual(precision_stage({"d10_candidates_differ": True}, 2)["current"], "d10_refine") + self.assertEqual(precision_stage({"d4_candidates_differ": True}, 2)["current"], "theme_refine") + ready = precision_stage({}, 3) + self.assertEqual(ready["current"], "ready_to_adopt") + self.assertFalse(ready["unique_minute_claim"]) + + def test_packet_ledger_oos_and_nakshatra_without_scores(self): + request = request_events() + built = { + "static_contexts": [ + feature("05:13", d1=1, d9=1, d10=4, degree=13.1), + feature("05:14", d1=2, d9=1, d10=4, degree=13.2), + ], + "matrix": { + request["events"][0]["id"]: { + "05:13": {"points": 6, "rule_ids": ["vim_md_domain_house", "narayana_md_domain_house"]}, + "05:14": {"points": 1, "rule_ids": ["no_domain_activation"]}, + }, + request["events"][1]["id"]: { + "05:13": {"points": 2, "rule_ids": ["vim_ad_domain_house"]}, + "05:14": {"points": 2, "rule_ids": ["vim_ad_domain_house"]}, + }, + request["events"][2]["id"]: { + "05:13": {"points": 0, "rule_ids": ["no_domain_activation"]}, + "05:14": {"points": 0, "rule_ids": ["no_domain_activation"]}, + }, + }, + } + packet = build_refinement_packet( + request, + built, + representative_time="05:13", + candidate_times=["05:13", "05:14"], + ) + self.assertEqual(packet["event_dasha_ledger"][0]["match"], "strong") + self.assertIn("入职", packet["event_dasha_ledger"][0]["user_meaning"]) + self.assertNotIn("points", str(packet["event_dasha_ledger"])) + self.assertEqual(packet["lagna_contrast"]["intervals"][0]["lagna"], "金牛座") + self.assertTrue(packet["nakshatra_boundary"]["near_boundary"]) + self.assertEqual(packet["oos_blind_prompts"][0]["domain"], "family") + self.assertFalse(packet["oos_blind_prompts"][0]["used_for_scoring"]) + self.assertFalse(packet["confirmation_allowed"]) + encoded = str(packet["window_scan"]) + self.assertNotIn("热情冲动", encoded) + self.assertNotIn("配偶类型", encoded) + + def test_decision_receipt_downgrades_confidence_on_dasha_conflict(self): + request = request_events() + rows = [ + {"time": "05:13", "score": 20, "evidence": [], "missing_layers": []}, + {"time": "05:14", "score": 8, "evidence": [], "missing_layers": []}, + ] + decisions = build_candidate_decisions(rows, result_id="00000000-0000-4000-8000-000000000099") + built = { + "missing_layers": [], + "static_contexts": [feature("05:13", d1=1, d9=1, d10=4), feature("05:14", d1=1, d9=1, d10=4)], + "matrix": { + request["events"][0]["id"]: { + "05:13": {"points": 8, "rule_ids": ["vim_md_domain_house"]}, + "05:14": {"points": 1, "rule_ids": ["vim_ad_domain_house"]}, + }, + request["events"][1]["id"]: { + "05:13": {"points": 1, "rule_ids": ["narayana_ad_domain_house"]}, + "05:14": {"points": 9, "rule_ids": ["narayana_md_domain_house"]}, + }, + request["events"][2]["id"]: { + "05:13": {"points": 1, "rule_ids": ["vim_md_domain_varga"]}, + "05:14": {"points": 1, "rule_ids": ["vim_md_domain_varga"]}, + }, + }, + } + diagnostics = { + "leave_one_event_out_retention_rate": 1, + "leave_one_domain_out_retention_rate": 1, + "date_sensitivity_retention_rate": 1, + "primary_secondary_margin_percent": 50, + } + receipt = build_decision_receipt(request, decisions, built, diagnostics) + self.assertTrue(receipt["acceptance_allowed"]) + self.assertFalse(receipt["confirmation_allowed"]) + self.assertEqual(receipt["overall_confidence"], "medium") + self.assertIn("vimshottari_narayana_conflict", receipt["reasons"]) + self.assertEqual(receipt["dasha_agreement"]["status"], "conflict") + self.assertEqual(receipt["event_dasha_ledger"][0]["match_label"], "强相关") + + +if __name__ == "__main__": + unittest.main()