diff --git a/frontend/src/app/(app)/rectification/page.tsx b/frontend/src/app/(app)/rectification/page.tsx index c1ae7666..03330f5f 100644 --- a/frontend/src/app/(app)/rectification/page.tsx +++ b/frontend/src/app/(app)/rectification/page.tsx @@ -1,12 +1,12 @@ import type { Metadata } from "next"; -import { RectificationEntryPage } from "@/components/rectification-page/rectification-entry-page"; +import { ChartChoicePage } from "@/components/chart-choice/chart-choice-page"; export const metadata: Metadata = { - title: "生时校正 · Jyotisha", + title: "选定解读用的盘 · Jyotisha", robots: { index: false, follow: false }, }; export default function RectificationRoute() { - return ; + return ; } diff --git a/frontend/src/app/api/chart-choice/adopt/route.ts b/frontend/src/app/api/chart-choice/adopt/route.ts new file mode 100644 index 00000000..496b98ab --- /dev/null +++ b/frontend/src/app/api/chart-choice/adopt/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { chartChoiceCaller, chartChoiceErrorResponse, windowSchema } from "@/lib/chart-choice/route-support"; +import { adoptCertainTime, adoptChartChoice, previewChartChoice } from "@/lib/chart-choice/server"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; + +export const runtime = "nodejs"; + +const side = z.enum(["before", "after"]); +const bodySchema = z.object({ + subjectId: z.string().max(64).optional(), + window: windowSchema.optional(), + mode: z.enum(["preview", "choice", "certain"]).default("choice"), + answers: z.record(z.string().max(32), z.enum(["before", "after", "unsure"])).optional(), + overrideSide: side.optional(), + readBothAll: z.boolean().optional(), +}).strict(); + +/** + * POST /api/chart-choice/adopt — preview (mode "preview") or save the chosen chart. The minute is always + * recomputed on the server from the stored declaration; the browser only sends + * the user's answers. + */ +export async function POST(request: Request) { + const caller = await chartChoiceCaller("POST /api/chart-choice/adopt"); + if (!caller.ok) return caller.response; + const parsed = bodySchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "请求格式不对", code: "bad_request" }, { status: 400 }); + try { + if (parsed.data.mode === "preview") { + return NextResponse.json(await previewChartChoice(caller.supabase, caller.userId, parsed.data)); + } + const admin = createAdminSupabaseClient(); + if (parsed.data.mode === "certain") { + return NextResponse.json(await adoptCertainTime(caller.supabase, admin, caller.userId, parsed.data)); + } + const { decision, answers } = await adoptChartChoice(caller.supabase, admin, caller.userId, parsed.data); + return NextResponse.json({ decision, answers }); + } catch (error) { + return chartChoiceErrorResponse(error, "POST /api/chart-choice/adopt"); + } +} diff --git a/frontend/src/app/api/chart-choice/packet/route.ts b/frontend/src/app/api/chart-choice/packet/route.ts new file mode 100644 index 00000000..ce12a38a --- /dev/null +++ b/frontend/src/app/api/chart-choice/packet/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { chartChoiceCaller, chartChoiceErrorResponse, windowSchema } from "@/lib/chart-choice/route-support"; +import { buildChartChoicePacket } from "@/lib/chart-choice/server"; + +export const runtime = "nodejs"; + +const bodySchema = z.object({ + subjectId: z.string().max(64).optional(), + window: windowSchema.optional(), +}).strict(); + +/** POST /api/chart-choice/packet — narrowing question, "certain", or the two sides of the nearest line. */ +export async function POST(request: Request) { + const caller = await chartChoiceCaller("POST /api/chart-choice/packet"); + if (!caller.ok) return caller.response; + const parsed = bodySchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "请求格式不对", code: "bad_request" }, { status: 400 }); + try { + return NextResponse.json(await buildChartChoicePacket(caller.supabase, caller.userId, parsed.data)); + } catch (error) { + return chartChoiceErrorResponse(error, "POST /api/chart-choice/packet"); + } +} diff --git a/frontend/src/app/api/chart-choice/route.ts b/frontend/src/app/api/chart-choice/route.ts new file mode 100644 index 00000000..b4f288a5 --- /dev/null +++ b/frontend/src/app/api/chart-choice/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +import { chartChoiceCaller, chartChoiceErrorResponse } from "@/lib/chart-choice/route-support"; +import { readChartChoiceStart } from "@/lib/chart-choice/server"; + +export const runtime = "nodejs"; + +/** GET /api/chart-choice?subject=… — the declared range the chart choice starts from. */ +export async function GET(request: Request) { + const caller = await chartChoiceCaller("GET /api/chart-choice"); + if (!caller.ok) return caller.response; + try { + const subject = new URL(request.url).searchParams.get("subject"); + return NextResponse.json(await readChartChoiceStart(caller.supabase, caller.userId, subject)); + } catch (error) { + return chartChoiceErrorResponse(error, "GET /api/chart-choice"); + } +} diff --git a/frontend/src/app/chart-choice.css b/frontend/src/app/chart-choice.css new file mode 100644 index 00000000..2cc7aa97 --- /dev/null +++ b/frontend/src/app/chart-choice.css @@ -0,0 +1,174 @@ +/* /rectification — chart choice (2026-11-06). Standalone page, no chart panel. + Basic layout only; the visual design follows the prototype review. */ +.chart-choice-page { + display: grid; + gap: var(--space-4); + width: min(100%, 720px); + margin: 0 auto; + padding: var(--space-8) var(--space-6) var(--space-10); + box-sizing: border-box; +} +.chart-choice-intro, +.chart-choice-note { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-body-md); + line-height: 1.75; +} +.chart-choice-honest, +.chart-choice-fine { + margin: 0; + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + line-height: 1.6; +} +.chart-choice-card { + display: grid; + gap: var(--space-3); + padding: var(--space-5); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); +} +.chart-choice-heading { + margin: 0; + color: var(--color-ink); + font-size: var(--type-body-lg, 1.0625rem); + font-weight: 600; + line-height: 1.6; +} +.chart-choice-subheading { + display: flex; + align-items: baseline; + gap: var(--space-2); + margin: 0; + font-size: var(--type-body-md); + font-weight: 600; +} +.chart-choice-subheading span { + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + font-weight: 400; +} +.chart-choice-actions, +.chart-choice-options { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} +.chart-choice-options { + justify-content: flex-start; +} +.chart-choice-link { + display: inline-flex; + align-items: center; + height: 2.75rem; + padding: 0 var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md, 0.5rem); + color: var(--color-ink); +} +.chart-choice-link.is-primary { + border-color: transparent; + background: var(--color-action-fill); + color: var(--color-on-action-fill); +} +.chart-choice-sides { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-2); + margin: 0; +} +.chart-choice-sides > div { + padding: var(--space-3); + border-radius: var(--radius-md, 0.5rem); + background: var(--color-canvas-muted); +} +.chart-choice-sides > div.is-reported { + outline: 1px solid var(--color-ink-tertiary); +} +.chart-choice-sides dt { + color: var(--color-ink-secondary); + font-size: var(--type-caption); +} +.chart-choice-sides dd { + margin: 0; + font-variant-numeric: tabular-nums; + font-weight: 600; +} +.chart-choice-compare { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); +} +.chart-choice-column-title { + margin: 0 0 var(--space-2); + color: var(--color-ink-secondary); + font-size: var(--type-caption); + font-weight: 600; +} +.chart-choice-column ul { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; + font-size: var(--type-body-sm, 0.875rem); + line-height: 1.6; +} +.chart-choice-column li { + display: grid; + gap: 2px; +} +.chart-choice-fact { + color: var(--color-ink-tertiary); + font-size: var(--type-caption); +} +.chart-choice-questions { + display: grid; + gap: var(--space-4); + margin: 0; + padding-left: 1.25rem; +} +.chart-choice-question { + margin: 0 0 var(--space-2); + font-weight: 600; +} +.chart-choice-answers { + display: grid; + gap: var(--space-2); +} +.chart-choice-answer { + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md, 0.5rem); + background: var(--color-canvas); + color: var(--color-ink); + text-align: left; + line-height: 1.6; + cursor: pointer; +} +.chart-choice-answer.is-selected { + border-color: var(--color-ink); + background: var(--color-selected); +} +.chart-choice-table { + width: 100%; + border-collapse: collapse; + font-size: var(--type-body-sm, 0.875rem); +} +.chart-choice-table th, +.chart-choice-table td { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: top; +} +.chart-choice-table tr.is-both td { + background: var(--color-canvas-muted); +} +@media (max-width: 640px) { + .chart-choice-page { padding: var(--space-6) var(--space-4) var(--space-8); } + .chart-choice-compare { grid-template-columns: 1fr; } +} diff --git a/frontend/src/app/site-styles.ts b/frontend/src/app/site-styles.ts index f9a821da..34287e22 100644 --- a/frontend/src/app/site-styles.ts +++ b/frontend/src/app/site-styles.ts @@ -4,3 +4,4 @@ import "./globals.css"; import "./fonts/serif-sc/serif-sc.css"; import "./birth-time-choice.css"; import "./annual-report.css"; +import "./chart-choice.css"; diff --git a/frontend/src/components/chart-choice/chart-choice-page.tsx b/frontend/src/components/chart-choice/chart-choice-page.tsx new file mode 100644 index 00000000..65e6f341 --- /dev/null +++ b/frontend/src/components/chart-choice/chart-choice-page.tsx @@ -0,0 +1,393 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; + +import { AppLink } from "@/components/app-link"; +import { SecondaryPageShell } from "@/components/secondary-page-shell"; +import { Button } from "@/components/ui/button"; +import { + RECORD_RANGE_CHOICES, + RULE_TEXT, + type ChartChoiceAnswer, + type ChartChoiceDecision, + type ChartChoicePacket, + type ChartChoiceSide, + type ChartChoiceStart, + type ChartChoiceWindow, +} from "@/lib/chart-choice/contract"; +import { SIDE_TEXT, clockLabel, distanceText, otherSide, rangeLabel, windowAround } from "@/lib/chart-choice/view"; +import { useCurrentSubject, useCurrentSubjectReady } from "@/lib/current-subject"; +import { cn } from "@/lib/utils"; + +type Choose = Extract; + +type Step = + | { kind: "loading" } + | { kind: "failed"; message: string; retry: () => void } + | { kind: "unavailable"; reason: "profile_incomplete" | "subject_not_found" } + | { kind: "pick_range"; reportedAt: string } + | { kind: "packet_loading" } + | { kind: "narrow"; packet: Extract } + | { kind: "certain"; packet: Extract; saved: boolean } + | { kind: "choose"; packet: Choose } + | { kind: "result"; packet: Choose; decision: ChartChoiceDecision; saving: boolean } + | { kind: "saved"; packet: Choose; decision: ChartChoiceDecision }; + +const COPY = { + title: "选定解读用的盘", + intro: "出生时间差几分钟,有些分盘就会换星座。这里帮你看清楚:你填的时间附近有没有这样的分界线,两边的盘读出来差在哪,再由你来定用哪一张。", + honest: "这一步不会也不能证明你的准确出生分钟。用人生经历去对时间,我们在 77 个出生时间可靠的公开案例上测过,和随机挑差不多,所以这里不再那样做。", + profileHref: (subjectId: string) => (subjectId === "self" ? "/people?self=1" : `/people?edit=${encodeURIComponent(subjectId)}`), +}; + +async function postJson(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => null) as (T & { error?: string }) | null; + if (!response.ok || !payload) throw new Error(payload?.error || "暂时没有响应,请稍后再试。"); + return payload; +} + +function Card({ children, className }: { readonly children: ReactNode; readonly className?: string }) { + return
{children}
; +} + +function Actions({ children }: { readonly children: ReactNode }) { + return
{children}
; +} + +function Primary(props: { readonly onClick: () => void; readonly disabled?: boolean; readonly children: ReactNode }) { + return ; +} + +function Secondary(props: { readonly onClick: () => void; readonly disabled?: boolean; readonly children: ReactNode }) { + return ; +} + +export function ChartChoicePage() { + const subjectId = useCurrentSubject(); + const subjectReady = useCurrentSubjectReady(); + const [step, setStep] = useState({ kind: "loading" }); + const [window, setWindow] = useState(undefined); + const [answers, setAnswers] = useState>({}); + const generation = useRef(0); + + const loadPacket = useCallback(async (nextWindow: ChartChoiceWindow | undefined) => { + const ticket = ++generation.current; + setWindow(nextWindow); + setAnswers({}); + setStep({ kind: "packet_loading" }); + try { + const packet = await postJson("/api/chart-choice/packet", { subjectId, ...(nextWindow ? { window: nextWindow } : {}) }); + if (ticket !== generation.current) return; + if (packet.status === "needs_narrowing") setStep({ kind: "narrow", packet }); + else if (packet.status === "certain") setStep({ kind: "certain", packet, saved: false }); + else setStep({ kind: "choose", packet }); + } catch (error) { + if (ticket !== generation.current) return; + setStep({ kind: "failed", message: (error as Error).message, retry: () => void loadPacket(nextWindow) }); + } + }, [subjectId]); + + const loadStart = useCallback(async () => { + const ticket = ++generation.current; + setStep({ kind: "loading" }); + try { + const response = await fetch(`/api/chart-choice?subject=${encodeURIComponent(subjectId)}`); + const start = await response.json() as ChartChoiceStart & { error?: string }; + if (ticket !== generation.current) return; + if (!response.ok) throw new Error(start.error || "暂时没有响应,请稍后再试。"); + if (start.status === "unavailable") setStep({ kind: "unavailable", reason: start.reason }); + else if (start.status === "needs_range") setStep({ kind: "pick_range", reportedAt: start.reportedAt }); + else void loadPacket(undefined); + } catch (error) { + if (ticket !== generation.current) return; + setStep({ kind: "failed", message: (error as Error).message, retry: () => void loadStart() }); + } + }, [loadPacket, subjectId]); + + useEffect(() => { + if (!subjectReady) return; + void loadStart(); + }, [loadStart, subjectReady]); + + const runDecision = useCallback(async (packet: Choose, mode: "preview" | "choice", extra: { overrideSide?: ChartChoiceSide; readBothAll?: boolean } = {}) => { + const body = { subjectId, ...(window ? { window } : {}), mode, answers, ...extra }; + if (mode === "choice" && step.kind === "result") setStep({ ...step, saving: true }); + try { + const { decision } = await postJson<{ decision: ChartChoiceDecision }>("/api/chart-choice/adopt", body); + setStep(mode === "preview" ? { kind: "result", packet, decision, saving: false } : { kind: "saved", packet, decision }); + } catch (error) { + setStep({ kind: "failed", message: (error as Error).message, retry: () => setStep({ kind: "choose", packet }) }); + } + }, [answers, step, subjectId, window]); + + const saveCertain = useCallback(async (packet: Extract) => { + try { + await postJson("/api/chart-choice/adopt", { subjectId, ...(window ? { window } : {}), mode: "certain" }); + setStep({ kind: "certain", packet, saved: true }); + } catch (error) { + setStep({ kind: "failed", message: (error as Error).message, retry: () => setStep({ kind: "certain", packet, saved: false }) }); + } + }, [subjectId, window]); + + return ( + +
+

{COPY.intro}

+ {renderStep()} +

{COPY.honest}

+
+
+ ); + + function renderStep(): ReactNode { + switch (step.kind) { + case "loading": + case "packet_loading": + return

正在计算你填的时间附近的分界线…

; + case "failed": + return ( + +

{step.message}

+ 重试 +
+ ); + case "unavailable": + return ( + +

+ {step.reason === "profile_incomplete" ? "出生资料还不完整。先补全出生日期、出生时间(或大概时段)和出生地点。" : "这份人物资料不存在,或不属于当前账户。"} +

+ {step.reason === "profile_incomplete" + ? 去补全资料 + : null} +
+ ); + case "pick_range": + return ( + +

你填的是 {clockLabel(step.reportedAt)},这个时间大概准到什么程度?

+
+ {RECORD_RANGE_CHOICES.map((minutes) => ( + void loadPacket(windowAround(step.reportedAt, minutes))}> + {minutes === 5 ? "很准,前后 5 分钟内" : minutes === 60 ? "前后一小时" : `前后 ${minutes} 分钟`} + + ))} +
+
+ ); + case "narrow": + return ( + +

+ 你给的范围是 {rangeLabel(step.packet.window)},有点宽,里面的分界线太多。先把范围缩小一点,再开始比较。 +

+

{step.packet.narrowing.prompt}

+
+ {step.packet.narrowing.options.map((option) => ( + void loadPacket({ start_at: option.start_at, end_at: option.end_at })}> + {option.text} + + ))} +
+

实在记不清的话可以先不选,照你原来填的资料解读,之后想起来再回来。

+
+ ); + case "certain": + return ( + +

+ {rangeLabel(step.packet.window)} 这个范围里,你的主盘和分盘都一样,不需要选。 +

+

解读会用 {clockLabel(step.packet.saved_at)}。

+ {step.saved + ?

已保存。

+ : step.packet.reported_known + ? null + : void saveCertain(step.packet)}>用这个时间} +
+ ); + case "choose": + return setAnswers((prev) => ({ ...prev, [domain]: value }))} onNext={() => void runDecision(step.packet, "preview")} />; + case "result": + return ( + void runDecision(step.packet, "choice")} + onFlip={() => void runDecision(step.packet, "choice", { overrideSide: otherSide(step.decision.chosen_side) })} + onBoth={() => void runDecision(step.packet, "choice", { readBothAll: true })} + onBack={() => setStep({ kind: "choose", packet: step.packet })} + /> + ); + case "saved": + return ( + +

已保存:用 {clockLabel(step.decision.saved_at)}({SIDE_TEXT[step.decision.chosen_side]})这张盘。

+ {step.decision.both_versions.length ? ( +

+ 咨询 {step.decision.both_versions.map((d) => d.label).join("、")} 时,会把两种看法都写出来。 +

+ ) : null} +

这是你自己的选择,不是验证出来的出生分钟。想改随时可以回来重新选。

+ + 看星盘 + 去咨询 + +
+ ); + } + } +} + +function ChooseStep({ + packet, + answers, + onAnswer, + onNext, +}: { + readonly packet: Choose; + readonly answers: Readonly>; + readonly onAnswer: (domain: string, value: ChartChoiceAnswer) => void; + readonly onNext: () => void; +}) { + const ref = packet.reported_at; + const flipped = packet.comparisons.map((c) => c.label).join("、"); + return ( + <> + +

+ 你填的 {clockLabel(packet.reported_at)} 离一条分界线只有 {distanceText(packet.seconds_from_line)}。 +

+

+ {clockLabel(packet.line.at, ref, true)} 前后,{flipped}这几张盘会一起换星座。你的主盘里, + {packet.unchanged.length ? `${packet.unchanged.map((u) => u.label).join("、")}这几块两边一样,不受影响。` : "所有分盘都会受影响。"} +

+
+ {(["before", "after"] as const).map((side) => ( +
+
{SIDE_TEXT[side]}{packet.reported_side === side ? "(你填的时间在这边)" : ""}
+
{clockLabel(packet.sides[side].from, ref)}–{clockLabel(packet.sides[side].to, ref)}
+
+ ))} +
+ {packet.other_lines_in_window > 0 ? ( +

你给的范围里还有 {packet.other_lines_in_window} 条分界线,离你填的时间更远,这次只比较最近的这一条。

+ ) : null} +
+ + {packet.comparisons.map((comparison) => ( + +

{comparison.label}{comparison.chart_label}

+
+ {(["before", "after"] as const).map((side) => ( +
+

{SIDE_TEXT[side]} · {comparison[side].ascendant}

+
    + {comparison[side].points.map((point) => ( +
  • + {point.fact} + {point.meaning} +
  • + ))} +
+
+ ))} +
+
+ ))} + + {packet.questions.length ? ( + +

按你自己的感觉选,哪一边更像你?

+

每题都可以选「说不清」。回答只记作你的选择,不会被写成验证结果。

+
    + {packet.questions.map((question) => ( +
  1. +

    {question.prompt}

    +
    + {question.options.map((option) => ( + + ))} +
    +
  2. + ))} +
+ 看结果 +
+ ) : ( + 看结果 + )} + + ); +} + +function ResultStep({ + packet, + decision, + saving, + onAccept, + onFlip, + onBoth, + onBack, +}: { + readonly packet: Choose; + readonly decision: ChartChoiceDecision; + readonly saving: boolean; + readonly onAccept: () => void; + readonly onFlip: () => void; + readonly onBoth: () => void; + readonly onBack: () => void; +}) { + const chosen = decision.chosen_side; + const both = new Set(decision.both_versions_domains); + const votes = decision.votes; + return ( + +

+ 用哪张盘:{SIDE_TEXT[chosen]},保存为 {clockLabel(decision.saved_at, packet.reported_at)} + {decision.saved_at === packet.reported_at ? "(你填的时间)" : ""} +

+ {votes ? ( +

+ 你的回答里 {votes.before} 题更像线前,{votes.after} 题更像线后{votes.unsure ? `,${votes.unsure} 题说不清` : ""}。{RULE_TEXT[decision.rule]} +

+ ) :

{RULE_TEXT[decision.rule]}

} + + + + {packet.unchanged.length ? ( + + ) : null} + {packet.comparisons.map((c) => ( + + + + + ))} + +
领域之后怎么读
{packet.unchanged.map((u) => u.label).join("、")}两边一样,正常读
{c.label}{both.has(c.before.domain) ? "两版都读:你在这一题上觉得另一边更像,咨询时把两种看法并排写出来" : `按${SIDE_TEXT[chosen]}读`}
+

这是你按自己的感觉做的选择,不是验证出来的出生分钟。以后随时可以回来改。

+ + 回去改答案 + 改成{SIDE_TEXT[otherSide(chosen)]} + 全部两版都读 + {saving ? "保存中…" : "好,就这样"} + +
+ ); +} diff --git a/frontend/src/lib/chart-choice/contract.ts b/frontend/src/lib/chart-choice/contract.ts new file mode 100644 index 00000000..4948f7e2 --- /dev/null +++ b/frontend/src/lib/chart-choice/contract.ts @@ -0,0 +1,107 @@ +/** + * Chart choice: shared client/server shapes. + * + * Mirrors scripts/chart_choice/service.py. The engine never scores life + * events (BUG-1091); it finds the dividing line nearest the reported time, + * describes both sides, and applies fixed rules to the user's own answers. + */ + +export type ChartChoiceSide = "before" | "after"; +export type ChartChoiceAnswer = ChartChoiceSide | "unsure"; + +export type ChartChoiceWindow = { readonly start_at: string; readonly end_at: string }; + +export type ChartChoicePoint = { readonly kind: string; readonly fact: string; readonly meaning: string }; + +export type ChartChoiceSideReading = { + readonly chart: string; + readonly domain: string; + readonly label: string; + readonly chart_label: string; + readonly ascendant: string; + readonly points: readonly ChartChoicePoint[]; +}; + +export type ChartChoiceComparison = { + readonly chart: string; + readonly label: string; + readonly chart_label: string; + readonly before: ChartChoiceSideReading; + readonly after: ChartChoiceSideReading; +}; + +export type ChartChoiceQuestion = { + readonly domain: string; + readonly chart: string; + readonly label: string; + readonly prompt: string; + readonly options: readonly { readonly value: ChartChoiceAnswer; readonly text: string }[]; +}; + +type PacketBase = { + readonly engine_version: string; + readonly window: ChartChoiceWindow; + readonly reported_at: string; + readonly reported_known: boolean; +}; + +export type ChartChoiceNarrowing = { + readonly anchor: "sunrise" | "half"; + readonly prompt: string; + readonly options: readonly (ChartChoiceWindow & { readonly text: string })[]; +}; + +export type ChartChoicePacket = + | (PacketBase & { readonly status: "needs_narrowing"; readonly narrowing: ChartChoiceNarrowing }) + | (PacketBase & { readonly status: "certain"; readonly saved_at: string }) + | (PacketBase & { + readonly status: "choose"; + readonly line: { readonly at: string; readonly degree: number; readonly charts: readonly string[] }; + readonly other_lines_in_window: number; + readonly reported_side: ChartChoiceSide; + readonly reported_neutral: boolean; + readonly seconds_from_line: number; + readonly sides: Record; + readonly comparisons: readonly ChartChoiceComparison[]; + readonly unchanged: readonly { readonly chart: string; readonly label: string }[]; + readonly questions: readonly ChartChoiceQuestion[]; + }); + +export type ChartChoiceDecision = { + readonly chosen_side: ChartChoiceSide; + readonly rule: "majority" | "tie_keeps_reported" | "strong_override" | "reported_kept" | "user_override"; + readonly votes: { readonly before: number; readonly after: number; readonly unsure: number } | null; + readonly both_versions_domains: readonly string[]; + readonly both_versions: readonly { readonly domain: string; readonly label: string }[]; + readonly saved_at: string; +}; + +/** What the page shows before any engine call: the declared range, or a prompt to give one. */ +export type ChartChoiceStart = + | { readonly status: "ready"; readonly subjectId: string; readonly name: string; readonly window: ChartChoiceWindow; readonly reportedAt: string | null; readonly precision: "minutes" | "period" | "unknown" } + | { readonly status: "needs_range"; readonly subjectId: string; readonly name: string; readonly reportedAt: string } + | { readonly status: "unavailable"; readonly subjectId: string; readonly reason: "profile_incomplete" | "subject_not_found" }; + +export type ChartChoiceRequest = { + readonly subjectId?: string; + /** A narrowed range; must sit inside the declared one (or, for a time given without a range, around it). */ + readonly window?: ChartChoiceWindow; +}; + +export type ChartChoiceAdoptRequest = ChartChoiceRequest & { + readonly answers?: Readonly>; + readonly overrideSide?: ChartChoiceSide; + readonly readBothAll?: boolean; +}; + +/** A time given without a range: the widths the user can pick from (minutes either side). */ +export const RECORD_RANGE_CHOICES = [5, 15, 30, 60] as const; + +/** Labels for result rules, in the voice of the result page. */ +export const RULE_TEXT: Record = { + majority: "你填的时间离分界线很近,按你的回答多数来定。", + tie_keeps_reported: "你的回答两边一样多,保留你填的时间。", + strong_override: "你的回答明显更像另一边,所以换过去。", + reported_kept: "你填的时间离分界线有一段距离,回答没有明显偏向另一边,保留你填的时间。", + user_override: "按你直接选的这一边。", +}; diff --git a/frontend/src/lib/chart-choice/route-support.ts b/frontend/src/lib/chart-choice/route-support.ts new file mode 100644 index 00000000..d5c055ac --- /dev/null +++ b/frontend/src/lib/chart-choice/route-support.ts @@ -0,0 +1,60 @@ +import "server-only"; + +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { jsonForSupabaseSetupFailure } from "../api/service-unavailable.ts"; +import { isProductEnabled } from "../product-access.ts"; +import { consumeUserRequestRateLimit } from "../request-rate-limit.ts"; +import { createServerSupabaseClient } from "../supabase/server.ts"; +import { ChartChoiceError } from "./server.ts"; + +export const windowSchema = z.object({ + start_at: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/), + end_at: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/), +}).strict(); + +const ERROR_TEXT: Record = { + subject_not_found: "这份人物资料不存在,或不属于当前账户。", + profile_incomplete: "出生资料还不完整,先补全出生日期、时间和地点。", + bad_window: "这个时间范围不在你填的范围里,请重新选。", + engine_unavailable: "排盘服务暂时没有响应,请稍后再试。", + no_choice: "这个范围里没有需要选的分界线。", +}; + +export function chartChoiceErrorResponse(error: unknown, routeName: string) { + if (error instanceof ChartChoiceError) { + return NextResponse.json({ error: ERROR_TEXT[error.code], code: error.code }, { status: error.status }); + } + console.error(`[${routeName}] chart choice failed`, error instanceof Error ? error.name : "unknown"); + return NextResponse.json({ error: ERROR_TEXT.engine_unavailable, code: "engine_unavailable" }, { status: 503 }); +} + +/** Signed-in user, product switch and rate limit; returns a response to send when any of them fails. */ +export async function chartChoiceCaller(routeName: string): Promise< + | { ok: true; supabase: Awaited>; userId: string } + | { ok: false; response: Response } +> { + let supabase; + try { + supabase = await createServerSupabaseClient(); + } catch (error) { + return { ok: false, response: jsonForSupabaseSetupFailure(error, routeName) }; + } + const { data: { user }, error } = await supabase.auth.getUser(); + if (error || !user) return { ok: false, response: NextResponse.json({ error: "请先登录" }, { status: 401 }) }; + if (!await isProductEnabled("rectification")) { + return { ok: false, response: NextResponse.json({ error: "选盘服务暂未开放", code: "product_disabled" }, { status: 403 }) }; + } + const limited = consumeUserRequestRateLimit("chartChoice", user.id); + if (!limited.ok) { + return { + ok: false, + response: NextResponse.json( + { error: "操作有点快,先停一下再试。", code: "rate_limited" }, + { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } }, + ), + }; + } + return { ok: true, supabase, userId: user.id }; +} diff --git a/frontend/src/lib/chart-choice/server.ts b/frontend/src/lib/chart-choice/server.ts new file mode 100644 index 00000000..f72f4cab --- /dev/null +++ b/frontend/src/lib/chart-choice/server.ts @@ -0,0 +1,298 @@ +import "server-only"; + +import { resolveAyanamsa } from "../ayanamsa.ts"; +import { CHART_VIEW_NODE_MODE } from "../chart-view-load.ts"; +import { prepareChartViewProfile } from "../chart-view-profile-read.ts"; +import { lagnaPrecheckWindow, type LagnaPrecheckWindow } from "../lagna-precheck.ts"; +import { ConsultationSubjectError, loadSubjectBirth, SELF_SUBJECT_ID } from "../subject-birth.ts"; +import type { + ChartChoiceAdoptRequest, + ChartChoiceDecision, + ChartChoicePacket, + ChartChoiceStart, + ChartChoiceWindow, +} from "./contract.ts"; +import { RECORD_RANGE_CHOICES } from "./contract.ts"; + +const apiBase = () => process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200"; +const ENGINE_TIMEOUT_MS = 8000; +const MAX_RECORD_SPAN_MINUTES = Math.max(...RECORD_RANGE_CHOICES); + +export class ChartChoiceError extends Error { + constructor(readonly code: "subject_not_found" | "profile_incomplete" | "bad_window" | "engine_unavailable" | "no_choice", readonly status: number) { + super(code); + this.name = "ChartChoiceError"; + } +} + +type Subject = { + subjectId: string; + name: string; + row: Record; + declared: LagnaPrecheckWindow | null; + location: { lat: number; lon: number; tz: number; ayanamsa: string }; +}; + +/** The declaration as the user gave it; an earlier adoption must not shift the range. */ +function declarationRow(row: Record): Record { + return { + ...row, + active_birth_time: null, + active_birth_date: null, + active_birth_timezone_offset: null, + active_birth_provenance: null, + birth_time_status: row.reported_birth_time ? "reported" : null, + }; +} + +async function loadSubject(supabase: unknown, userId: string, subjectId: unknown): Promise { + let resolved; + try { + resolved = await loadSubjectBirth(supabase, userId, subjectId); + } catch (error) { + if (error instanceof ConsultationSubjectError && error.code === "subject_incomplete") { + throw new ChartChoiceError("profile_incomplete", 409); + } + if (error instanceof ConsultationSubjectError && error.code === "subject_unavailable") { + throw new ChartChoiceError("engine_unavailable", 503); + } + throw new ChartChoiceError("subject_not_found", 404); + } + const row = declarationRow(resolved.row); + const prepared = await prepareChartViewProfile({ row }); + if (!prepared.ok) throw new ChartChoiceError("profile_incomplete", 409); + return { + subjectId: resolved.subjectId, + name: resolved.name, + row, + declared: lagnaPrecheckWindow({ row }), + location: { + lat: prepared.profile.latitude, + lon: prepared.profile.longitude, + tz: prepared.profile.timezoneOffset, + ayanamsa: resolveAyanamsa({ ayanamsa: prepared.profile.ayanamsa }), + }, + }; +} + +function minutesOf(at: string): number { + return Date.parse(`${at}:00Z`) / 60_000; +} + +const AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/; + +function reportedAt(declared: LagnaPrecheckWindow): string | null { + if (declared.precisionKind === "record") return declared.start_at; + if (declared.precisionKind !== "minutes" || declared.beforeMinutes == null) return null; + const at = new Date((minutesOf(declared.start_at) + declared.beforeMinutes) * 60_000).toISOString(); + return at.slice(0, 16); +} + +/** + * The range to scan. A narrowed range from the browser is accepted only when + * it sits inside the declared range; a time given without a range may be + * widened symmetrically up to the largest offered width. + */ +function scanWindow(subject: Subject, requested: ChartChoiceWindow | undefined): ChartChoiceWindow { + const declared = subject.declared; + if (!declared) throw new ChartChoiceError("profile_incomplete", 409); + if (!requested) return { start_at: declared.start_at, end_at: declared.end_at }; + if (!AT.test(requested.start_at) || !AT.test(requested.end_at)) throw new ChartChoiceError("bad_window", 400); + const start = minutesOf(requested.start_at); + const end = minutesOf(requested.end_at); + if (!(end > start)) throw new ChartChoiceError("bad_window", 400); + if (declared.precisionKind === "record") { + const reported = minutesOf(declared.start_at); + if (start > reported || end < reported || reported - start > MAX_RECORD_SPAN_MINUTES || end - reported > MAX_RECORD_SPAN_MINUTES) { + throw new ChartChoiceError("bad_window", 400); + } + return requested; + } + if (start < minutesOf(declared.start_at) || end > minutesOf(declared.end_at)) throw new ChartChoiceError("bad_window", 400); + return requested; +} + +async function engine(path: string, body: Record, fetchImpl: typeof fetch = fetch): Promise { + let response: Response; + try { + response = await fetchImpl(`${apiBase()}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(ENGINE_TIMEOUT_MS), + body: JSON.stringify(body), + }); + } catch { + throw new ChartChoiceError("engine_unavailable", 503); + } + if (response.status === 400) throw new ChartChoiceError("bad_window", 400); + if (!response.ok) throw new ChartChoiceError("engine_unavailable", 503); + return await response.json() as T; +} + +function engineBody(subject: Subject, window: ChartChoiceWindow): Record { + const reported = subject.declared ? reportedAt(subject.declared) : null; + return { + start_at: window.start_at, + end_at: window.end_at, + ...(reported ? { reported_at: reported } : {}), + lat: subject.location.lat, + lon: subject.location.lon, + tz: subject.location.tz, + ayanamsa: subject.location.ayanamsa, + node_mode: CHART_VIEW_NODE_MODE, + }; +} + +export async function readChartChoiceStart(supabase: unknown, userId: string, subjectId: unknown): Promise { + let subject: Subject; + try { + subject = await loadSubject(supabase, userId, subjectId); + } catch (error) { + if (error instanceof ChartChoiceError && (error.code === "profile_incomplete" || error.code === "subject_not_found")) { + return { status: "unavailable", subjectId: String(subjectId ?? SELF_SUBJECT_ID), reason: error.code }; + } + throw error; + } + const declared = subject.declared; + if (!declared) return { status: "unavailable", subjectId: subject.subjectId, reason: "profile_incomplete" }; + if (declared.precisionKind === "record") { + return { status: "needs_range", subjectId: subject.subjectId, name: subject.name, reportedAt: declared.start_at }; + } + return { + status: "ready", + subjectId: subject.subjectId, + name: subject.name, + window: { start_at: declared.start_at, end_at: declared.end_at }, + reportedAt: reportedAt(declared), + precision: declared.precisionKind === "minutes" ? "minutes" : declared.precisionKind === "period" ? "period" : "unknown", + }; +} + +export async function buildChartChoicePacket( + supabase: unknown, + userId: string, + request: { subjectId?: unknown; window?: ChartChoiceWindow }, + fetchImpl?: typeof fetch, +): Promise { + const subject = await loadSubject(supabase, userId, request.subjectId); + const window = scanWindow(subject, request.window); + return engine("/api/chart_choice/packet", engineBody(subject, window), fetchImpl); +} + +type RpcClient = { + rpc: (fn: string, args: Record) => PromiseLike<{ data: unknown; error: { message?: string } | null }>; +}; + +type DecidedChoice = { + packet: Extract; + decision: ChartChoiceDecision; + answers: Record; + subject: Subject; +}; + +async function decideChartChoice( + supabase: unknown, + userId: string, + request: ChartChoiceAdoptRequest, + fetchImpl?: typeof fetch, +): Promise { + const subject = await loadSubject(supabase, userId, request.subjectId); + const window = scanWindow(subject, request.window); + const result = await engine }>( + "/api/chart_choice/decide", + { + ...engineBody(subject, window), + answers: request.answers ?? {}, + ...(request.overrideSide ? { override_side: request.overrideSide } : {}), + ...(request.readBothAll ? { read_both_all: true } : {}), + }, + fetchImpl, + ); + if (result.status !== "choose" || !result.decision) throw new ChartChoiceError("no_choice", 409); + return { packet: result, decision: result.decision, answers: result.answers ?? {}, subject }; +} + +/** The result page before anything is saved. */ +export async function previewChartChoice( + supabase: unknown, + userId: string, + request: ChartChoiceAdoptRequest, + fetchImpl?: typeof fetch, +): Promise<{ decision: ChartChoiceDecision; answers: Record }> { + const { decision, answers } = await decideChartChoice(supabase, userId, request, fetchImpl); + return { decision, answers }; +} + +export async function adoptChartChoice( + supabase: unknown, + admin: RpcClient, + userId: string, + request: ChartChoiceAdoptRequest, + fetchImpl?: typeof fetch, +): Promise<{ decision: ChartChoiceDecision; answers: Record }> { + const { packet: result, decision, answers, subject } = await decideChartChoice(supabase, userId, request, fetchImpl); + const [date, time] = decision.saved_at.split("T"); + const { error } = await admin.rpc("chart_choice_adopt_v1", { + p_user_id: userId, + p_chart_profile_id: subject.subjectId === SELF_SUBJECT_ID ? null : subject.subjectId, + p_expected_updated_at: null, + p_time: `${time}:00`, + p_date: date, + p_offset: subject.location.tz, + p_provenance: { + contract: "chart-choice-v1", + source: "user_choice", + engine_version: result.engine_version, + window: result.window, + reported_at: result.reported_at, + line_at: result.line.at, + line_charts: result.line.charts, + chosen_side: decision.chosen_side, + rule: decision.rule, + votes: decision.votes, + answers, + both_versions: decision.both_versions_domains, + adopted_at: new Date().toISOString(), + }, + }); + if (error) { + if (String(error.message ?? "").includes("chart_choice_subject_not_found")) throw new ChartChoiceError("subject_not_found", 404); + throw new ChartChoiceError("engine_unavailable", 503); + } + return { decision, answers }; +} + +/** Save the time when the range holds no dividing line (nothing to choose). */ +export async function adoptCertainTime( + supabase: unknown, + admin: RpcClient, + userId: string, + request: { subjectId?: unknown; window?: ChartChoiceWindow }, + fetchImpl?: typeof fetch, +): Promise<{ saved_at: string }> { + const subject = await loadSubject(supabase, userId, request.subjectId); + const window = scanWindow(subject, request.window); + const packet = await engine("/api/chart_choice/packet", engineBody(subject, window), fetchImpl); + if (packet.status !== "certain") throw new ChartChoiceError("no_choice", 409); + const [date, time] = packet.saved_at.split("T"); + const { error } = await admin.rpc("chart_choice_adopt_v1", { + p_user_id: userId, + p_chart_profile_id: subject.subjectId === SELF_SUBJECT_ID ? null : subject.subjectId, + p_expected_updated_at: null, + p_time: `${time}:00`, + p_date: date, + p_offset: subject.location.tz, + p_provenance: { + contract: "chart-choice-v1", + source: "user_choice", + engine_version: packet.engine_version, + window: packet.window, + reported_at: packet.reported_at, + rule: "no_line_in_range", + both_versions: [], + adopted_at: new Date().toISOString(), + }, + }); + if (error) throw new ChartChoiceError("engine_unavailable", 503); + return { saved_at: packet.saved_at }; +} diff --git a/frontend/src/lib/chart-choice/view.ts b/frontend/src/lib/chart-choice/view.ts new file mode 100644 index 00000000..1525e2c6 --- /dev/null +++ b/frontend/src/lib/chart-choice/view.ts @@ -0,0 +1,43 @@ +/** + * Chart choice page: pure helpers (clock labels, request shapes) kept out of + * the component so they can be tested without a browser. + */ +import type { ChartChoiceAnswer, ChartChoicePacket, ChartChoiceSide, ChartChoiceWindow } from "./contract.ts"; + +/** "1997-08-08T04:59:43" → "04:59:43"; with a date only when it differs from ``sameDayAs``. */ +export function clockLabel(at: string, sameDayAs?: string, withSeconds = false): string { + const [date, time = ""] = at.split("T"); + const clock = withSeconds ? time.slice(0, 8) : time.slice(0, 5); + if (!sameDayAs || sameDayAs.slice(0, 10) === date) return clock; + const [, month, day] = date.split("-").map(Number); + return `${month}月${day}日 ${clock}`; +} + +export function rangeLabel(window: ChartChoiceWindow, reference?: string): string { + return `${clockLabel(window.start_at, reference)}–${clockLabel(window.end_at, reference)}`; +} + +/** Symmetric range around a time given without one (minutes either side). */ +export function windowAround(reportedAt: string, minutes: number): ChartChoiceWindow { + const center = Date.parse(`${reportedAt}:00Z`); + const iso = (ms: number) => new Date(ms).toISOString().slice(0, 16); + return { start_at: iso(center - minutes * 60_000), end_at: iso(center + minutes * 60_000) }; +} + +export const SIDE_TEXT: Record = { before: "线前", after: "线后" }; + +export function otherSide(side: ChartChoiceSide): ChartChoiceSide { + return side === "before" ? "after" : "before"; +} + +export function answeredCount(answers: Readonly>, packet: ChartChoicePacket): number { + if (packet.status !== "choose") return 0; + return packet.questions.filter((q) => answers[q.domain]).length; +} + +/** Plain words for how far the reported time is from the line. */ +export function distanceText(seconds: number): string { + if (seconds < 60) return `${seconds} 秒`; + const minutes = Math.round(seconds / 60); + return `${minutes} 分钟`; +} diff --git a/frontend/src/lib/request-rate-limit.ts b/frontend/src/lib/request-rate-limit.ts index 4c929e6f..43383139 100644 --- a/frontend/src/lib/request-rate-limit.ts +++ b/frontend/src/lib/request-rate-limit.ts @@ -36,6 +36,8 @@ export const requestRateLimits = { chartView: { limit: 20, windowMs: 60_000 }, chartViewLayer: { limit: 60, windowMs: 60_000 }, birthSky: { limit: 20, windowMs: 60_000 }, + // Chart choice: each step is one boundary scan (about 0.1 s of engine time). + chartChoice: { limit: 30, windowMs: 60_000 }, } as const; export const userRateLimitMessages = { diff --git a/frontend/supabase/migrations/20261106010000_chart_choice_adopt.sql b/frontend/supabase/migrations/20261106010000_chart_choice_adopt.sql new file mode 100644 index 00000000..52aa66c7 --- /dev/null +++ b/frontend/supabase/migrations/20261106010000_chart_choice_adopt.sql @@ -0,0 +1,89 @@ +-- Chart choice (replaces event-scored rectification adoption). +-- +-- The user picks which side of a dividing line to read; the server recomputes +-- the minute from the stored declaration and calls this function with it. +-- Provenance records that the minute is the user's own choice, never a +-- verified birth minute, plus the domains to read in both versions. +-- +-- Writes go through the same columns the chart and consult already read +-- (active_birth_time / active_birth_date / active_birth_timezone_offset / +-- active_birth_provenance, birth_time_status = 'accepted'). Another person's +-- row uses the transaction-local marker chart_profiles_guard_birth accepts. +begin; + +create or replace function public.chart_choice_adopt_v1( + p_user_id uuid, + p_chart_profile_id uuid, + p_expected_updated_at timestamptz, + p_time time without time zone, + p_date date, + p_offset double precision, + p_provenance jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_updated timestamptz; +begin + if p_user_id is null or p_time is null or p_date is null or p_offset is null + or extract(second from p_time) is distinct from 0 + or p_offset < -14 or p_offset > 14 + or p_provenance is null or jsonb_typeof(p_provenance) <> 'object' + or p_provenance->>'contract' is distinct from 'chart-choice-v1' + or p_provenance->>'source' is distinct from 'user_choice' then + raise exception 'chart_choice_invalid_input' using errcode = 'P0001'; + end if; + + if p_chart_profile_id is null then + select updated_at into v_updated from public.profiles where id = p_user_id for update; + if not found then + raise exception 'chart_choice_subject_not_found' using errcode = 'P0001'; + end if; + if p_expected_updated_at is not null and v_updated is distinct from p_expected_updated_at then + raise exception 'chart_choice_profile_changed' using errcode = 'P0001'; + end if; + update public.profiles + set active_birth_time = p_time, + active_birth_date = p_date, + active_birth_timezone_offset = p_offset, + active_birth_provenance = p_provenance, + birth_time = p_time, + birth_time_status = 'accepted', + updated_at = pg_catalog.now() + where id = p_user_id; + return jsonb_build_object('subject', 'self', 'time', p_time, 'date', p_date); + end if; + + select updated_at into v_updated + from public.chart_profiles + where id = p_chart_profile_id and user_id = p_user_id and role = 'other' + for update; + if not found then + raise exception 'chart_choice_subject_not_found' using errcode = 'P0001'; + end if; + if p_expected_updated_at is not null and v_updated is distinct from p_expected_updated_at then + raise exception 'chart_choice_profile_changed' using errcode = 'P0001'; + end if; + perform pg_catalog.set_config('jyotisha.rectification_subject_write', p_chart_profile_id::text, true); + update public.chart_profiles + set active_birth_time = p_time, + active_birth_date = p_date, + active_birth_timezone_offset = p_offset, + active_birth_provenance = p_provenance, + birth_time_status = 'accepted', + updated_at = pg_catalog.now() + where id = p_chart_profile_id and user_id = p_user_id and role = 'other'; + perform pg_catalog.set_config('jyotisha.rectification_subject_write', '', true); + return jsonb_build_object('subject', p_chart_profile_id, 'time', p_time, 'date', p_date); +end; +$$; + +revoke all on function public.chart_choice_adopt_v1(uuid, uuid, timestamptz, time without time zone, date, double precision, jsonb) + from public, anon, authenticated; +grant execute on function public.chart_choice_adopt_v1(uuid, uuid, timestamptz, time without time zone, date, double precision, jsonb) + to service_role; + +commit; diff --git a/scripts/chart_choice/__init__.py b/scripts/chart_choice/__init__.py new file mode 100644 index 00000000..63e5e8fb --- /dev/null +++ b/scripts/chart_choice/__init__.py @@ -0,0 +1,13 @@ +"""Chart choice: pick which chart to read when the birth time sits near a dividing line. + +This replaces the old event-scoring rectification flow (BUG-1091: event scoring +was measured at chance level on 77 Rodden-AA cases). Nothing here scores life +events. The engine only: + +1. finds the minutes where the ascendant of D1/D2/D4/D9/D10/D12/D24 changes sign + (``boundaries``), +2. describes how the reading differs on either side of the line nearest the + reported time (``readings``), +3. turns the user's own self-assessment answers into a choice with fixed, + documented rules (``decision``). +""" diff --git a/scripts/chart_choice/boundaries.py b/scripts/chart_choice/boundaries.py new file mode 100644 index 00000000..14283ba6 --- /dev/null +++ b/scripts/chart_choice/boundaries.py @@ -0,0 +1,157 @@ +"""Exact dividing lines inside a birth-time window. + +A "line" is the instant the ascendant crosses a degree where at least one of the +tracked divisional charts changes its ascendant sign. Several charts can flip at +the same degree (for example 15 degrees flips D2, D4, D10, D12 and D24 at once); +those are one line, one choice. +""" +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Final + +_SCRIPTS = Path(__file__).resolve().parents[1] +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + +import domain_calculation_service # noqa: E402 +import varga # noqa: E402 + +TRACKED_DIVISIONS: Final[tuple[int, ...]] = (1, 2, 4, 9, 10, 12, 24) +MAX_WINDOW_MINUTES: Final = 60 +# Lines closer than this to the edge of a segment are not used as a saved minute. +EDGE_GUARD_SECONDS: Final = 60 + + +class ChartChoiceInputError(ValueError): + """The request cannot be evaluated (bad date, window, or place).""" + + +@dataclass(frozen=True) +class ChartRequest: + birth_date: str # YYYY-MM-DD + lat: float + lon: float + tz: float + ayanamsa: str = "raman" + node_mode: str = "mean" + + +@dataclass +class Line: + at: datetime + degree: float # ascendant degree inside its sign where the line sits + flips: dict[str, tuple[int, int]] = field(default_factory=dict) # "D10": (before_sign, after_sign) + + +def chart_at(req: ChartRequest, at: datetime) -> dict[str, Any]: + return domain_calculation_service.compute_chart({ + "year": at.year, "month": at.month, "day": at.day, + "hour": at.hour, "minute": at.minute, "second": at.second, + "lat": req.lat, "lon": req.lon, "tz": req.tz, + "ayanamsa": req.ayanamsa, "node_mode": req.node_mode, + }) + + +def ascendant_longitude(req: ChartRequest, at: datetime) -> float: + return float(chart_at(req, at)["ascendant"]["lon"]) + + +def varga_sign(longitude: float, division: int) -> int: + if division == 1: + return int(longitude // 30) % 12 + return int(varga.calc_varga(longitude, division)["sign_idx"]) + + +def signature(longitude: float) -> dict[str, int]: + return {f"D{d}": varga_sign(longitude, d) for d in TRACKED_DIVISIONS} + + +def _unwrap(prev: float, cur: float) -> float: + while cur < prev - 180: + cur += 360 + return cur + + +def _crossing_degrees(lo: float, hi: float) -> list[float]: + """Every tracked division boundary strictly inside (lo, hi], unwrapped degrees.""" + found: set[float] = set() + for d in TRACKED_DIVISIONS: + part = 30.0 / d + k = int(lo // part) + 1 + while k * part <= hi + 1e-12: + found.add(round(k * part, 9)) + k += 1 + return sorted(found) + + +def _bisect_time(req: ChartRequest, t0: datetime, a0: float, t1: datetime, degree: float) -> datetime: + """Second-resolution time at which the (unwrapped) ascendant reaches ``degree``.""" + lo, hi = t0, t1 + while (hi - lo).total_seconds() > 1: + mid = lo + (hi - lo) / 2 + mid = mid.replace(microsecond=0) + if mid <= lo: + break + a = _unwrap(a0, ascendant_longitude(req, mid)) + if a < degree: + lo = mid + else: + hi = mid + return hi + + +def find_lines(req: ChartRequest, start: datetime, end: datetime) -> list[Line]: + """All dividing lines in [start, end], to the second.""" + if end <= start: + raise ChartChoiceInputError("window end must be after start") + lines: list[Line] = [] + t = start + a_prev = ascendant_longitude(req, t) + while t < end: + t_next = min(t + timedelta(minutes=1), end) + a_next = _unwrap(a_prev, ascendant_longitude(req, t_next)) + for deg in _crossing_degrees(a_prev, a_next): + at = _bisect_time(req, t, a_prev, t_next, deg) + before = signature((deg - 1e-6) % 360) + after = signature((deg + 1e-6) % 360) + flips = {k: (before[k], after[k]) for k in before if before[k] != after[k]} + if flips: + lines.append(Line(at=at, degree=round(deg % 30, 6), flips=flips)) + t, a_prev = t_next, a_next + return lines + + +def nearest_line(lines: list[Line], reported: datetime) -> Line | None: + if not lines: + return None + return min(lines, key=lambda line: abs((line.at - reported).total_seconds())) + + +def segment_around(lines: list[Line], line: Line, start: datetime, end: datetime) -> tuple[tuple[datetime, datetime], tuple[datetime, datetime]]: + """(before_segment, after_segment) bounded by the neighbouring lines and the window.""" + idx = lines.index(line) + prev_at = lines[idx - 1].at if idx > 0 else start + next_at = lines[idx + 1].at if idx + 1 < len(lines) else end + return (max(prev_at, start), line.at), (line.at, min(next_at, end)) + + +def representative_minute(segment: tuple[datetime, datetime], reported: datetime) -> datetime: + """Whole minute to save for a side. Keeps the reported time when it lies on that side.""" + lo, hi = segment + if lo <= reported < hi: + return reported + guard = timedelta(seconds=EDGE_GUARD_SECONDS) + inner_lo, inner_hi = lo + guard, hi - guard + if inner_hi < inner_lo: # very short segment: use its middle + inner_lo = inner_hi = lo + (hi - lo) / 2 + mid = inner_lo + (inner_hi - inner_lo) / 2 + minute = mid.replace(second=0, microsecond=0) + if minute < lo: + minute += timedelta(minutes=1) + if minute >= hi: + minute = (hi - timedelta(seconds=1)).replace(second=0, microsecond=0) + return minute diff --git a/scripts/chart_choice/decision.py b/scripts/chart_choice/decision.py new file mode 100644 index 00000000..9ec07e7f --- /dev/null +++ b/scripts/chart_choice/decision.py @@ -0,0 +1,57 @@ +"""Fixed rules that turn self-assessment answers into a chart choice. + +No scoring. The rules were agreed with the product owner on 2026-11-06: + +* When the reported time is within ``NEAR_LINE_MINUTES`` of the line, or is a + rounded time (minute divisible by 15), the reported time carries no real + preference, so the side with more answers wins. A tie keeps the reported side. +* Otherwise the reported side is kept unless at least ``OVERRIDE_VOTES`` answers + point to the other side and at most one answer points to the reported side. +* Domains whose answer points away from the chosen side are read in both versions. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Final, Literal + +Side = Literal["before", "after"] +NEAR_LINE_MINUTES: Final = 5 +OVERRIDE_VOTES: Final = 4 + + +def reported_is_neutral(reported: datetime, line_at: datetime, *, reported_known: bool) -> bool: + if not reported_known: + return True + if reported.minute % 15 == 0: + return True + return abs((reported - line_at).total_seconds()) < NEAR_LINE_MINUTES * 60 + + +def decide( + answers: dict[str, str], + *, + reported_side: Side, + neutral: bool, +) -> dict[str, Any]: + votes = {"before": 0, "after": 0, "unsure": 0} + for value in answers.values(): + votes[value if value in votes else "unsure"] += 1 + other: Side = "after" if reported_side == "before" else "before" + if neutral: + if votes[other] > votes[reported_side]: + chosen, rule = other, "majority" + elif votes[reported_side] > votes[other]: + chosen, rule = reported_side, "majority" + else: + chosen, rule = reported_side, "tie_keeps_reported" + elif votes[other] >= OVERRIDE_VOTES and votes[reported_side] <= 1: + chosen, rule = other, "strong_override" + else: + chosen, rule = reported_side, "reported_kept" + both_versions = sorted(domain for domain, value in answers.items() if value in ("before", "after") and value != chosen) + return { + "chosen_side": chosen, + "rule": rule, + "votes": votes, + "both_versions_domains": both_versions, + } diff --git a/scripts/chart_choice/readings.py b/scripts/chart_choice/readings.py new file mode 100644 index 00000000..74cf1503 --- /dev/null +++ b/scripts/chart_choice/readings.py @@ -0,0 +1,260 @@ +"""Plain-language differences between the two sides of a dividing line. + +Every sentence is derived from chart facts (sign of the divisional ascendant, +occupants of the domain houses, where the domain-house lords sit). The copy +states classical tendencies in modest words; it never claims that a life event +proves either side (BUG-1091). +""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Final + +_SCRIPTS = Path(__file__).resolve().parents[1] +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + +import varga # noqa: E402 + +SIGN_CN: Final = ("白羊", "金牛", "双子", "巨蟹", "狮子", "处女", "天秤", "天蝎", "射手", "摩羯", "水瓶", "双鱼") +SIGN_LORD: Final = ("Mars", "Venus", "Mercury", "Moon", "Sun", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Saturn", "Jupiter") +PLANETS: Final = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") +PLANET_CN: Final = { + "Sun": "太阳", "Moon": "月亮", "Mars": "火星", "Mercury": "水星", "Jupiter": "木星", + "Venus": "金星", "Saturn": "土星", "Rahu": "罗睺", "Ketu": "计都", +} + +# Domain per tracked chart, in the order questions are asked when more than five flip. +DOMAIN_ORDER: Final = ("D1", "D10", "D9", "D4", "D2", "D12", "D24") +DOMAIN: Final = { + "D1": {"key": "self", "label": "整体性格", "chart": "主盘 D1"}, + "D2": {"key": "money", "label": "钱财", "chart": "钱财盘 D2"}, + "D4": {"key": "home", "label": "住处", "chart": "住处盘 D4"}, + "D9": {"key": "relationship", "label": "婚恋", "chart": "婚恋盘 D9"}, + "D10": {"key": "career", "label": "事业", "chart": "事业盘 D10"}, + "D12": {"key": "parents", "label": "父母", "chart": "父母盘 D12"}, + "D24": {"key": "study", "label": "学业", "chart": "学业盘 D24"}, +} + +# What a planet brings to whichever domain house it occupies. +PLANET_TONE: Final = { + "Sun": "权威和管理,想出头、要面子", + "Moon": "和人打交道,变化多、投入感情", + "Mars": "竞争和技术,行动快、敢拼", + "Mercury": "沟通、商业、文字和数据", + "Jupiter": "大平台和贵人,重学识、有分寸", + "Venus": "审美、设计和人缘,讲究舒适", + "Saturn": "慢热、担责任、能坚持,早年辛苦、后劲足", + "Rahu": "野心大、不走寻常路,和外来或新兴的东西有缘", + "Ketu": "专精、抽离,不太在乎外界认可", +} + +# Where the key lord of a domain sits, read for that domain. Index = house 1..12. +LORD_IN_HOUSE: Final = { + "career": ( + "事业和自我绑在一起,靠个人特色和自己做主", + "事业和钱、家族或口才有关,重视收入稳定", + "路多半是自己闯出来的,靠主动、表达、写说做内容", + "工作和家、住处分不太开,适合和生活方式有关的行业", + "靠专长、兴趣和脑力吃饭,偏技术、研究、创作或教育", + "在竞争和解决问题里成长,适合服务、专业技术或要拼的岗位", + "事业靠合作、客户和对外打交道推动", + "事业有转折和起伏,适合幕后、研究或需要深挖的工作", + "遇到导师、去远方、读更高的学历会推动事业", + "事业心强,容易站到显眼的位置上", + "机会多来自人脉和圈子,收入导向明显", + "事业和外地、外企或幕后有关,常要离开熟悉的环境", + ), + "home": ( + "对住的地方有主见,住处跟着自己的选择走", + "住处和家里的钱、家族资源有关", + "搬动多,但多在同一城市或熟悉的范围里", + "住处安稳,和老家、家人的牵连深", + "住的地方和兴趣、学习或孩子有关", + "住处容易有琐碎的麻烦,要花心思打理", + "住处的变化常因为伴侣或合作的人", + "住处偶尔会有突然的变化", + "住处和远方、求学有关,可能为了深造搬家", + "人跟着工作走,为了事业定居", + "住处和朋友圈、收益有关,住得热闹", + "长期在出生地以外生活,心里有离乡感", + ), + "money": (), # D2 uses the hora rule, not house lords + "relationship": ( + "感情里很看重自我,伴侣和自己性格相近", + "伴侣和家庭、钱财观念紧密相关", + "感情从交流开始,伴侣可能是同学、同事或邻近的人", + "感情和家庭绑在一起,重视安稳", + "感情从兴趣和浪漫开始,重视心动的感觉", + "感情里有磨合和要处理的问题", + "重视伴侣本身,关系是生活的重心", + "感情有深度也有起伏,容易有转折", + "伴侣可能来自远方,或带着学识、信念上的影响", + "伴侣和事业、社会地位相关", + "感情从朋友圈里来,重视共同的圈子", + "感情和远方或私密的一面有关,可能异地", + ), + "parents": ( + "和这位家长性格相近,影响直接", + "这位家长和家里的钱、家族传承关系大", + "这位家长的支持是鼓励你自己去闯", + "这位家长和家庭、住处的牵连深", + "这位家长很看重你的学习和兴趣", + "和这位家长之间有要磨合的地方", + "这位家长的影响通过合作或对外关系体现", + "和这位家长的关系有深度也有变化", + "这位家长像导师,带给你信念和见识", + "这位家长在事业或社会上有分量", + "这位家长对你帮助大,带来实际的支持", + "和这位家长之间有距离,可能分开生活", + ), + "study": ( + "学习靠自己,主动性强", + "学的东西和挣钱、实用技能有关", + "学习靠多交流、多动手,读写表达好", + "学习需要安稳的环境,和家里的支持有关", + "学习从兴趣出发,专长明显", + "学业靠考试和拼劲,在竞争里出成绩", + "学习和合作、老师或同伴关系大", + "学业有停顿或转向,偏研究和深层的东西", + "深造运好,容易去远方读书", + "学的东西后来直接用在工作上,偏实用", + "学习带来实际收获和人脉", + "深造多半在外地,或者来得晚", + ), + "self": ( + "性格以自我为中心,有主见", + "看重钱和家里,说话有分量", + "主动、爱表达,靠自己闯", + "恋家,内心需要安全感", + "重兴趣和创造,脑子活", + "能吃苦,在磨炼里成长", + "以人为镜,重视伴侣和合作", + "经历转折多,喜欢探究深层的东西", + "有信念,容易遇到贵人", + "事业心重,看重地位", + "重视圈子和收获", + "向往远方,有离乡或内省的一面", + ), +} + +# (domain houses whose occupants matter, house whose lord is read, label of that lord) +DOMAIN_FOCUS: Final = { + "career": ((10, 1), 10, "管事业的星"), + "home": ((4, 12), 4, "管住处的星"), + "relationship": ((7, 1), 7, "管伴侣的星"), + "parents": ((4, 9), 4, "管母亲的星"), + "study": ((4, 5, 9), 9, "管深造的星"), + "self": ((1,), 1, "命主星"), +} +HOUSE_NAME: Final = { + 1: "命位", 4: "家的位置", 5: "专长的位置", 7: "伴侣的位置", 9: "远方和贵人的位置", + 10: "事业位", 12: "离乡的位置", +} +# Second lord read for parents (father) and study (the learner itself). +EXTRA_LORD: Final = { + "parents": (9, "管父亲的星"), + "study": (1, "代表学习者自己的星"), + "career": (1, "代表你自己的星"), + "home": (1, "代表你自己的星"), + "relationship": (1, "代表你自己的星"), +} +# Which person a parents sentence talks about, by the house whose lord is read. +PARENT_BY_HOUSE: Final = {4: "母亲", 9: "父亲"} + +HORA_TEXT: Final = { + 4: ("太阳这一半", "钱更多来自自己出头、拿职位、担责任,在体系里往上走,来得慢但稳"), + 3: ("月亮这一半", "钱更多来自和人打交道、做买卖、做内容或和审美有关的东西,来得灵活、零散"), +} + + +def varga_chart(planet_longitudes: dict[str, float], ascendant_longitude: float, division: int) -> dict[str, int]: + """Sign index of the ascendant and each planet in one divisional chart.""" + if division == 1: + out = {"Ascendant": int(ascendant_longitude // 30) % 12} + out.update({p: int(planet_longitudes[p] // 30) % 12 for p in PLANETS if p in planet_longitudes}) + return out + out = {"Ascendant": int(varga.calc_varga(ascendant_longitude, division)["sign_idx"])} + for p in PLANETS: + if p in planet_longitudes: + out[p] = int(varga.calc_varga(planet_longitudes[p], division)["sign_idx"]) + return out + + +def _house(chart: dict[str, int], sign: int) -> int: + return (sign - chart["Ascendant"]) % 12 + 1 + + +def _occupants(chart: dict[str, int], house: int) -> list[str]: + return [p for p in PLANETS if p in chart and _house(chart, chart[p]) == house] + + +def _lord_of(chart: dict[str, int], house: int) -> str: + return SIGN_LORD[(chart["Ascendant"] + house - 1) % 12] + + +def describe(chart_key: str, chart: dict[str, int]) -> dict[str, Any]: + """Facts plus plain sentences for one side of one divisional chart.""" + meta = DOMAIN[chart_key] + domain = meta["key"] + asc = chart["Ascendant"] + points: list[dict[str, str]] = [] + if chart_key == "D2": + half, text = HORA_TEXT.get(asc, ("", "")) + own = [PLANET_CN[p] for p in PLANETS if chart.get(p) == asc] + detail = f"{'、'.join(own)}在你这一半。" if own else "" + points.append({"kind": "hora", "fact": f"上升落在{half}。{detail}", "meaning": text}) + return {"chart": chart_key, "domain": domain, "label": meta["label"], "chart_label": meta["chart"], + "ascendant": SIGN_CN[asc], "points": points} + + houses, lord_house, lord_label = DOMAIN_FOCUS[domain] + for house in houses: + for planet in _occupants(chart, house): + points.append({"kind": "occupant", "fact": f"{PLANET_CN[planet]}在{HOUSE_NAME.get(house, f'第 {house} 宫')}", + "meaning": PLANET_TONE[planet]}) + lord_reads = [(lord_house, lord_label)] + if domain in EXTRA_LORD: + lord_reads.append(EXTRA_LORD[domain]) + seen: set[str] = set() + for house, label in lord_reads: + lord = _lord_of(chart, house) + if lord in seen or lord not in chart: + continue + seen.add(lord) + where = _house(chart, chart[lord]) + table = LORD_IN_HOUSE["self"] if (domain == "career" and house == 1) else LORD_IN_HOUSE[domain] + meaning = table[where - 1] + if domain == "parents": + meaning = meaning.replace("这位家长", PARENT_BY_HOUSE[house]) + points.append({"kind": "lord", "fact": f"{label}({PLANET_CN[lord]})落在第 {where} 宫", "meaning": meaning}) + return {"chart": chart_key, "domain": domain, "label": meta["label"], "chart_label": meta["chart"], + "ascendant": SIGN_CN[asc], "points": points} + + +def _option_text(own: dict[str, Any], other: dict[str, Any]) -> str: + """Up to two lord readings that differ from the other side, else the first differing occupant tone.""" + other_meanings = {p["meaning"] for p in other["points"]} + lords = [p["meaning"] for p in own["points"] if p["kind"] == "lord" and p["meaning"] not in other_meanings] + if lords: + return ";".join(lords[:2]) + tones = [p for p in own["points"] if p["kind"] in ("occupant", "hora") and p["meaning"] not in other_meanings] + return tones[0]["meaning"] if tones else "" + + +def build_question(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any] | None: + """One self-assessment question for a flipped chart, or None when both sides read alike.""" + a, b = _option_text(before, after), _option_text(after, before) + if not a or not b or a == b: + return None + return { + "domain": before["domain"], + "chart": before["chart"], + "label": before["label"], + "prompt": f"你的{before['label']},更像哪一种?", + "options": [ + {"value": "before", "text": a}, + {"value": "after", "text": b}, + {"value": "unsure", "text": "说不清"}, + ], + } diff --git a/scripts/chart_choice/service.py b/scripts/chart_choice/service.py new file mode 100644 index 00000000..60ebf31c --- /dev/null +++ b/scripts/chart_choice/service.py @@ -0,0 +1,214 @@ +"""Stateless chart-choice service used by the API server. + +Two calls, both recomputed from the same inputs so the browser never supplies +a minute the server did not derive: + +* ``build_packet(body)`` -> narrowing question, "certain", or the choice packet +* ``decide_packet(body)`` -> the packet plus the decision for ``body["answers"]`` +""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Final + +from scripts.chart_choice.boundaries import ( + MAX_WINDOW_MINUTES, + ChartChoiceInputError, + ChartRequest, + chart_at, + find_lines, + nearest_line, + representative_minute, + segment_around, +) +from scripts.chart_choice.decision import decide, reported_is_neutral +from scripts.chart_choice.readings import DOMAIN, DOMAIN_ORDER, build_question, describe, varga_chart + +ENGINE_VERSION: Final = "chart-choice-v1" +MAX_QUESTIONS: Final = 5 +ALL_CHARTS: Final = ("D1", "D2", "D4", "D9", "D10", "D12", "D24") + + +def _parse_at(value: Any, field: str) -> datetime: + if not isinstance(value, str): + raise ChartChoiceInputError(f"{field} must be YYYY-MM-DDTHH:MM") + try: + return datetime.strptime(value[:16], "%Y-%m-%dT%H:%M") + except ValueError as exc: + raise ChartChoiceInputError(f"{field} must be YYYY-MM-DDTHH:MM") from exc + + +def _request(body: dict[str, Any]) -> tuple[ChartRequest, datetime, datetime, datetime, bool]: + try: + lat, lon, tz = float(body["lat"]), float(body["lon"]), float(body["tz"]) + except (KeyError, TypeError, ValueError) as exc: + raise ChartChoiceInputError("lat, lon and tz are required") from exc + if not (-90 <= lat <= 90 and -180 <= lon <= 180 and -14 <= tz <= 14): + raise ChartChoiceInputError("lat, lon or tz out of range") + start = _parse_at(body.get("start_at"), "start_at") + end = _parse_at(body.get("end_at"), "end_at") + if end <= start: + raise ChartChoiceInputError("end_at must be after start_at") + if end - start > timedelta(hours=24): + raise ChartChoiceInputError("window longer than 24 hours") + reported_known = isinstance(body.get("reported_at"), str) and bool(body.get("reported_at")) + reported = _parse_at(body["reported_at"], "reported_at") if reported_known else start + (end - start) / 2 + reported = reported.replace(second=0, microsecond=0) + if not start <= reported <= end: + reported_known = False + reported = (start + (end - start) / 2).replace(second=0, microsecond=0) + ayanamsa = str(body.get("ayanamsa") or "raman") + node_mode = str(body.get("node_mode") or "mean") + if node_mode not in ("mean", "true"): + raise ChartChoiceInputError("node_mode must be mean or true") + req = ChartRequest(birth_date=reported.date().isoformat(), lat=lat, lon=lon, tz=tz, + ayanamsa=ayanamsa, node_mode=node_mode) + return req, start, end, reported, reported_known + + +def _at(at: datetime) -> str: + return at.strftime("%Y-%m-%dT%H:%M") + + +def _at_s(at: datetime) -> str: + return at.strftime("%Y-%m-%dT%H:%M:%S") + + +def _hhmm(at: datetime) -> str: + return at.strftime("%H:%M") + + +def _sunrise_local_naive(date_iso: str, lat: float, lon: float, tz: float) -> datetime | None: + """Real sunrise in local clock time; None for polar or unavailable cases (never invent 06:00).""" + try: + import saham_daynight + import swisseph as swe + + noon = datetime.fromisoformat(f"{date_iso}T12:00:00") + row = saham_daynight.determine_daytime(noon, lat=lat, lon=lon, tz=tz) + year, month, day, ut_hours = swe.revjul(float(row["sunrise_jd_ut"])) + return datetime(int(year), int(month), int(day)) + timedelta(hours=float(ut_hours) + float(tz)) + except Exception: # noqa: BLE001 - any failure means "no sunrise anchor", the halves fallback is used + return None + + +def _narrowing(req: ChartRequest, start: datetime, end: datetime) -> dict[str, Any]: + """Split a wide window in two, at sunrise when sunrise sits well inside it.""" + split = None + anchor = "half" + sunrise = _sunrise_local_naive(req.birth_date, req.lat, req.lon, req.tz) + if sunrise is not None: + sunrise = sunrise.replace(second=0, microsecond=0) + margin = timedelta(minutes=20) + if start + margin <= sunrise <= end - margin: + split, anchor = sunrise, "sunrise" + if split is None: + split = (start + (end - start) / 2).replace(second=0, microsecond=0) + if anchor == "sunrise": + prompt = f"你出生时,天已经亮了吗?(当天日出约 {_hhmm(split)})" + labels = ("天还没亮", "天已经亮了") + else: + prompt = "家里人说起你出生的时间,更靠近哪一段?" + labels = (f"{_hhmm(start)}–{_hhmm(split)}", f"{_hhmm(split)}–{_hhmm(end)}") + return { + "anchor": anchor, + "prompt": prompt, + "options": [ + {"text": labels[0], "start_at": _at(start), "end_at": _at(split)}, + {"text": labels[1], "start_at": _at(split), "end_at": _at(end)}, + ], + } + + +def _planets_and_asc(req: ChartRequest, at: datetime) -> tuple[dict[str, float], float]: + chart = chart_at(req, at) + planets = { + name: float(data["lon"]) for name, data in chart.get("planets", {}).items() + if isinstance(data, dict) and isinstance(data.get("lon"), int | float) + } + return planets, float(chart["ascendant"]["lon"]) + + +def build_packet(body: dict[str, Any]) -> dict[str, Any]: + req, start, end, reported, reported_known = _request(body) + base = { + "engine_version": ENGINE_VERSION, + "window": {"start_at": _at(start), "end_at": _at(end)}, + "reported_at": _at(reported), + "reported_known": reported_known, + "ayanamsa": req.ayanamsa, + "node_mode": req.node_mode, + } + if (end - start) > timedelta(minutes=MAX_WINDOW_MINUTES): + return {**base, "status": "needs_narrowing", "narrowing": _narrowing(req, start, end)} + + lines = find_lines(req, start, end) + line = nearest_line(lines, reported) + if line is None: + return {**base, "status": "certain", "saved_at": _at(reported)} + + before_seg, after_seg = segment_around(lines, line, start, end) + before_min = representative_minute(before_seg, reported) + after_min = representative_minute(after_seg, reported) + reported_side = "before" if reported < line.at else "after" + + flipped = [key for key in DOMAIN_ORDER if key in line.flips] + unchanged = [key for key in DOMAIN_ORDER if key not in line.flips] + pb, ab = _planets_and_asc(req, before_min) + pa, aa = _planets_and_asc(req, after_min) + comparisons, questions = [], [] + for key in flipped: + division = int(key[1:]) + before = describe(key, varga_chart(pb, ab, division)) + after = describe(key, varga_chart(pa, aa, division)) + comparisons.append({"chart": key, "label": DOMAIN[key]["label"], "chart_label": DOMAIN[key]["chart"], + "before": before, "after": after}) + if len(questions) < MAX_QUESTIONS and (q := build_question(before, after)): + questions.append(q) + + return { + **base, + "status": "choose", + "line": {"at": _at_s(line.at), "degree": line.degree, "charts": flipped}, + "other_lines_in_window": len(lines) - 1, + "reported_side": reported_side, + "reported_neutral": reported_is_neutral(reported, line.at, reported_known=reported_known), + "seconds_from_line": int(abs((reported - line.at).total_seconds())), + "sides": { + "before": {"from": _at_s(before_seg[0]), "to": _at_s(before_seg[1]), "saved_at": _at(before_min)}, + "after": {"from": _at_s(after_seg[0]), "to": _at_s(after_seg[1]), "saved_at": _at(after_min)}, + }, + "comparisons": comparisons, + "unchanged": [{"chart": k, "label": DOMAIN[k]["label"]} for k in unchanged], + "questions": questions, + } + + +def decide_packet(body: dict[str, Any]) -> dict[str, Any]: + packet = build_packet(body) + if packet["status"] != "choose": + raise ChartChoiceInputError(f"no choice to make (status {packet['status']})") + raw = body.get("answers") or {} + if not isinstance(raw, dict): + raise ChartChoiceInputError("answers must be an object") + asked = {q["domain"] for q in packet["questions"]} + answers = {k: str(v) for k, v in raw.items() if k in asked and v in ("before", "after", "unsure")} + override = body.get("override_side") + if override in ("before", "after"): + decision = {"chosen_side": override, "rule": "user_override", + "votes": None, "both_versions_domains": []} + else: + decision = decide(answers, reported_side=packet["reported_side"], neutral=packet["reported_neutral"]) + if body.get("read_both_all"): + decision["both_versions_domains"] = sorted(c["before"]["domain"] for c in packet["comparisons"]) + side = decision["chosen_side"] + labels = {c["before"]["domain"]: c["label"] for c in packet["comparisons"]} + return { + **packet, + "answers": answers, + "decision": { + **decision, + "saved_at": packet["sides"][side]["saved_at"], + "both_versions": [{"domain": d, "label": labels.get(d, d)} for d in decision["both_versions_domains"]], + }, + } diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index a4d51300..0dc2d98e 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -10722,6 +10722,14 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService): elif path == '/api/active_rectification_events_v4': result = self._compute_active_rectification_events_v4(body) self._json(result) + elif path in ('/api/chart_choice/packet', '/api/chart_choice/decide'): + from scripts.chart_choice.boundaries import ChartChoiceInputError + from scripts.chart_choice.service import build_packet, decide_packet + try: + handler = build_packet if path.endswith('/packet') else decide_packet + self._json(handler(body if isinstance(body, dict) else {})) + except ChartChoiceInputError as exc: + raise BadRequest(str(exc)) from exc elif path == '/api/rectification/v5/varga-segments': _load_local_module('rectification_varga_segments_api').handle_varga_segments(self, body) elif path == '/api/rectification/v5/candidate-features': diff --git a/tests/test_chart_choice.py b/tests/test_chart_choice.py new file mode 100644 index 00000000..838935cd --- /dev/null +++ b/tests/test_chart_choice.py @@ -0,0 +1,156 @@ +"""Chart choice engine: dividing lines, readings, and the agreed decision rules.""" +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts")) + +from scripts.chart_choice.boundaries import ( # noqa: E402 + ChartChoiceInputError, + ChartRequest, + find_lines, + nearest_line, + representative_minute, + segment_around, + signature, +) +from scripts.chart_choice.decision import decide, reported_is_neutral # noqa: E402 +from scripts.chart_choice.service import build_packet, decide_packet # noqa: E402 + +# Fictional reference input (not a user record): mid-latitude north China, early morning. +BODY = { + "lat": 36.42, "lon": 114.21, "tz": 8, + "start_at": "1997-08-08T04:45", "end_at": "1997-08-08T05:15", "reported_at": "1997-08-08T05:00", +} +REQ = ChartRequest("1997-08-08", 36.42, 114.21, 8.0) + + +def test_lines_are_exact_and_merge_charts_that_flip_together(): + lines = find_lines(REQ, datetime(1997, 8, 8, 4, 45), datetime(1997, 8, 8, 5, 15)) + fifteen = next(line for line in lines if line.degree == 15.0) + assert set(fifteen.flips) == {"D2", "D4", "D10", "D12", "D24"} + assert fifteen.at.strftime("%H:%M") == "04:59" + assert any("D9" in line.flips for line in lines), "D9 lines must not be lost to float rounding" + for line in lines: + before = signature((line.degree - 1e-4) + 90) # Cancer: sign index 3 → 90° + after = signature((line.degree + 1e-4) + 90) + assert {k for k in before if before[k] != after[k]} == set(line.flips) + + +def test_nearest_line_and_segments(): + start, end = datetime(1997, 8, 8, 4, 45), datetime(1997, 8, 8, 5, 15) + lines = find_lines(REQ, start, end) + reported = datetime(1997, 8, 8, 5, 0) + line = nearest_line(lines, reported) + assert line.degree == 15.0 + before, after = segment_around(lines, line, start, end) + assert before[1] == after[0] == line.at + assert representative_minute(after, reported) == reported + saved_before = representative_minute(before, reported) + assert before[0] < saved_before < before[1] + assert (before[1] - saved_before).total_seconds() >= 60 + + +def test_packet_lists_only_charts_that_flip_and_unchanged_ones(): + packet = build_packet(BODY) + assert packet["status"] == "choose" + assert packet["line"]["charts"] == ["D10", "D4", "D2", "D12", "D24"] + assert [u["chart"] for u in packet["unchanged"]] == ["D1", "D9"] + assert packet["reported_side"] == "after" + assert packet["reported_neutral"] is True + assert 1 <= len(packet["questions"]) <= 5 + for question in packet["questions"]: + texts = [o["text"] for o in question["options"]] + assert texts[0] != texts[1] and texts[2] == "说不清" + + +def test_decision_majority_when_reported_time_is_neutral(): + answers = {"career": "before", "home": "after", "money": "before", "parents": "before", "study": "after"} + result = decide_packet({**BODY, "answers": answers})["decision"] + assert result["chosen_side"] == "before" + assert result["rule"] == "majority" + assert result["both_versions_domains"] == ["home", "study"] + assert result["saved_at"] == "1997-08-08T04:56" + + +def test_decision_rules(): + assert decide({"a": "before", "b": "after"}, reported_side="after", neutral=True)["rule"] == "tie_keeps_reported" + kept = decide({"a": "before", "b": "before", "c": "before", "d": "after"}, reported_side="after", neutral=False) + assert kept["chosen_side"] == "after" and kept["rule"] == "reported_kept" + flipped = decide({"a": "before", "b": "before", "c": "before", "d": "before", "e": "after"}, + reported_side="after", neutral=False) + assert flipped["chosen_side"] == "before" and flipped["rule"] == "strong_override" + assert flipped["both_versions_domains"] == ["e"] + unsure = decide({"a": "unsure"}, reported_side="before", neutral=True) + assert unsure["chosen_side"] == "before" and unsure["both_versions_domains"] == [] + + +def test_reported_neutral_rules(): + line = datetime(1997, 8, 8, 5, 20, 30) + assert reported_is_neutral(datetime(1997, 8, 8, 5, 30), line, reported_known=True) # rounded + assert reported_is_neutral(datetime(1997, 8, 8, 5, 22), line, reported_known=True) # near line + assert not reported_is_neutral(datetime(1997, 8, 8, 5, 37), line, reported_known=True) + assert reported_is_neutral(datetime(1997, 8, 8, 5, 37), line, reported_known=False) + + +def test_override_and_read_both(): + result = decide_packet({**BODY, "override_side": "before", "read_both_all": True})["decision"] + assert result["chosen_side"] == "before" and result["rule"] == "user_override" + assert set(result["both_versions_domains"]) == {"career", "home", "money", "parents", "study"} + + +def test_answers_for_unasked_domains_are_ignored(): + result = decide_packet({**BODY, "answers": {"relationship": "before", "self": "before"}}) + assert result["answers"] == {} + assert result["decision"]["chosen_side"] == "after" + + +def test_wide_window_asks_to_narrow_first(): + packet = build_packet({**BODY, "start_at": "1997-08-08T03:00", "end_at": "1997-08-08T08:00"}) + assert packet["status"] == "needs_narrowing" + options = packet["narrowing"]["options"] + assert options[0]["start_at"] == "1997-08-08T03:00" and options[1]["end_at"] == "1997-08-08T08:00" + assert options[0]["end_at"] == options[1]["start_at"] + assert packet["narrowing"]["anchor"] == "sunrise" # sunrise falls inside 03:00–08:00 here + + +def test_no_line_means_certain(): + packet = build_packet({**BODY, "start_at": "1997-08-08T04:55", "end_at": "1997-08-08T04:58", "reported_at": "1997-08-08T04:56"}) + assert packet["status"] == "certain" and packet["saved_at"] == "1997-08-08T04:56" + + +def test_reported_outside_window_falls_back_to_middle(): + packet = build_packet({**BODY, "reported_at": "1997-08-08T06:00"}) + assert packet["reported_known"] is False and packet["reported_at"] == "1997-08-08T05:00" + + +@pytest.mark.parametrize("patch", [ + {"start_at": "1997-08-08T05:15", "end_at": "1997-08-08T04:45"}, + {"lat": 120}, + {"start_at": None}, + {"start_at": "5pm"}, + {"end_at": "1997-08-10T05:15"}, + {"node_mode": "osculating"}, +]) +def test_bad_input(patch): + with pytest.raises(ChartChoiceInputError): + build_packet({**BODY, **patch}) + + +def test_decide_requires_a_choice(): + with pytest.raises(ChartChoiceInputError): + decide_packet({**BODY, "start_at": "1997-08-08T04:55", "end_at": "1997-08-08T04:58", "reported_at": "1997-08-08T04:56"}) + + +def test_window_across_midnight(): + packet = build_packet({**BODY, "start_at": "1997-08-07T23:40", "end_at": "1997-08-08T00:20", + "reported_at": "1997-08-08T00:00"}) + assert packet["status"] in ("choose", "certain") + if packet["status"] == "choose": + assert packet["sides"]["before"]["from"] >= "1997-08-07T23:40"