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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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} 分钟`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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``).
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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": "说不清"},
|
||||
],
|
||||
}
|
||||
@@ -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"]],
|
||||
},
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user