feat: chart choice replaces event-scored rectification page

Event scoring was measured at chance level on the 77 Rodden-AA holdout
cases (BUG-1091). The new flow finds the dividing line nearest the reported
time, describes how the flipped divisional charts read on either side, asks
up to five self-assessment questions, and saves the user's own choice
(provenance user_choice) with the domains to read in both versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXozbkyFNqbu8pVEtZ7W
This commit is contained in:
2026-11-06 02:06:17 +08:00
co-authored by Claude Opus 5.5
parent 945e86911a
commit 97afd1716a
20 changed files with 2121 additions and 3 deletions
@@ -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 <RectificationEntryPage />;
return <ChartChoicePage />;
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
+174
View File
@@ -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; }
}
+1
View File
@@ -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";
@@ -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<ChartChoicePacket, { status: "choose" }>;
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<ChartChoicePacket, { status: "needs_narrowing" }> }
| { kind: "certain"; packet: Extract<ChartChoicePacket, { status: "certain" }>; 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<T>(url: string, body: unknown): Promise<T> {
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 <section className={cn("chart-choice-card", className)}>{children}</section>;
}
function Actions({ children }: { readonly children: ReactNode }) {
return <div className="chart-choice-actions">{children}</div>;
}
function Primary(props: { readonly onClick: () => void; readonly disabled?: boolean; readonly children: ReactNode }) {
return <Button type="button" className="chart-choice-primary" disabled={props.disabled} onClick={props.onClick}>{props.children}</Button>;
}
function Secondary(props: { readonly onClick: () => void; readonly disabled?: boolean; readonly children: ReactNode }) {
return <Button type="button" variant="outline" disabled={props.disabled} onClick={props.onClick}>{props.children}</Button>;
}
export function ChartChoicePage() {
const subjectId = useCurrentSubject();
const subjectReady = useCurrentSubjectReady();
const [step, setStep] = useState<Step>({ kind: "loading" });
const [window, setWindow] = useState<ChartChoiceWindow | undefined>(undefined);
const [answers, setAnswers] = useState<Record<string, ChartChoiceAnswer>>({});
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<ChartChoicePacket>("/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<ChartChoicePacket, { status: "certain" }>) => {
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 (
<SecondaryPageShell title={COPY.title} waiting={subjectReady ? undefined : "正在打开…"}>
<div className="chart-choice-page">
<p className="chart-choice-intro">{COPY.intro}</p>
{renderStep()}
<p className="chart-choice-honest">{COPY.honest}</p>
</div>
</SecondaryPageShell>
);
function renderStep(): ReactNode {
switch (step.kind) {
case "loading":
case "packet_loading":
return <Card className="is-waiting"><p className="chart-choice-note" role="status">正在计算你填的时间附近的分界线…</p></Card>;
case "failed":
return (
<Card>
<p className="chart-choice-note" role="alert">{step.message}</p>
<Actions><Secondary onClick={step.retry}>重试</Secondary></Actions>
</Card>
);
case "unavailable":
return (
<Card>
<p className="chart-choice-note">
{step.reason === "profile_incomplete" ? "出生资料还不完整。先补全出生日期、出生时间(或大概时段)和出生地点。" : "这份人物资料不存在,或不属于当前账户。"}
</p>
{step.reason === "profile_incomplete"
? <Actions><AppLink href={COPY.profileHref(subjectId)} className="chart-choice-link">去补全资料</AppLink></Actions>
: null}
</Card>
);
case "pick_range":
return (
<Card>
<h2 className="chart-choice-heading">你填的是 {clockLabel(step.reportedAt)},这个时间大概准到什么程度?</h2>
<div className="chart-choice-options" role="group" aria-label="时间的把握">
{RECORD_RANGE_CHOICES.map((minutes) => (
<Secondary key={minutes} onClick={() => void loadPacket(windowAround(step.reportedAt, minutes))}>
{minutes === 5 ? "很准,前后 5 分钟内" : minutes === 60 ? "前后一小时" : `前后 ${minutes} 分钟`}
</Secondary>
))}
</div>
</Card>
);
case "narrow":
return (
<Card>
<p className="chart-choice-note">
你给的范围是 {rangeLabel(step.packet.window)},有点宽,里面的分界线太多。先把范围缩小一点,再开始比较。
</p>
<h2 className="chart-choice-heading">{step.packet.narrowing.prompt}</h2>
<div className="chart-choice-options" role="group" aria-label="缩小范围">
{step.packet.narrowing.options.map((option) => (
<Secondary key={option.start_at} onClick={() => void loadPacket({ start_at: option.start_at, end_at: option.end_at })}>
{option.text}
</Secondary>
))}
</div>
<p className="chart-choice-fine">实在记不清的话可以先不选,照你原来填的资料解读,之后想起来再回来。</p>
</Card>
);
case "certain":
return (
<Card>
<h2 className="chart-choice-heading">
{rangeLabel(step.packet.window)} 这个范围里,你的主盘和分盘都一样,不需要选。
</h2>
<p className="chart-choice-note">解读会用 {clockLabel(step.packet.saved_at)}。</p>
{step.saved
? <p className="chart-choice-note" role="status">已保存。</p>
: step.packet.reported_known
? null
: <Actions><Primary onClick={() => void saveCertain(step.packet)}>用这个时间</Primary></Actions>}
</Card>
);
case "choose":
return <ChooseStep packet={step.packet} answers={answers} onAnswer={(domain, value) => setAnswers((prev) => ({ ...prev, [domain]: value }))} onNext={() => void runDecision(step.packet, "preview")} />;
case "result":
return (
<ResultStep
packet={step.packet}
decision={step.decision}
saving={step.saving}
onAccept={() => 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 (
<Card>
<h2 className="chart-choice-heading">已保存:用 {clockLabel(step.decision.saved_at)}({SIDE_TEXT[step.decision.chosen_side]})这张盘。</h2>
{step.decision.both_versions.length ? (
<p className="chart-choice-note">
咨询 {step.decision.both_versions.map((d) => d.label).join("、")} 时,会把两种看法都写出来。
</p>
) : null}
<p className="chart-choice-fine">这是你自己的选择,不是验证出来的出生分钟。想改随时可以回来重新选。</p>
<Actions>
<AppLink href="/chart" className="chart-choice-link">看星盘</AppLink>
<AppLink href="/" className="chart-choice-link is-primary">去咨询</AppLink>
</Actions>
</Card>
);
}
}
}
function ChooseStep({
packet,
answers,
onAnswer,
onNext,
}: {
readonly packet: Choose;
readonly answers: Readonly<Record<string, ChartChoiceAnswer>>;
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 (
<>
<Card>
<h2 className="chart-choice-heading">
你填的 {clockLabel(packet.reported_at)} 离一条分界线只有 {distanceText(packet.seconds_from_line)}。
</h2>
<p className="chart-choice-note">
{clockLabel(packet.line.at, ref, true)} 前后,{flipped}这几张盘会一起换星座。你的主盘里,
{packet.unchanged.length ? `${packet.unchanged.map((u) => u.label).join("、")}这几块两边一样,不受影响。` : "所有分盘都会受影响。"}
</p>
<dl className="chart-choice-sides">
{(["before", "after"] as const).map((side) => (
<div key={side} className={cn(packet.reported_side === side && "is-reported")}>
<dt>{SIDE_TEXT[side]}{packet.reported_side === side ? "(你填的时间在这边)" : ""}</dt>
<dd>{clockLabel(packet.sides[side].from, ref)}–{clockLabel(packet.sides[side].to, ref)}</dd>
</div>
))}
</dl>
{packet.other_lines_in_window > 0 ? (
<p className="chart-choice-fine">你给的范围里还有 {packet.other_lines_in_window} 条分界线,离你填的时间更远,这次只比较最近的这一条。</p>
) : null}
</Card>
{packet.comparisons.map((comparison) => (
<Card key={comparison.chart} className="is-compare">
<h3 className="chart-choice-subheading">{comparison.label}<span>{comparison.chart_label}</span></h3>
<div className="chart-choice-compare">
{(["before", "after"] as const).map((side) => (
<div key={side} className="chart-choice-column">
<p className="chart-choice-column-title">{SIDE_TEXT[side]} · {comparison[side].ascendant}</p>
<ul>
{comparison[side].points.map((point) => (
<li key={point.fact}>
<span className="chart-choice-fact">{point.fact}</span>
<span>{point.meaning}</span>
</li>
))}
</ul>
</div>
))}
</div>
</Card>
))}
{packet.questions.length ? (
<Card>
<h2 className="chart-choice-heading">按你自己的感觉选,哪一边更像你?</h2>
<p className="chart-choice-fine">每题都可以选「说不清」。回答只记作你的选择,不会被写成验证结果。</p>
<ol className="chart-choice-questions">
{packet.questions.map((question) => (
<li key={question.domain}>
<p className="chart-choice-question">{question.prompt}</p>
<div className="chart-choice-answers" role="radiogroup" aria-label={question.prompt}>
{question.options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={answers[question.domain] === option.value}
className={cn("chart-choice-answer", answers[question.domain] === option.value && "is-selected")}
onClick={() => onAnswer(question.domain, option.value)}
>
{option.text}
</button>
))}
</div>
</li>
))}
</ol>
<Actions><Primary onClick={onNext}>看结果</Primary></Actions>
</Card>
) : (
<Card><Actions><Primary onClick={onNext}>看结果</Primary></Actions></Card>
)}
</>
);
}
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 (
<Card>
<h2 className="chart-choice-heading">
用哪张盘:{SIDE_TEXT[chosen]},保存为 {clockLabel(decision.saved_at, packet.reported_at)}
{decision.saved_at === packet.reported_at ? "(你填的时间)" : ""}
</h2>
{votes ? (
<p className="chart-choice-note">
你的回答里 {votes.before} 题更像线前,{votes.after} 题更像线后{votes.unsure ? `,${votes.unsure} 题说不清` : ""}。{RULE_TEXT[decision.rule]}
</p>
) : <p className="chart-choice-note">{RULE_TEXT[decision.rule]}</p>}
<table className="chart-choice-table">
<thead><tr><th scope="col">领域</th><th scope="col">之后怎么读</th></tr></thead>
<tbody>
{packet.unchanged.length ? (
<tr><td>{packet.unchanged.map((u) => u.label).join("、")}</td><td>两边一样,正常读</td></tr>
) : null}
{packet.comparisons.map((c) => (
<tr key={c.chart} className={cn(both.has(c.before.domain) && "is-both")}>
<td>{c.label}</td>
<td>{both.has(c.before.domain) ? "两版都读:你在这一题上觉得另一边更像,咨询时把两种看法并排写出来" : `按${SIDE_TEXT[chosen]}读`}</td>
</tr>
))}
</tbody>
</table>
<p className="chart-choice-fine">这是你按自己的感觉做的选择,不是验证出来的出生分钟。以后随时可以回来改。</p>
<Actions>
<Secondary onClick={onBack} disabled={saving}>回去改答案</Secondary>
<Secondary onClick={onFlip} disabled={saving}>改成{SIDE_TEXT[otherSide(chosen)]}</Secondary>
<Secondary onClick={onBoth} disabled={saving}>全部两版都读</Secondary>
<Primary onClick={onAccept} disabled={saving}>{saving ? "保存中…" : "好,就这样"}</Primary>
</Actions>
</Card>
);
}
+107
View File
@@ -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<ChartChoiceSide, { readonly from: string; readonly to: string; readonly saved_at: string }>;
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<Record<string, ChartChoiceAnswer>>;
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<ChartChoiceDecision["rule"], string> = {
majority: "你填的时间离分界线很近,按你的回答多数来定。",
tie_keeps_reported: "你的回答两边一样多,保留你填的时间。",
strong_override: "你的回答明显更像另一边,所以换过去。",
reported_kept: "你填的时间离分界线有一段距离,回答没有明显偏向另一边,保留你填的时间。",
user_override: "按你直接选的这一边。",
};
@@ -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<ChartChoiceError["code"], string> = {
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<ReturnType<typeof createServerSupabaseClient>>; 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 };
}
+298
View File
@@ -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<string, unknown>;
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<string, unknown>): Record<string, unknown> {
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<Subject> {
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<T>(path: string, body: Record<string, unknown>, fetchImpl: typeof fetch = fetch): Promise<T> {
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<string, unknown> {
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<ChartChoiceStart> {
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<ChartChoicePacket> {
const subject = await loadSubject(supabase, userId, request.subjectId);
const window = scanWindow(subject, request.window);
return engine<ChartChoicePacket>("/api/chart_choice/packet", engineBody(subject, window), fetchImpl);
}
type RpcClient = {
rpc: (fn: string, args: Record<string, unknown>) => PromiseLike<{ data: unknown; error: { message?: string } | null }>;
};
type DecidedChoice = {
packet: Extract<ChartChoicePacket, { status: "choose" }>;
decision: ChartChoiceDecision;
answers: Record<string, string>;
subject: Subject;
};
async function decideChartChoice(
supabase: unknown,
userId: string,
request: ChartChoiceAdoptRequest,
fetchImpl?: typeof fetch,
): Promise<DecidedChoice> {
const subject = await loadSubject(supabase, userId, request.subjectId);
const window = scanWindow(subject, request.window);
const result = await engine<ChartChoicePacket & { decision?: ChartChoiceDecision; answers?: Record<string, string> }>(
"/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<string, string> }> {
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<string, string> }> {
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<ChartChoicePacket>("/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 };
}
+43
View File
@@ -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<ChartChoiceSide, string> = { before: "线前", after: "线后" };
export function otherSide(side: ChartChoiceSide): ChartChoiceSide {
return side === "before" ? "after" : "before";
}
export function answeredCount(answers: Readonly<Record<string, ChartChoiceAnswer>>, 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} 分钟`;
}
+2
View File
@@ -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 = {
@@ -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;
+13
View File
@@ -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``).
"""
+157
View File
@@ -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
+57
View File
@@ -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,
}
+260
View File
@@ -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": "说不清"},
],
}
+214
View File
@@ -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"]],
},
}
+8
View File
@@ -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':
+156
View File
@@ -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"