feat: add birth rectification preview card
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { chinaLocations } from "@/data/china-locations";
|
||||
|
||||
type Profile = {
|
||||
date?: string;
|
||||
time?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
districtCode?: string;
|
||||
};
|
||||
|
||||
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
|
||||
function payloadFromProfile(profile: Profile) {
|
||||
if (!profile.date || !profile.time) return null;
|
||||
const province = chinaLocations.country.provinces.find((item) => item.code === profile.provinceCode);
|
||||
const city = province?.cities.find((item) => item.code === profile.cityCode);
|
||||
const district = city?.districts.find((item) => item.code === profile.districtCode);
|
||||
const location = district ?? city;
|
||||
if (!location) return null;
|
||||
return {
|
||||
birth_time: `${profile.date} ${profile.time}`,
|
||||
uncertainty_minutes: 30,
|
||||
step_minutes: 2,
|
||||
lat: location.center[1],
|
||||
lon: location.center[0],
|
||||
tz: chinaLocations.country.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchQuestionnaire(payload: Record<string, unknown>) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2500);
|
||||
try {
|
||||
const response = await fetch(`${jyotishApiBase}/api/active_rectification_questions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`jyotish_api_${response.status}`);
|
||||
return await response.json() as Record<string, unknown>;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null) as { profile?: Profile } | null;
|
||||
const payload = payloadFromProfile(body?.profile ?? {});
|
||||
if (!payload) {
|
||||
return NextResponse.json({
|
||||
status: "blocked",
|
||||
boundary: "not_auto_rectified",
|
||||
reason: "profile_incomplete",
|
||||
});
|
||||
}
|
||||
const questionnaire = await fetchQuestionnaire(payload).catch(() => null);
|
||||
const candidateScan = questionnaire?.candidate_scan && typeof questionnaire.candidate_scan === "object"
|
||||
? questionnaire.candidate_scan
|
||||
: null;
|
||||
const questions = Array.isArray(questionnaire?.questions) ? questionnaire.questions : [];
|
||||
return NextResponse.json({
|
||||
status: questionnaire ? "ok" : "blocked",
|
||||
candidate_scan: candidateScan,
|
||||
question_count: questions.length,
|
||||
boundary: "not_auto_rectified",
|
||||
source: questionnaire ? "active_rectification_questions" : "fallback_unavailable",
|
||||
});
|
||||
}
|
||||
@@ -315,14 +315,14 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.product-entrypoints button { min-height: 132px; align-items: end; border: 1px solid var(--color-border); background: var(--color-canvas); }
|
||||
.product-entrypoints span { display: grid; gap: var(--space-2); }
|
||||
.product-entrypoints small { color: var(--color-ink-secondary); line-height: 1.45; font-size: var(--type-caption); }
|
||||
.daily-starlanguage-card { min-height: 132px; display: grid; gap: var(--space-3); padding: var(--space-5); border: 1px solid color-mix(in srgb, var(--color-action) 16%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-canvas); }
|
||||
.daily-starlanguage-card, .birth-rectification-card { min-height: 132px; display: grid; gap: var(--space-3); padding: var(--space-5); border: 1px solid color-mix(in srgb, var(--color-action) 16%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-canvas); }
|
||||
.daily-starlanguage-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
|
||||
.daily-starlanguage-heading > span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: 1.5px; }
|
||||
.daily-starlanguage-heading button { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 10px; color: var(--color-ink); font-size: var(--type-caption); }
|
||||
.daily-starlanguage-card dl { display: grid; gap: var(--space-2); margin: 0; }
|
||||
.daily-starlanguage-card div { display: grid; gap: 4px; }
|
||||
.daily-starlanguage-card dt { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.daily-starlanguage-card dd { margin: 0; color: var(--color-ink); line-height: 1.45; }
|
||||
.daily-starlanguage-card dl, .birth-rectification-card dl { display: grid; gap: var(--space-2); margin: 0; }
|
||||
.daily-starlanguage-card div, .birth-rectification-card div { display: grid; gap: 4px; }
|
||||
.daily-starlanguage-card dt, .birth-rectification-card dt { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.daily-starlanguage-card dd, .birth-rectification-card dd { margin: 0; color: var(--color-ink); line-height: 1.45; }
|
||||
.starter-loading { color: var(--color-ink-secondary); margin-left: 0; padding: var(--space-5); border-radius: var(--radius-lg); background: var(--color-canvas-muted); font-size: 14px; }
|
||||
.starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; }
|
||||
|
||||
|
||||
@@ -81,6 +81,13 @@ type DailyStarlanguageApiResponse = {
|
||||
claim_status?: "exploratory_unvalidated";
|
||||
boundary?: "not_deterministic_prediction";
|
||||
};
|
||||
type BirthRectificationPreview = {
|
||||
status?: "ok" | "blocked";
|
||||
candidate_scan?: { start?: string; end?: string; candidate_count?: number };
|
||||
question_count?: number;
|
||||
boundary?: "not_auto_rectified";
|
||||
source?: "active_rectification_questions" | "fallback_unavailable";
|
||||
};
|
||||
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
|
||||
type PendingConsultation = {
|
||||
readonly requestId: string;
|
||||
@@ -360,6 +367,16 @@ async function fetchDailyStarlanguage(profile: Profile) {
|
||||
return payload.card;
|
||||
}
|
||||
|
||||
async function fetchBirthRectificationPreview(profile: Profile) {
|
||||
const response = await fetch("/api/birth-rectification", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile }),
|
||||
});
|
||||
if (!response.ok) throw new Error("birth_rectification_preview_unavailable");
|
||||
return await response.json().catch(() => null) as BirthRectificationPreview | null;
|
||||
}
|
||||
|
||||
function buildBirthTimeRectificationQuestion(profile: Profile) {
|
||||
return [
|
||||
`请为${profile.name || "我"}做生时校正辅助。`,
|
||||
@@ -637,6 +654,7 @@ export default function Home() {
|
||||
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
|
||||
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
|
||||
const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState<DailyStarlanguageCard | null>(null);
|
||||
const [birthRectificationPreview, setBirthRectificationPreview] = useState<BirthRectificationPreview | null>(null);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [profileNotice, setProfileNotice] = useState("");
|
||||
@@ -1003,6 +1021,22 @@ export default function Home() {
|
||||
};
|
||||
}, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !profileComplete) return;
|
||||
let cancelled = false;
|
||||
setBirthRectificationPreview(null);
|
||||
void fetchBirthRectificationPreview(profile)
|
||||
.then((preview) => {
|
||||
if (!cancelled) setBirthRectificationPreview(preview);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBirthRectificationPreview({ status: "blocked", boundary: "not_auto_rectified", source: "fallback_unavailable" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
conversationEnd.current?.scrollIntoView({ behavior: isLoading || reduceMotion ? "auto" : "smooth", block: "end" });
|
||||
@@ -2017,10 +2051,18 @@ export default function Home() {
|
||||
</dl>
|
||||
<small>探索性日提示,不是确定预测。</small>
|
||||
</article>
|
||||
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftBirthTimeRectificationQuestion}>
|
||||
<span><b>生时校正</b><small>收集事件证据并给候选出生时间段,不能直接改写默认星盘。</small></span>
|
||||
<ArrowUpRight className="starter-arrow" aria-hidden="true" />
|
||||
</button>
|
||||
<article className="birth-rectification-card" aria-label="生时校正">
|
||||
<div className="daily-starlanguage-heading">
|
||||
<span>生时校正</span>
|
||||
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftBirthTimeRectificationQuestion}>开始校正 <ArrowUpRight className="starter-arrow" aria-hidden="true" /></button>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>候选出生时间段</dt><dd>{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} – ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}</dd></div>
|
||||
<div><dt>候选点</dt><dd>{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count} 个` : "待后端生成"}</dd></div>
|
||||
<div><dt>问题数</dt><dd>{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}</dd></div>
|
||||
</dl>
|
||||
<small>不能直接改写默认星盘;需事件证据验证。</small>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
PAGE = Path("frontend/src/app/page.tsx")
|
||||
DAILY_ROUTE = Path("frontend/src/app/api/daily-starlanguage/route.ts")
|
||||
RECTIFICATION_ROUTE = Path("frontend/src/app/api/birth-rectification/route.ts")
|
||||
|
||||
|
||||
def test_daily_starlanguage_entrypoint_is_productized() -> None:
|
||||
@@ -33,6 +34,16 @@ def test_daily_starlanguage_api_declares_honest_source_boundary() -> None:
|
||||
def test_birth_time_rectification_entrypoint_is_productized() -> None:
|
||||
source = PAGE.read_text(encoding="utf-8")
|
||||
assert "生时校正" in source
|
||||
assert "fetchBirthRectificationPreview" in source
|
||||
assert "birth-rectification-card" in source
|
||||
assert "draftBirthTimeRectificationQuestion" in source
|
||||
assert "候选出生时间段" in source
|
||||
assert "不能直接改写默认星盘" in source
|
||||
|
||||
|
||||
def test_birth_rectification_api_proxies_active_questionnaire_with_boundary() -> None:
|
||||
source = RECTIFICATION_ROUTE.read_text(encoding="utf-8")
|
||||
assert "/api/active_rectification_questions" in source
|
||||
assert "candidate_scan" in source
|
||||
assert "question_count" in source
|
||||
assert "not_auto_rectified" in source
|
||||
|
||||
Reference in New Issue
Block a user