Compare commits

...
Author SHA1 Message Date
jesse-uxandCursor 3956519f33 feat(synastry): open a follow-up chat from one stored report section
The chat stays bound to that section's saved calculation, checks each chart fact before answering, and bills like an ordinary turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 21:44:25 +08:00
42 changed files with 2342 additions and 71 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-19 — 合盘报告可以按节追问依据
- 报告末尾可以选择前八节里的一节,打开一条绑定这份合盘的新对话。问题预先写好,由用户自己发送。打开对话不扣点,发出去的每一轮按普通对话 `chat.standard` 计费。
- 回答只读这份报告已经存下的计算。每轮先读该节材料,盘面对不上就改写一次,仍对不上就回复「这一点在这份合盘里没有算到,我不能确定」。材料没写到的,说「这份合盘没算到」。
- 侧栏标题是「甲 × 乙 · 合盘追问」,两个人各自的列表都能看到。报告删除后对话还在,输入框变为「这份合盘已删除」,不能继续。普通单人对话的提示、证据卡和工具没有改。Skill 版本不变。
## 2026-10-19 — 完整合盘报告第三次打回后的修复
- 「我的报告」翻页恢复成一次查询,按创建时间和编号往下翻。
@@ -0,0 +1,44 @@
# 进度:合盘报告追问(2026-10-19)
基线:`origin/staging` `123295135`。分支 `codex/synastry-report-chat-20261018`。#82 的代码已在这条基线上。
## 做了什么
- 会话类型增加 `synastry_report`。绑定 `personal_reports.id`(删除后置空,会话留下,只读)和八节之一。对方人物记在 `synastry_partner_subject_id`,这样报告删了之后两边的侧栏仍能找到这条对话。
- 报告末尾打开「对哪一段想再问清楚?」。点一节只创建会话并预填问题,不调用 `/api/consult`,不扣点。
- `POST /api/consult` 在这类会话上走独立分支,不调用 `run-jyotish-consultation`。每轮先读该节已存材料,再核对盘面。对不上改写一次,仍对不上就回复「这一点在这份合盘里没有算到,我不能确定」。计费键是 `chat.standard`。
- 侧栏标题是「甲 × 乙 · 合盘追问」。任一方是当前人物都会列出。人物选择器锁住,显示两个人的名字。报告删除后输入框是「这份合盘已删除」。
- 普通单人对话的提示、证据卡和工具没有改。`scripts/jyotish_api_server.py` 没有改。`Home()` 的状态数没有增加。
## 让步
没有。T4 的任一方侧栏、锁定的「甲 × 乙」和删除后只读都在这一轮里。
## 验证
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错。webpack 构建里的 TypeScript 也通过 |
| `npm run lint` | 0 error。原有 warning 没有改 |
| 定向测试 | 合盘追问、报告阅读页、会话列、入口、列表过滤、计费列合同、容量列合同、输入框禁用、Home 增长护栏,通过 |
| 数据库 | `frontend/tests/database-synastry-report-chat.test.ts` 通过。别人的报告和非法节被拒绝;删除报告后会话还在、报告编号为空、不能再扣 `chat.standard` |
| `next build --webpack` | 通过。`/` 仍是 Static |
| 首屏 gzip-9 | 基线 `123295135` 613,694 B;本轮 615,879 B;+2,185 B,+0.36%(35 个首屏 js/css,±2% 内) |
| 全量 `npm test` | 退出码 1。本轮改坏的三条源码合同已改完并单独重跑通过。其余失败是这台机器的环境:路径被拼成 `G:\G:\`、没有 WSL、浏览器测试落到另一个工作树的 `node_modules`。回答计时两条单独重跑通过 |
| 模型 / 浏览器 | 没有模型凭据,没有登录态。部署后按 `docs/testing/synastry-report-chat-20261018.md` 在 staging 看 |
Turbopack 不能用这个工作树里的 `node_modules` 联接(联接指向工作树外面)。首屏数字来自同机 `next build --webpack`。
## 断言改动
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 报告阅读页测试 | 页面不含「对哪一段想再问清楚」 | 含这句,八节可点,共同练习不是按钮 | #83 打开入口 |
| 会话列表列 / 详情列 | 列到人物字段后就是时间 | 中间加上报告、节、对方人物 | 侧栏和删除后只读要读到这些列 |
| 另一人物的会话过滤 | `eq:chart_profile_id` | `or` 同时匹配本人列和对方列 | 合盘追问两边都要看见 |
| 创建字段表 | `chatSessionCreateSchema.shape` | `chatSessionCreateFields.shape` | 交叉校验包在字段表外面,字段表仍不收 context_summary |
| 普通发送起点 | `const birthPlace = selectedBirthPlace(profile)` | 合盘追问跳过出生地 | 不能因为查看者没有出生地而停住 |
| 咨询读取列 | select 到 `context_summary` | 后面加上报告、节、对方人物 | 同一条读取要认出合盘会话 |
| 会话详情列合同 | 人物字段后是 `created_at` | 中间加上这三列 | 详情和容量合同跟着列走 |
| 输入框禁用 | `subjectDeleted` 开头 | `synastryClosed` 开头,合盘追问不看查看者资料是否齐全 | 报告删除后只读;追问不要求查看者出生资料 |
| 称呼按钮 | 资料停在称呼步时按钮是「确认称呼」 | 合盘追问仍是「发送」,也不再被出生资料表单挡住 | 预填问题要能发出;查看者自己的资料没填完也能问 |
+1 -1
View File
@@ -507,4 +507,4 @@
| `TASK-full-reading-module-parity-20261018.md` | #81 | full-reading 补齐上游 5 个模块(西占层文件没搬致永远 blocked、Navamsha / Niryaana 用我们自己的实现填键、Lagna Vargottamamsa / Pushkara / Punarphoo)(BUG-1375 起) | 待执行(可与 #78 并行) | `codex/full-reading-module-parity-20261018` |
| `TASK-niryaana-shoola-pvr-20261018.md` | `PROGRESS-niryaana-shoola-pvr-20261018.md` | #84 Niryaana Shoola 改 PVR 口径(我们的表与 Shoola 逐行相同;推翻 BUG-1283 所选 profile;占星师 7a773d13) | 待验收 | `codex/niryaana-shoola-pvr-20261018` |
| `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 |
| `TASK-synastry-report-chat-20261018.md` | #83 | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 排队(等 #82) | `codex/synastry-report-chat-20261018`;动表 |
| `TASK-synastry-report-chat-20261018.md` | `PROGRESS-synastry-report-chat-20261018.md` | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 待验收 | `codex/synastry-report-chat-20261018`;动表 |
@@ -0,0 +1,11 @@
# 合盘报告追问 · 真机清单(2026-10-19)
自动化没有登录态,也没有模型凭据。下面这些要在 staging 部署之后用一份公开名人合盘看。
1. 打开一份已经写好的中文合盘报告,滚到末尾。能看到「对哪一段想再问清楚?」和八个节名,没有「共同练习」。
2. 点「关系基调」。进入一条新对话,输入框里已经写好「合盘报告里「关系基调」这一段,在我们两人的盘里依据是什么?」。这一步没有自动发出,余额不变。
3. 自己发送。回答里的宫位、星曜或大运都能在这一节的材料里找到。再追问一轮,仍然只讲这份报告。
4. 问一个报告没写的问题,例如「他们哪一年领证」。回答里出现「这份合盘没算到」这一类说法。
5. 删掉这份合盘报告,再打开刚才的对话。原文还在,输入框是「这份合盘已删除」,不能发送。
6. 用英文版报告点同一节。预填和回答是英文。
7. 换到合盘里的另一个人,侧栏仍能看到标题为「甲 × 乙 · 合盘追问」的这条对话。人物选择器锁住,显示两个人的名字。
+1 -1
View File
@@ -2,7 +2,7 @@
## 婚恋合盘报告(2026-10-19)
选人页用自定义列表,最多约二十人,列表本身可滚动。资料不全的人灰掉,不能选。标题和按钮同一行两端对齐。正文与技术依据在窄屏上换行,不把页面撑出横向滚动。九节里的「分析逻辑」默认收起。英文准备期间沿用报告页的等待区,揭幕后不再出现加载指示。
选人页用自定义列表,最多约二十人,列表本身可滚动。资料不全的人灰掉,不能选。标题和按钮同一行两端对齐。正文与技术依据在窄屏上换行,不把页面撑出横向滚动。九节里的「分析逻辑」默认收起。英文准备期间沿用报告页的等待区,揭幕后不再出现加载指示。报告末尾一行「对哪一段想再问清楚?」,下面是八枚描边胶囊按钮(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送,也不出现新的加载动画。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。
## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途)
+1 -1
View File
@@ -34,7 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 吵完之后,怎样递出台阶才不会变成认输?
- 过日子的安排里,哪一件需要现在就说清楚?
「对哪一段想再问清楚?」先不显示。英文还没写好时只说「英文版即将提供」。选人页按钮是「开始合盘」。点数从配置读,没有配置时说「计费配置不可用」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。等待时分别说「正在排两人的星盘」「正在写关系画像」「正在核对表述」。
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文报告用「合盘报告里「这一段」这一段,在我们两人的盘里依据是什么?」;英文报告用英文。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页按钮是「开始合盘」。点数从配置读,没有配置时说「计费配置不可用」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。等待时分别说「正在排两人的星盘」「正在写关系画像」「正在核对表述」。
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
+14 -11
View File
@@ -17,6 +17,7 @@ import { BirthSkyReveal } from "@/components/birth-sky/birth-sky-reveal";
import {
composerLocksAsRectification,
ordinaryComposerPlaceholder,
synastryReportClosed,
rectificationOrdinaryComposerReopen,
} from "@/lib/rectification-session-composer-guard";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
@@ -527,6 +528,8 @@ export default function Home() {
status: chatProfileStatus,
library: chartLibrary,
});
const synastryChat = activeSession?.sessionType === "synastry_report";
const synastryClosed = synastryReportClosed(activeSession);
function reloadChatProfiles() {
if (!accountId) return;
@@ -674,7 +677,7 @@ export default function Home() {
&& !activeSession.messagesHydrated
&& pendingSessionId !== activeSession.id,
);
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
const onboardingFormActive = !synastryChat && !profileComplete && onboardingStep !== "name";
const starterHasPriorSessions = sessions.some((session) => session.id !== activeSession?.id);
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection, starterHasPriorSessions);
const conversationAnchor = useConversationScrollAnchor(
@@ -1025,11 +1028,11 @@ export default function Home() {
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!profileComplete) {
if (!synastryChat && !profileComplete) {
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
return;
}
if (subjectDeleted) return;
if (synastryClosed || subjectDeleted) return;
if (isLoading || cancellationPending) return void enqueueQueuedDraft(composerDraftSnapshot());
void send(composerDraftSnapshot(), draftTheme.current ?? undefined, draftEntrypoint.current);
}
@@ -1168,7 +1171,7 @@ export default function Home() {
<SidebarTrigger placement="inset" />
<div className="chat-header-title">
<strong>{activeSession?.title || "新对话"}</strong>
{activeSession?.sessionType === "consultation" ? (
{activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? (
<ChatProfilePicker
session={activeSession}
library={chartLibrary}
@@ -1195,7 +1198,7 @@ export default function Home() {
<div ref={conversation} className={`conversation${!onboardingFormActive && !activeSession?.messages.length && (Boolean(activeSession?.messagesHydrated) || activeSession?.sessionType !== "consultation") ? " is-empty" : ""}${!profileComplete ? " is-onboarding" : ""}${onboardingFormActive ? " is-onboarding-form" : ""}`}>
{!activeSession?.messages.length ? (
<div className="welcome">
{!profileComplete ? (
{!synastryChat && !profileComplete ? (
<HomeOnboardingShellSlot
profileComplete={profileComplete}
profileDraft={profileDraft}
@@ -1271,12 +1274,12 @@ export default function Home() {
{rectificationComposerReopen ? <RectificationComposerReopen message={rectificationErrorMessage} onReopen={() => void reopenRectificationSession(activeSession?.id || rectificationErrorSessionId || "")} /> : null}
<ChatComposer
inputRef={composerInput}
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete, onboardingName: onboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription), subjectDeleted })}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
inputDisabled={subjectDeleted || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={subjectDeleted || Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
inputLabel={!synastryChat && !profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete: synastryChat || profileComplete, onboardingName: onboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription), subjectDeleted, synastryClosed })}
maxLength={!synastryChat && !profileComplete && onboardingStep === "name" ? 80 : 500}
inputDisabled={synastryClosed || subjectDeleted || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!synastryChat && !profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!synastryChat && !profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={synastryClosed || subjectDeleted || Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || !account || !modelCatalog || (!synastryChat && !profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
stopVisible={isLoading}
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
+69 -7
View File
@@ -135,6 +135,7 @@ import {
} from "@/lib/session-context-summary";
import { persistGuardedSessionTitle } from "@/lib/session-title-guard";
import { generateSessionTitle, shouldGenerateSessionTitle } from "@/lib/session-title-agent";
import { handleSynastryReportConsult } from "@/lib/synastry-report-consult";
import { z } from "zod";
import { CONSULTATION_QUEUE_FAILED_MESSAGE, isConsultationRateLimitFailure, retryOnConsultationRateLimit } from "@/lib/consultation-workflow-retry";
import {
@@ -195,7 +196,23 @@ const windowChatRequestSchema = z.object({
entrypoint: consultationEntrypointSchema.exclude(["birth_time_rectification"]).optional(),
}).strict();
const synastryChatRequestSchema = z.object({
requestId: z.string().uuid(),
sessionId: z.string().uuid(),
modelId: z.string().trim().min(1).max(64),
question: z.string().trim().min(1).max(500),
theme: consultationDomainSchema,
entrypoint: z.literal("synastry_report"),
consultationMode: z.literal("synastry_report"),
name: z.string().trim().max(80).optional().default(""),
history: z.array(z.object({
role: z.enum(["user", "assistant"]),
text: z.string().max(4000),
})).max(20).optional().default([]),
}).strict();
const chatRequestSchema = z.union([
synastryChatRequestSchema,
generalChatRequestSchema,
windowChatRequestSchema,
chartChatRequestSchema,
@@ -269,17 +286,46 @@ export async function POST(request: Request) {
const prepParallel = consultPrepParallelEnabled();
const prepPhases: ConsultPrepPhase[] = [];
const synastryHandoff: {
current: {
userId: string;
parsed: { requestId: string; sessionId: string; modelId: string; question: string; theme: string };
session: {
model_id: string | null;
messages: unknown;
synastry_report_id?: string | null;
synastry_section?: string | null;
};
} | null;
} = { current: null };
const head = await loadConsultTurnHead({
parallel: prepParallel,
getUser: () => supabase.auth.getUser(),
readPendingDeletion: (userId) => refuseWhenAccountDeletionPending(userId),
readBody: async () => chatRequestSchema.safeParse(await request.json().catch(() => null)),
readSession: async (userId, parsed) => await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
.eq("id", parsed.sessionId)
.eq("user_id", userId)
.maybeSingle(),
readSession: async (userId, parsed) => {
const result = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary,synastry_report_id,synastry_section,synastry_partner_subject_id")
.eq("id", parsed.sessionId)
.eq("user_id", userId)
.maybeSingle();
const row = result.data;
if (row?.session_type === "synastry_report" && "question" in parsed && typeof parsed.question === "string") {
synastryHandoff.current = {
userId,
parsed: {
requestId: parsed.requestId,
sessionId: parsed.sessionId,
modelId: parsed.modelId,
question: parsed.question,
theme: "theme" in parsed && typeof parsed.theme === "string" ? parsed.theme : "marriage",
},
session: row,
};
}
return result;
},
resolveModel: async (chatSession) => {
if (!chatSession.model_id) return null;
return await resolveSessionLanguageModel(
@@ -292,6 +338,15 @@ export async function POST(request: Request) {
// A pending account deletion freezes the account: nothing new is charged.
const frozen = head.frozen ? head.response : null;
if (frozen) return frozen;
if (synastryHandoff.current) {
return handleSynastryReportConsult({
request,
userId: synastryHandoff.current.userId,
parsed: synastryHandoff.current.parsed,
session: synastryHandoff.current.session,
accounting,
});
}
return head.response;
}
const { user, parsed, chatSession, sessionModel } = head;
@@ -306,6 +361,13 @@ export async function POST(request: Request) {
);
const sessionContextWindow = sessionModel.contextWindow;
if (parsed.data.consultationMode === "synastry_report") {
return NextResponse.json(
{ error: "会话类型不匹配", message: "请从合盘报告进入后再问。", code: "session_not_synastry" },
{ status: 409 },
);
}
if (parsed.data.entrypoint === "birth_time_rectification") {
return NextResponse.json(
{
@@ -452,7 +514,7 @@ export async function POST(request: Request) {
try {
prepared = await prepareConsultationRoute<ModelSelection, ConsultationPlan>({
userId,
mode: parsed.data.consultationMode,
mode: parsed.data.consultationMode as Exclude<typeof parsed.data.consultationMode, "synastry_report">,
subject: {
binding: consultationSubjectBindingFromSession(chatSession),
clientBirth: parsed.data,
+1 -1
View File
@@ -23,7 +23,7 @@ import { CHART_SUBJECT_SELECT } from "@/lib/subject-birth";
type RouteContext = { params: Promise<{ id: string }> };
const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at";
const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,synastry_report_id,synastry_section,synastry_partner_subject_id,created_at,updated_at,pinned,archived_at";
const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function payloadHasMessages(payload: unknown): payload is { messages: unknown } {
+46 -8
View File
@@ -24,9 +24,18 @@ import {
sessionCursorFilter,
} from "@/lib/session-cursor";
import { excludeEmptyConsultations } from "@/lib/session-list-filter";
import { authoritativeSynastrySession, SynastrySessionError } from "@/lib/synastry-report-session";
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at";
const SELF_SESSION_FILTER = "chart_profile_id.is.null,chart_profile_id.eq.self";
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,synastry_report_id,synastry_section,synastry_partner_subject_id,updated_at,pinned,archived_at";
const SELF_SESSION_FILTER = "chart_profile_id.is.null,chart_profile_id.eq.self,synastry_partner_subject_id.eq.self";
const SUBJECT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function subjectSessionFilter(subjectId: string): string {
if (subjectId === "self") return SELF_SESSION_FILTER;
if (!SUBJECT_UUID.test(subjectId)) return "chart_profile_id.eq.00000000-0000-4000-8000-000000000000";
return `chart_profile_id.eq.${subjectId},synastry_partner_subject_id.eq.${subjectId}`;
}
export async function GET(request: Request) {
try {
@@ -49,7 +58,7 @@ export async function GET(request: Request) {
.eq("pinned", false)
.is("archived_at", null);
let pageQuery = excludeEmptyConsultations(
subjectId === "self" ? unpinned.or(SELF_SESSION_FILTER) : unpinned.eq("chart_profile_id", subjectId),
unpinned.or(subjectSessionFilter(subjectId)),
)
.order("updated_at", { ascending: false })
.order("id", { ascending: false })
@@ -69,7 +78,7 @@ export async function GET(request: Request) {
.eq("pinned", true)
.is("archived_at", null);
const { data: pinnedData, error: pinnedError } = await excludeEmptyConsultations(
subjectId === "self" ? pinned.or(SELF_SESSION_FILTER) : pinned.eq("chart_profile_id", subjectId),
pinned.or(subjectSessionFilter(subjectId)),
)
.order("updated_at", { ascending: false })
.order("id", { ascending: false });
@@ -105,7 +114,23 @@ export async function POST(request: Request) {
const parsed = chatSessionCreateSchema.safeParse(await readChatSessionJson(request));
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
const { id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, ...values } = parsed.data;
const binding = await authoritativeSessionBinding({
const synastryBinding = values.session_type === "synastry_report"
? await authoritativeSynastrySession({
reportId: values.synastry_report_id ?? "",
section: values.synastry_section ?? "",
loadOwnedReport: async (reportId) => {
const { data, error } = await supabase
.from("personal_reports")
.select("id,report_type,status,subject_low,subject_high,report_document,card_summary")
.eq("id", reportId)
.eq("user_id", user.id)
.maybeSingle();
if (error) throw new SynastrySessionError("synastry_report_not_owned");
return data;
},
})
: null;
const binding = synastryBinding ? null : await authoritativeSessionBinding({
userId: user.id,
requested: values,
existingMessages: values.messages,
@@ -122,16 +147,19 @@ export async function POST(request: Request) {
.eq("user_id", user.id)
.maybeSingle()),
});
const insertValues = binding ? { ...values, ...binding } : values;
const inheritedSummary = await resolveInheritedContextSummary({
const insertValues = synastryBinding
? { ...values, ...synastryBinding }
: binding ? { ...values, ...binding } : values;
const inheritedSummary = values.session_type === "synastry_report" ? null : await resolveInheritedContextSummary({
continuedFromSessionId,
loadOwnedSummary: async (sourceId) => {
const { data } = await supabase
.from("chat_sessions")
.select("context_summary")
.select("context_summary,session_type")
.eq("id", sourceId)
.eq("user_id", user.id)
.maybeSingle();
if (data?.session_type === "synastry_report") return null;
return data?.context_summary ?? null;
},
});
@@ -147,6 +175,16 @@ export async function POST(request: Request) {
if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
return NextResponse.json({ ok: true }, { status: 201 });
} catch (error) {
if (error instanceof SynastrySessionError) {
const missing = error.code === "synastry_report_not_owned" || error.code === "synastry_report_not_ready";
return NextResponse.json(
{
error: missing ? "无法打开这份合盘" : "这一节不能发起追问",
code: error.code,
},
{ status: 409 },
);
}
if (error instanceof ConsultationSubjectError) {
const failure = consultationSubjectFailureResponse(error);
return NextResponse.json(failure.body, { status: failure.status });
+28
View File
@@ -4550,6 +4550,34 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
color: var(--color-ink);
}
.synastry-report-ask {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-6);
}
.synastry-report-ask p {
flex-basis: 100%;
margin: 0;
}
.synastry-report-ask button {
min-height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-canvas);
color: var(--color-ink);
font: inherit;
cursor: pointer;
}
.synastry-report-ask button:disabled {
cursor: default;
opacity: 0.6;
}
.personal-report-state {
width: 100%;
height: 100%;
+2 -2
View File
@@ -54,7 +54,7 @@ export type ChatTranscriptProps = Readonly<{
streamingTimeline?: readonly ConsultationTimelineRow[];
streamingResponseKind?: "smalltalk";
sessionId: string;
sessionType: "consultation" | "birth_time_rectification";
sessionType: "consultation" | "birth_time_rectification" | "synastry_report";
theme?: ConsultationDomain;
messageFeedback: Readonly<Record<string, ChatMessageFeedback>>;
copiedMessageKey: string | null;
@@ -68,7 +68,7 @@ type MessageEntryProps = Readonly<{
views: readonly ChatMessageView[];
index: number;
sessionId: string;
sessionType: "consultation" | "birth_time_rectification";
sessionType: "consultation" | "birth_time_rectification" | "synastry_report";
theme?: ConsultationDomain;
messageFeedback: Readonly<Record<string, ChatMessageFeedback>>;
copiedMessageKey: string | null;
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react";
import { SECTION_TITLES, SYNASTRY_FOOTNOTE, SYNASTRY_PROSE_IDS, TALK_TOGETHER } from "@/lib/synastry-full-sections";
import { beginSynastrySectionChat } from "@/lib/synastry-report-chat-client";
import { SECTION_TITLES, SYNASTRY_ASK_SECTION_IDS, SYNASTRY_FOOTNOTE, SYNASTRY_PROSE_IDS, TALK_TOGETHER, type SynastryAskSectionId } from "@/lib/synastry-full-sections";
import type { TechnicalBlock } from "@/lib/synastry-full-technical";
type Reason = { plain?: string; basis?: string; evidence?: string; label?: string };
@@ -40,6 +41,8 @@ export function SynastryReportView(props: {
const [language, setLanguage] = useState<"zh" | "en">(props.initialLanguage ?? "zh");
const [englishNote, setEnglishNote] = useState("");
const [english, setEnglish] = useState<LanguageBody | null>(snapshot?.english ?? null);
const [asking, setAsking] = useState("");
const [askError, setAskError] = useState("");
const waiting = language === "en" && !english && !englishNote;
useEffect(() => {
@@ -75,6 +78,22 @@ export function SynastryReportView(props: {
setLanguage("en");
}
async function askSection(sectionId: SynastryAskSectionId) {
setAsking(sectionId);
setAskError("");
try {
await beginSynastrySectionChat({
reportId: props.reportId,
sectionId,
language,
names: snapshot?.names ?? {},
});
} catch (error) {
setAskError(error instanceof Error ? error.message : "暂时无法打开对话");
setAsking("");
}
}
if (!snapshot) return null;
const body: LanguageBody | null = language === "en" && english ? english : snapshot;
if (!body) return null;
@@ -157,6 +176,15 @@ export function SynastryReportView(props: {
</section>
))}
</details>
<nav className="synastry-report-ask" aria-label="对哪一段想再问清楚?">
<p>对哪一段想再问清楚?</p>
{SYNASTRY_ASK_SECTION_IDS.map((id) => (
<button key={id} type="button" disabled={asking !== ""} onClick={() => void askSection(id)}>
{SECTION_TITLES[id][language]}
</button>
))}
</nav>
{askError ? <p role="alert">{askError}</p> : null}
<p>{SYNASTRY_FOOTNOTE}</p>
</article>
);
+29 -15
View File
@@ -553,6 +553,11 @@ export function useConsultationRun(params: ConsultationRunParams) {
return false;
}
if (!modelCatalog || !account) return false;
const synastryTurn = currentSession.sessionType === "synastry_report";
if (!resuming && synastryTurn && !currentSession.synastryReportId) {
setComposerNotice("这份合盘已删除");
return false;
}
if (consultSendBlockedByRectificationSession(currentSession)) {
if (resuming) {
pendingConsultation.current = null;
@@ -583,14 +588,14 @@ export function useConsultationRun(params: ConsultationRunParams) {
|| pending.cancelled) return false;
}
if (!isProfileComplete(profile)) {
if (!synastryTurn && !isProfileComplete(profile)) {
openAccountDialog("profile");
setProfileNotice("请先补充出生资料,才能进行星盘计算。");
return false;
}
const birthPlace = selectedBirthPlace(profile);
if (!birthPlace) return false;
const birthPlace = synastryTurn ? null : selectedBirthPlace(profile);
if (!synastryTurn && !birthPlace) return false;
const theme = requestedTheme ?? currentSession.theme;
const sessionId = currentSession.id;
@@ -631,8 +636,9 @@ export function useConsultationRun(params: ConsultationRunParams) {
}
}
const [year, month, day] = profile.date.split("-").map(Number);
const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? [];
const [year, month, day] = synastryTurn ? [] : profile.date.split("-").map(Number);
const [hour, minute] = synastryTurn ? [] : consultationRoute.time?.split(":").map(Number) ?? [];
const turnEntrypoint = synastryTurn ? "synastry_report" as const : consultEntrypoint;
const lastMessage = currentSession.messages.at(-1);
const questionAlreadyPresent = lastMessage?.role === "user" && lastMessage.text === question;
@@ -643,9 +649,9 @@ export function useConsultationRun(params: ConsultationRunParams) {
: currentSession.messages);
const userSession: ChatSession = {
...currentSession,
title: currentSession.messages.length === 0 && isGenericSessionTitle(currentSession.title)
title: !synastryTurn && currentSession.messages.length === 0 && isGenericSessionTitle(currentSession.title)
? resolveSessionTitle(question, undefined, {
entrypoint: consultEntrypoint,
entrypoint: turnEntrypoint,
theme,
existingTitles: sessions.filter((item) => item.id !== currentSession.id).map((item) => item.title),
})
@@ -674,7 +680,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
requestId,
sessionId,
question: originalQuestion,
entrypoint: consultEntrypoint,
entrypoint: turnEntrypoint,
theme,
previousSession: rollbackSession,
optimisticSession: userSession,
@@ -690,7 +696,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
requestId,
question: originalQuestion,
theme,
entrypoint: consultEntrypoint,
entrypoint: turnEntrypoint,
}));
} catch {
// Private-mode storage must not block send.
@@ -751,7 +757,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
pendingConsultation.current = {
...pendingConsultation.current,
question: originalQuestion,
entrypoint: consultEntrypoint,
entrypoint: turnEntrypoint,
theme,
optimisticSession: userSession,
phase: "streaming",
@@ -818,7 +824,15 @@ export function useConsultationRun(params: ConsultationRunParams) {
const response = await fetch("/api/consult", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
body: JSON.stringify(synastryTurn ? {
requestId,
sessionId: currentSession.id,
modelId: currentSession.modelId,
question,
theme: "marriage",
entrypoint: "synastry_report",
consultationMode: "synastry_report",
} : {
requestId,
sessionId: currentSession.id,
modelId: currentSession.modelId,
@@ -831,10 +845,10 @@ export function useConsultationRun(params: ConsultationRunParams) {
day,
hour,
minute,
city: birthPlace.label,
lat: birthPlace.lat,
lon: birthPlace.lon,
tz: birthPlace.tz,
city: birthPlace?.label,
lat: birthPlace?.lat,
lon: birthPlace?.lon,
tz: birthPlace?.tz,
entryMode: "direct_chart" as const,
}),
theme,
@@ -16,6 +16,7 @@ import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectSco
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
import { takeSynastryChatDraft } from "@/lib/synastry-report-chat-client";
import { sortSessions } from "@/lib/session-groups";
import {
clearBirthTimeConsultationConsent,
@@ -127,6 +128,13 @@ export function useSessionManagement(params: SessionManagementParams) {
uiPreview,
openRectificationSession,
} = params;
useEffect(() => {
if (activeSession?.sessionType !== "synastry_report") return;
const question = takeSynastryChatDraft(activeSession.id);
if (!question) return;
setDraft(question);
setDraftEntrypoint("synastry_report");
}, [activeSession, setDraft, setDraftEntrypoint]);
// Owned here since 2026-09-16 (state lowering batch 2). Home reads back only
// what its own JSX renders; the rest never leaves this hook.
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
+18 -2
View File
@@ -36,10 +36,23 @@ export function chatSessionSubjectLocked(session: {
return session.messages.length > 0;
}
function subjectHit(chartProfileId: string | null | undefined, subjectId: string): boolean {
const id = chartProfileId?.trim() || null;
if (!subjectId || subjectId === "self") return id == null || id === "self";
return id === subjectId;
}
export function sessionMatchesSubject(
session: Pick<ChatSession, "chartProfileId" | "chartProfileRole">,
session: Pick<ChatSession, "chartProfileId" | "chartProfileRole"> & {
sessionType?: string;
synastryPartnerSubjectId?: string | null;
},
subjectId: string,
): boolean {
if (session.sessionType === "synastry_report") {
return subjectHit(session.chartProfileId, subjectId)
|| subjectHit(session.synastryPartnerSubjectId, subjectId);
}
const id = session.chartProfileId?.trim() || null;
if (!subjectId || subjectId === "self") {
return id == null || id === "self" || session.chartProfileRole === "self";
@@ -55,10 +68,13 @@ export function chatProfileSelectedId(session: Pick<ChatSession, "chartProfileId
}
export function chatProfileDisplayName(input: {
session: Pick<ChatSession, "chartProfileId" | "chartProfileName" | "chartProfileRole">;
session: Pick<ChatSession, "chartProfileId" | "chartProfileName" | "chartProfileRole"> & { sessionType?: string };
status: ChatProfileCatalogStatus;
library: readonly ChartLibraryRecord[];
}): string {
if (input.session.sessionType === "synastry_report") {
return input.session.chartProfileName?.trim() || "合盘追问";
}
if (input.status !== "ready") {
const snapshot = input.session.chartProfileName?.trim();
if (snapshot) return snapshot;
@@ -1,4 +1,5 @@
import { z } from "zod";
import { SYNASTRY_ASK_SECTION_IDS } from "./synastry-full-sections.ts";
import {
agentExecutionReceiptSchema,
workflowReceiptSchema,
@@ -49,7 +50,7 @@ export const chatSessionModelPatchSchema = z.object({
model_id: z.string().trim().min(1).max(64),
}).strict();
export const chatSessionCreateSchema = z.object({
export const chatSessionCreateFields = z.object({
id: z.string().uuid(),
title: z.string().trim().min(1).max(160),
theme: consultationSessionThemeSchema,
@@ -57,13 +58,36 @@ export const chatSessionCreateSchema = z.object({
// Former value: `.max(CHAT_SESSION_MAX_MESSAGES)` with transcript contents.
// Create is no longer a history-import path; only an empty array is accepted.
messages: z.array(chatMessageSchema).max(0),
session_type: z.enum(["consultation", "birth_time_rectification"]),
session_type: z.enum(["consultation", "birth_time_rectification", "synastry_report"]),
rectification_case_id: z.string().uuid().nullable(),
continued_from_session_id: z.string().uuid().optional(),
synastry_report_id: z.string().uuid().optional(),
synastry_section: z.enum(SYNASTRY_ASK_SECTION_IDS).optional(),
...chartBindingSchema,
updated_at: z.string().datetime().optional(),
}).strict();
export const chatSessionCreateSchema = chatSessionCreateFields.superRefine((value, context) => {
if (value.session_type === "synastry_report") {
if (!value.synastry_report_id) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["synastry_report_id"], message: "synastry_report_required" });
}
if (!value.synastry_section) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["synastry_section"], message: "synastry_section_required" });
}
if (value.rectification_case_id) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["rectification_case_id"], message: "synastry_rectification_forbidden" });
}
if (value.continued_from_session_id) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["continued_from_session_id"], message: "synastry_context_forbidden" });
}
return;
}
if (value.synastry_report_id || value.synastry_section) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["session_type"], message: "synastry_fields_forbidden" });
}
});
const chatSessionLegacyWriteObjectSchema = z.object({
title: z.string().trim().min(1).max(160),
theme: consultationSessionThemeSchema,
@@ -150,9 +174,11 @@ export type ChatSessionCreate = Readonly<{
theme: ConsultationDomain;
model_id: string;
messages: readonly [];
session_type: "consultation" | "birth_time_rectification";
session_type: "consultation" | "birth_time_rectification" | "synastry_report";
rectification_case_id: string | null;
continued_from_session_id?: string;
synastry_report_id?: string;
synastry_section?: (typeof SYNASTRY_ASK_SECTION_IDS)[number];
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
@@ -163,8 +189,11 @@ export type ChatSessionCreateInsertValues = Readonly<{
theme: ConsultationDomain;
model_id: string;
messages: readonly unknown[];
session_type: "consultation" | "birth_time_rectification";
session_type: "consultation" | "birth_time_rectification" | "synastry_report";
rectification_case_id: string | null;
synastry_report_id?: string;
synastry_section?: (typeof SYNASTRY_ASK_SECTION_IDS)[number];
synastry_partner_subject_id?: string;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
+5 -1
View File
@@ -5,6 +5,7 @@ export const consultationEntrypointSchema = z.enum([
"birth_time_rectification",
// 入口已于 2026-09-15 下线,枚举值保留用于读回历史会话
"guided_topic",
"synastry_report",
]);
export type ConsultationEntrypoint = z.infer<typeof consultationEntrypointSchema>;
@@ -60,7 +61,8 @@ function publicDailyExpansion(currentDate: string): ResolvedConsultationQuestion
export function pinsConsultationDomains(entrypoint: ConsultationEntrypoint | undefined): boolean {
return entrypoint === "daily_starlanguage"
|| entrypoint === "birth_time_rectification"
|| entrypoint === "guided_topic";
|| entrypoint === "guided_topic"
|| entrypoint === "synastry_report";
}
export function resolveConsultationQuestion(
@@ -92,6 +94,8 @@ export function resolveConsultationQuestion(
};
case "guided_topic":
return { kind: "plain", modelQuestion: input.visibleQuestion };
case "synastry_report":
return { kind: "plain", modelQuestion: input.visibleQuestion };
default: {
const exhaustive: never = input.entrypoint;
return exhaustive;
+14 -2
View File
@@ -278,6 +278,9 @@ export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog
chart_profile_name?: unknown;
chart_profile_role?: unknown;
session_type?: unknown;
synastry_report_id?: unknown;
synastry_section?: unknown;
synastry_partner_subject_id?: unknown;
created_at?: unknown;
updated_at?: unknown;
archived_at?: unknown;
@@ -316,13 +319,22 @@ export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog
if (catalog && selection.fellBack) fallbackSessionIds.push(session.id);
return [{
id: session.id,
title: typeof session.title === "string" ? session.title.slice(0, 48) : "新对话",
title: typeof session.title === "string"
? session.title.slice(0, session.session_type === "synastry_report" ? 160 : 48)
: "新对话",
theme: normalizeConsultationDomain(session.theme) ?? "general",
modelId: selection.modelId,
messages,
sessionType: session.session_type === "birth_time_rectification"
? "birth_time_rectification"
: "consultation",
: session.session_type === "synastry_report"
? "synastry_report"
: "consultation",
synastryReportId: typeof session.synastry_report_id === "string" ? session.synastry_report_id : null,
synastrySection: typeof session.synastry_section === "string" ? session.synastry_section : null,
synastryPartnerSubjectId: typeof session.synastry_partner_subject_id === "string"
? session.synastry_partner_subject_id
: null,
rectificationCaseId: typeof session.rectification_case_id === "string"
? session.rectification_case_id
: null,
+4 -1
View File
@@ -71,7 +71,7 @@ export type SynastryReportApiRecord = {
report?: SynastryReportCard;
created_at?: string;
};
export type ChatSessionType = "consultation" | "birth_time_rectification";
export type ChatSessionType = "consultation" | "birth_time_rectification" | "synastry_report";
export type ChatProfileBinding = {
chartProfileId: string | null;
chartProfileName: string | null;
@@ -90,6 +90,9 @@ export type ChatSession = {
chartProfileId: string | null;
chartProfileName: string | null;
chartProfileRole: "self" | "other" | null;
synastryReportId?: string | null;
synastrySection?: string | null;
synastryPartnerSubjectId?: string | null;
pinned: boolean;
archivedAt: string | null;
messagesHydrated: boolean;
@@ -54,6 +54,15 @@ export function dropRectificationStoredPending<
return stored;
}
export const SYNASTRY_REPORT_DELETED_PLACEHOLDER = "这份合盘已删除";
export function synastryReportClosed(session: {
sessionType?: string;
synastryReportId?: string | null;
} | null | undefined): boolean {
return session?.sessionType === "synastry_report" && !session.synastryReportId;
}
export function ordinaryComposerPlaceholder(input: {
locked: boolean;
hasAccount: boolean;
@@ -62,8 +71,10 @@ export function ordinaryComposerPlaceholder(input: {
presetMessageFinished: boolean;
creditsEmpty: boolean;
subjectDeleted?: boolean;
synastryClosed?: boolean;
}): string {
if (input.locked) return RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER;
if (input.synastryClosed) return SYNASTRY_REPORT_DELETED_PLACEHOLDER;
if (input.subjectDeleted) return "这份资料已经删除,这段对话只能看";
if (!input.hasAccount) return "正在读取账户…";
if (!input.profileComplete) {
+1
View File
@@ -27,6 +27,7 @@ export function replaceUnsavedEmptyConsultations(
export function isListedSidebarSession(session: ChatSession): boolean {
if (session.archivedAt) return false;
if (session.sessionType === "synastry_report") return true;
if (session.sessionType === "birth_time_rectification") return true;
if (!session.messagesHydrated) return true;
return session.messages.length > 0;
@@ -0,0 +1,191 @@
/**
* Chart facts in a synastry follow-up must appear in the tool material from
* this turn. Names and relationship language are not claims.
*/
const PLANETS: ReadonlyArray<readonly [string, string]> = [
["北交点", "rahu"],
["南交点", "ketu"],
["罗睺", "rahu"],
["计都", "ketu"],
["太阳", "sun"],
["月亮", "moon"],
["火星", "mars"],
["水星", "mercury"],
["木星", "jupiter"],
["金星", "venus"],
["土星", "saturn"],
["Jupiter", "jupiter"],
["Mercury", "mercury"],
["Saturn", "saturn"],
["Venus", "venus"],
["Rahu", "rahu"],
["Ketu", "ketu"],
["Moon", "moon"],
["Mars", "mars"],
["Sun", "sun"],
];
const SIGNS: ReadonlyArray<readonly [string, string]> = [
["白羊", "aries"], ["金牛", "taurus"], ["双子", "gemini"], ["巨蟹", "cancer"],
["狮子", "leo"], ["处女", "virgo"], ["天秤", "libra"], ["天蝎", "scorpio"],
["射手", "sagittarius"], ["摩羯", "capricorn"], ["水瓶", "aquarius"], ["双鱼", "pisces"],
["Aries", "aries"], ["Taurus", "taurus"], ["Gemini", "gemini"], ["Cancer", "cancer"],
["Leo", "leo"], ["Virgo", "virgo"], ["Libra", "libra"], ["Scorpio", "scorpio"],
["Sagittarius", "sagittarius"], ["Capricorn", "capricorn"], ["Aquarius", "aquarius"], ["Pisces", "pisces"],
];
const HOUSE_WORDS: Readonly<Record<string, number>> = {
一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10, 十一: 11, 十二: 12,
};
const ASPECTS: ReadonlyArray<readonly [string, string]> = [
["合相", "conjunction"],
["对冲", "opposition"],
["三分相", "trine"],
["六分相", "sextile"],
["四分相", "square"],
["三分", "trine"],
["六分", "sextile"],
["四分", "square"],
["conjunction", "conjunction"],
["opposition", "opposition"],
["trine", "trine"],
["sextile", "sextile"],
["square", "square"],
["刑", "square"],
["冲", "opposition"],
["拱", "trine"],
["合", "conjunction"],
];
export const SYNASTRY_FIXED_REPLY = "这一点在这份合盘里没有算到,我不能确定";
const planetAlt = PLANETS.map(([name]) => name).join("|");
const signAlt = SIGNS.map(([name]) => name).join("|");
const aspectAlt = ASPECTS.map(([name]) => name).join("|");
function planetId(name: string): string | null {
const found = PLANETS.find(([label]) => label.toLowerCase() === name.toLowerCase());
return found?.[1] ?? null;
}
function signId(name: string): string | null {
const found = SIGNS.find(([label]) => label.toLowerCase() === name.toLowerCase());
return found?.[1] ?? null;
}
function aspectId(name: string): string | null {
const found = ASPECTS.find(([label]) => label.toLowerCase() === name.toLowerCase());
return found?.[1] ?? null;
}
function houseNumber(raw: string): number | null {
if (HOUSE_WORDS[raw]) return HOUSE_WORDS[raw];
const digits = Number(raw);
return Number.isInteger(digits) && digits >= 1 && digits <= 12 ? digits : null;
}
function monthToken(raw: string): string {
return raw.padStart(2, "0");
}
export function extractSynastryClaims(text: string): Set<string> {
const claims = new Set<string>();
const housePattern = new RegExp(
`(${planetAlt})(?:星)?(?:落在|落于|在|位于)\\s*(?:第\\s*)?(\\d{1,2}|[一二三四五六七八九十]{1,3})\\s*宫`,
"giu",
);
const signPattern = new RegExp(
`(${planetAlt})(?:星)?(?:落在|落于|在|位于)\\s*(${signAlt})(?:座)?`,
"giu",
);
const englishHouse = new RegExp(
`(${planetAlt})\\s+in\\s+(?:the\\s+)?(\\d{1,2})(?:st|nd|rd|th)?\\s+house`,
"giu",
);
const englishSign = new RegExp(`(${planetAlt})\\s+in\\s+(${signAlt})`, "giu");
const aspectPattern = new RegExp(
`(${planetAlt})(?:与|和)\\s*(${planetAlt})\\s*(${aspectAlt})`,
"giu",
);
const dashaPattern = new RegExp(
`(${planetAlt})(?:大运|子运)\\s*(\\d{4})\\s*[-/.年]\\s*(\\d{1,2})(?:\\s*[-/.月]\\s*\\d{1,2})?\\s*(?:至|到|—|–|-)\\s*(\\d{4})\\s*[-/.年]\\s*(\\d{1,2})`,
"giu",
);
for (const match of text.matchAll(housePattern)) {
const planet = planetId(match[1] ?? "");
const house = houseNumber(match[2] ?? "");
if (planet && house) claims.add(`placement:${planet}:h${house}`);
}
for (const match of text.matchAll(signPattern)) {
const planet = planetId(match[1] ?? "");
const sign = signId(match[2] ?? "");
if (planet && sign) claims.add(`placement:${planet}:s${sign}`);
}
for (const match of text.matchAll(englishHouse)) {
const planet = planetId(match[1] ?? "");
const house = houseNumber(match[2] ?? "");
if (planet && house) claims.add(`placement:${planet}:h${house}`);
}
for (const match of text.matchAll(englishSign)) {
const planet = planetId(match[1] ?? "");
const sign = signId(match[2] ?? "");
if (planet && sign) claims.add(`placement:${planet}:s${sign}`);
}
for (const match of text.matchAll(aspectPattern)) {
const left = planetId(match[1] ?? "");
const right = planetId(match[2] ?? "");
const aspect = aspectId(match[3] ?? "");
if (left && right && aspect && left !== right) {
const pair = [left, right].sort().join(":");
claims.add(`aspect:${pair}:${aspect}`);
}
}
for (const match of text.matchAll(dashaPattern)) {
const planet = planetId(match[1] ?? "");
if (!planet) continue;
const start = `${match[2]}-${monthToken(match[3] ?? "")}`;
const end = `${match[4]}-${monthToken(match[5] ?? "")}`;
claims.add(`dasha:${planet}:${start}:${end}`);
}
return claims;
}
export function auditSynastryAnswer(answer: string, material: unknown): {
ok: boolean;
mismatches: string[];
} {
const known = extractSynastryClaims(JSON.stringify(material ?? {}));
const said = extractSynastryClaims(answer);
const mismatches = [...said].filter((claim) => !known.has(claim));
return { ok: mismatches.length === 0, mismatches };
}
export function settleSynastryDrafts(input: {
toolCalled: boolean;
material: unknown;
first: string;
second?: string;
}): {
text: string;
outcome: "tool_missing" | "pass" | "rewritten" | "fixed";
mismatches: string[];
} {
if (!input.toolCalled) {
return { text: "", outcome: "tool_missing", mismatches: [] };
}
const firstAudit = auditSynastryAnswer(input.first, input.material);
if (firstAudit.ok) {
return { text: input.first.trim(), outcome: "pass", mismatches: [] };
}
if (input.second !== undefined) {
const secondAudit = auditSynastryAnswer(input.second, input.material);
if (secondAudit.ok) {
return { text: input.second.trim(), outcome: "rewritten", mismatches: firstAudit.mismatches };
}
return { text: SYNASTRY_FIXED_REPLY, outcome: "fixed", mismatches: secondAudit.mismatches };
}
return { text: SYNASTRY_FIXED_REPLY, outcome: "fixed", mismatches: firstAudit.mismatches };
}
@@ -16,6 +16,24 @@ export type SynastrySectionId = (typeof SYNASTRY_SECTION_IDS)[number];
export const SYNASTRY_PROSE_IDS = SYNASTRY_SECTION_IDS.slice(0, 7);
/** Sections the report-end chat may bind. Practices stay on the page. */
export const SYNASTRY_ASK_SECTION_IDS = [
"climate",
"emotion",
"communication",
"safety",
"conflict",
"daily",
"direction",
"timeline",
] as const;
export type SynastryAskSectionId = (typeof SYNASTRY_ASK_SECTION_IDS)[number];
export function isSynastryAskSection(value: string): value is SynastryAskSectionId {
return (SYNASTRY_ASK_SECTION_IDS as readonly string[]).includes(value);
}
export const SECTION_TITLES: Readonly<Record<SynastrySectionId, Readonly<{ zh: string; en: string }>>> = {
climate: { zh: "关系基调", en: "Relationship Climate" },
emotion: { zh: "情感节奏", en: "Emotional Rhythm" },
@@ -0,0 +1,74 @@
import { synastryFollowupTitle, synastryPairNames, synastrySectionQuestion } from "@/lib/synastry-report-session";
import type { SynastryAskSectionId } from "@/lib/synastry-full-sections";
export const SYNASTRY_CHAT_DRAFT_KEY = "jyotisha.synastry-chat-draft";
type DraftRecord = { sessionId: string; question: string };
function draftStorage(): Storage | null {
try {
return globalThis.sessionStorage ?? null;
} catch {
return null;
}
}
export function stashSynastryChatDraft(sessionId: string, question: string): void {
const storage = draftStorage();
if (!storage) return;
storage.setItem(SYNASTRY_CHAT_DRAFT_KEY, JSON.stringify({ sessionId, question } satisfies DraftRecord));
}
export function takeSynastryChatDraft(sessionId: string): string | null {
const storage = draftStorage();
if (!storage) return null;
const raw = storage.getItem(SYNASTRY_CHAT_DRAFT_KEY);
if (!raw) return null;
storage.removeItem(SYNASTRY_CHAT_DRAFT_KEY);
try {
const parsed = JSON.parse(raw) as DraftRecord;
return parsed.sessionId === sessionId && parsed.question.trim() ? parsed.question : null;
} catch {
return null;
}
}
export async function beginSynastrySectionChat(input: {
reportId: string;
sectionId: SynastryAskSectionId;
language: "zh" | "en";
names: { a?: string; b?: string };
fetcher?: typeof fetch;
open?: (href: string) => void;
}): Promise<{ sessionId: string; question: string }> {
const fetcher = input.fetcher ?? fetch;
const models = await fetcher("/api/models", { credentials: "same-origin" });
const catalog = await models.json().catch(() => null) as { defaultModelId?: string } | null;
if (!models.ok || !catalog?.defaultModelId) throw new Error("暂时没有可用的模型");
const sessionId = globalThis.crypto.randomUUID();
const names = synastryPairNames({ report_document: { names: input.names } });
const question = synastrySectionQuestion(input.sectionId, input.language);
const created = await fetcher("/api/sessions", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: sessionId,
title: synastryFollowupTitle(names),
theme: "marriage",
model_id: catalog.defaultModelId,
messages: [],
session_type: "synastry_report",
rectification_case_id: null,
synastry_report_id: input.reportId,
synastry_section: input.sectionId,
}),
});
if (!created.ok) {
const payload = await created.json().catch(() => null) as { error?: string } | null;
throw new Error(payload?.error || "暂时无法打开对话");
}
stashSynastryChatDraft(sessionId, question);
(input.open ?? ((href: string) => { window.location.assign(href); }))(`/?c=${sessionId}`);
return { sessionId, question };
}
+285
View File
@@ -0,0 +1,285 @@
import "server-only";
import { NextResponse } from "next/server";
import { logAgentObservability } from "@/lib/agent-observability";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { consultationCompletionSchema, createConsultSettlement, first, retryDetachedSettlement } from "@/lib/consult/settlement";
import { CreditRpcError } from "@/lib/consultation-billing";
import { consultationAgentPublicEventSchema, type ConsultationAgentPublicEvent } from "@/lib/consultation-agent-events";
import { FeaturePricingError } from "@/lib/feature-pricing";
import { inputBlockedCopy, moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/lib/moderation";
import { recordModerationEvent } from "@/lib/moderation/log";
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { SYNASTRY_TOOL_MISSING_MESSAGE, generateSynastryChatReply } from "@/mastra/synastry-report-chat";
import { runSynastryReportTurn } from "@/lib/synastry-report-turn";
import { boundSynastryRead } from "@/lib/synastry-report-material";
import { isSynastryAskSection } from "@/lib/synastry-full-sections";
import { SNAPSHOT_SECTION } from "@/lib/synastry-full-run";
import { consumeUserRateLimitSet, userRateLimitDeniedResponse, userRateLimitMessages } from "@/lib/request-rate-limit";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
type Accounting = ReturnType<typeof createAdminSupabaseClient>;
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function historyOf(messages: unknown): Array<{ role: "user" | "assistant"; text: string }> {
if (!Array.isArray(messages)) return [];
return messages.flatMap((item) => {
const row = record(item);
if (!row) return [];
if ((row.role !== "user" && row.role !== "assistant") || typeof row.text !== "string") return [];
return [{ role: row.role, text: row.text }];
});
}
function answerLanguage(question: string): "zh" | "en" {
return /[\u4e00-\u9fff]/u.test(question) ? "zh" : "en";
}
function streamAnswer(requestId: string, reply: string, complete: () => Promise<void>, onError: () => Promise<void>) {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: ConsultationAgentPublicEvent) => {
controller.enqueue(encoder.encode(`${JSON.stringify(consultationAgentPublicEventSchema.parse(event))}\n`));
};
try {
await complete();
send({ type: "answer.delta", text: reply });
send({ type: "run.completed", responseKind: "smalltalk" });
} catch {
await onError().catch(() => undefined);
send({ type: "run.failed", code: "calculation_failed", message: "这次回复没能保存,请重试。" });
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"content-type": "application/x-ndjson; charset=utf-8",
"cache-control": "no-cache, no-transform",
"x-accel-buffering": "no",
"x-ayanam-mode": "synastry-report-chat",
"x-ayanam-request-id": requestId,
},
});
}
export async function handleSynastryReportConsult(input: {
request: Request;
userId: string;
parsed: { requestId: string; sessionId: string; modelId: string; question: string; theme: string };
session: {
model_id: string | null;
messages: unknown;
synastry_report_id?: string | null;
synastry_section?: string | null;
};
accounting: Accounting;
}): Promise<Response> {
const { userId, parsed, session, accounting } = input;
const reportId = session.synastry_report_id ?? null;
const sectionId = session.synastry_section ?? "";
if (!reportId || !isSynastryAskSection(sectionId)) {
return NextResponse.json(
{ error: "这份合盘已删除", code: "synastry_report_deleted", message: "这份合盘已删除" },
{ status: 409 },
);
}
if (!session.model_id || session.model_id !== parsed.modelId) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
const limited = consumeUserRateLimitSet(["consultMinute", "consultHour"], userId);
if (!limited.ok) {
const denied = userRateLimitDeniedResponse(limited.retryAfterSeconds, userRateLimitMessages.consult);
return NextResponse.json(denied.body, { status: denied.status, headers: denied.headers });
}
const history = historyOf(session.messages);
const visibleQuestion = parsed.question.trim();
if (blocksPromptExtraction([visibleQuestion, ...history.map((message) => message.text)].join("\n"))) {
return NextResponse.json(
{ error: "无法处理该请求", message: "我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。" },
{ status: 400 },
);
}
const moderationContext = { route: "consult", userId, requestId: parsed.requestId, modelId: parsed.modelId };
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
if (inputModeration.verdict === "block") {
return NextResponse.json(
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(inputModeration) },
{ status: 400 },
);
}
const sessionModel = await resolveSessionLanguageModel(session.model_id, null);
let usageStartedAt = 0;
const settlement = createConsultSettlement({
accounting,
userId,
requestId: parsed.requestId,
sessionId: parsed.sessionId,
visibleQuestion,
getUsageStartedAt: () => usageStartedAt,
getSelectedModel: () => {
if (!sessionModel) throw new Error("model_missing");
return sessionModel;
},
getClassification: () => ({ usage: {}, outcome: "synastry_report", durationMs: null }),
getLatencyState: () => null,
});
let reserved: Awaited<ReturnType<typeof reserveConsultationModel<NonNullable<typeof sessionModel>, { success: boolean; credits: number | null; error_code: string | null }>>>;
try {
reserved = await reserveConsultationModel(
session.model_id,
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
(model) => settlement.reserveCredit(model),
);
} catch (error) {
if (error instanceof FeaturePricingError) {
return NextResponse.json(
{ error: "计费配置不可用", message: "当前服务的计费配置尚未完成,请联系支持人员,本次不会扣点。", code: "pricing_configuration_unavailable" },
{ status: 503 },
);
}
return NextResponse.json({ error: "暂时无法确认咨询点数", message: "请稍后重试。" }, { status: 503 });
}
if (reserved.status === "unavailable" || !sessionModel) {
return NextResponse.json(
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
{ status: 409 },
);
}
if (!reserved.reservation.success) {
const insufficient = reserved.reservation.error_code === "insufficient_credits";
return NextResponse.json(
{
error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数",
message: insufficient ? "请先兑换咨询点数后再继续。" : reserved.reservation.error_code || "请稍后重试。",
},
{ status: insufficient ? 402 : 503 },
);
}
const appended = await settlement.appendQuestion();
if (appended) return appended;
usageStartedAt = Date.now();
const owned = await accounting.from("personal_reports")
.select("id,request_id,report_type,status")
.eq("id", reportId)
.eq("user_id", userId)
.maybeSingle();
const report = record(owned.data);
if (owned.error || !report || report.report_type !== "synastry_full" || report.status !== "ready" || typeof report.request_id !== "string") {
await settlement.cancel();
return NextResponse.json(
{ error: "这份合盘已删除", code: "synastry_report_deleted", message: "这份合盘已删除" },
{ status: 409 },
);
}
const stored = await accounting.from("personal_report_sections")
.select("payload")
.eq("user_id", userId)
.eq("request_id", report.request_id)
.eq("section_id", SNAPSHOT_SECTION)
.maybeSingle();
const payload = record(record(stored.data)?.payload);
if (stored.error || !payload) {
await settlement.cancel();
return NextResponse.json(
{ error: "合盘材料暂时读不到", message: "请稍后重试,本次不会扣点。" },
{ status: 503 },
);
}
const language = answerLanguage(visibleQuestion);
let turn: Awaited<ReturnType<typeof runSynastryReportTurn>>;
try {
turn = await runSynastryReportTurn({
question: visibleQuestion,
sectionId,
history,
readMaterial: (request) => boundSynastryRead({
boundReportId: reportId,
requestedReportId: reportId,
sectionId: request.section,
includeTechnical: request.includeTechnical,
payload,
language,
}).material,
generate: (messages, readMaterial) => generateSynastryChatReply({
model: sessionModel,
messages,
readMaterial,
signal: input.request.signal,
}),
});
} catch {
await settlement.cancel();
return NextResponse.json({ error: "这次没能完成回答", message: "请稍后重试,本次不会扣点。" }, { status: 503 });
}
logAgentObservability({
requestId: parsed.requestId,
sessionId: parsed.sessionId,
agentVersion: "synastry-report-chat-v1",
toolCalls: [{
name: "read-synastry-section",
durationMs: 0,
status: turn.outcome === "tool_missing" ? "failed" : "completed",
...(turn.outcome === "tool_missing" ? { failureCode: "tool-missing" } : {}),
}],
retryCount: turn.outcome === "rewritten" || turn.outcome === "fixed" ? 1 : 0,
claimCount: turn.mismatches.length,
...(turn.outcome === "fixed" ? { errorCode: "synastry-fact-unverified" } : {}),
...(turn.outcome === "tool_missing" ? { errorCode: "synastry-tool-missing" } : {}),
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
});
if (turn.outcome === "tool_missing" || !turn.text.trim()) {
await settlement.cancel();
return NextResponse.json(
{ error: SYNASTRY_TOOL_MISSING_MESSAGE, message: SYNASTRY_TOOL_MISSING_MESSAGE },
{ status: 503 },
);
}
const outputModeration = await moderate(turn.text, "output", moderationContext);
const blocked = outputModeration.verdict === "block";
if (outputModeration.verdict !== "pass") await recordModerationEvent(accounting, outputModeration, "output", moderationContext);
const reply = (blocked ? MODERATION_OUTPUT_REPLACED_COPY : turn.text).slice(0, 12_000);
const usage = settlement.usagePayload(Promise.resolve({ inputTokens: turn.inputTokens, outputTokens: turn.outputTokens }));
return streamAnswer(parsed.requestId, reply, async () => {
const actualUsage = await usage;
await retryDetachedSettlement(async () => {
const rpcName = blocked ? "complete_consultation_moderated" : "complete_consultation_response";
const responseMessage = blocked
? { role: "assistant", text: reply, responseKind: "moderated" }
: { role: "assistant", text: reply };
const { data, error } = await accounting.rpc(rpcName, {
p_user_id: userId,
p_request_id: parsed.requestId,
p_session_id: parsed.sessionId,
p_response_message: responseMessage,
p_actual_usage: actualUsage,
});
const result = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !result.success || !result.data.success) {
throw new CreditRpcError(error?.message || (result.success ? result.data.error_code : "invalid_completion") || "completion_rejected");
}
return result.data;
});
}, () => settlement.cancel().then(() => undefined));
}
@@ -0,0 +1,153 @@
import {
PRIVATE_KEY_NAMES,
SECTION_TITLES,
sectionEvidence,
type SynastryAskSectionId,
type SynastrySectionId,
} from "@/lib/synastry-full-sections";
export const SYNASTRY_CHAT_CHAR_BUDGET = 20_000;
type LanguageBody = {
sections?: Array<{ id?: string; title?: string; text?: string; reasons?: Array<{ plain?: string; basis?: string }> }>;
timeline?: Array<{ id?: string; phase?: string; text?: string; basis?: string }>;
practices?: Array<{ text?: string }>;
technical?: Array<{ id?: string; title?: string; lines?: string[] }>;
};
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function scrub(value: unknown): unknown {
if (Array.isArray(value)) return value.slice(0, 24).map(scrub);
const row = record(value);
if (!row) return value;
const next: Record<string, unknown> = {};
for (const [key, item] of Object.entries(row)) {
if (PRIVATE_KEY_NAMES.has(key)) continue;
next[key] = scrub(item);
}
return next;
}
function languageBody(payload: Record<string, unknown>, language: "zh" | "en"): LanguageBody | null {
const source = language === "en" ? record(payload.english) : record(payload.zh);
return source as LanguageBody | null;
}
function timingLines(payload: Record<string, unknown>): unknown {
const timing = record(payload.timingPeriods);
if (!timing) return null;
const picked: Record<string, unknown> = {};
for (const side of ["person_a", "person_b"] as const) {
if (!Array.isArray(timing[side])) continue;
picked[side] = (timing[side] as unknown[]).slice(0, 12).map((item) => {
const row = record(item);
if (!row) return null;
return scrub({
level: row.level,
lord: row.lord,
lordCn: row.lordCn,
start: row.start,
end: row.end,
});
}).filter(Boolean);
}
return Object.keys(picked).length > 0 ? picked : null;
}
export function synastrySectionMaterial(input: {
payload: unknown;
sectionId: SynastrySectionId;
includeTechnical?: boolean;
language?: "zh" | "en";
}): Record<string, unknown> {
const payload = record(input.payload) ?? {};
const language = input.language === "en" && record(payload.english) ? "en" : "zh";
const body = languageBody(payload, language) ?? languageBody(payload, "zh");
const names = record(payload.names);
const section = body?.sections?.find((item) => item.id === input.sectionId);
const reasons = (section?.reasons ?? []).slice(0, 3).map((reason) => ({
basis: typeof reason.basis === "string" ? reason.basis : "",
plain: typeof reason.plain === "string" ? reason.plain : "",
}));
const timeline = input.sectionId === "timeline"
? (body?.timeline ?? []).slice(0, 8).map((segment) => ({
phase: segment.phase ?? "",
text: segment.text ?? "",
basis: segment.basis ?? "",
}))
: undefined;
const material: Record<string, unknown> = {
sectionId: input.sectionId,
title: section?.title || SECTION_TITLES[input.sectionId][language],
names: {
a: typeof names?.a === "string" ? names.a : "",
b: typeof names?.b === "string" ? names.b : "",
},
text: section?.text ?? "",
reasons,
evidence: scrub(sectionEvidence(payload.dossier, input.sectionId, timeline, language)),
};
if (timeline) {
material.timeline = timeline;
const periods = timingLines(payload);
if (periods) material.timing = periods;
}
if (input.includeTechnical) {
const blocks = (record(payload.zh)?.technical ?? body?.technical) as LanguageBody["technical"];
material.technical = (blocks ?? []).map((block) => ({
id: block.id ?? "",
title: block.title ?? "",
lines: (block.lines ?? []).slice(0, 12),
}));
}
return fitSynastryMaterial(material, 16_000);
}
function dropHeavyEvidence(material: Record<string, unknown>): Record<string, unknown> {
const evidence = record(material.evidence);
if (!evidence) return material;
const next = { ...evidence };
delete next.pair_interaction_evidence;
delete next.relationship_lenses;
return { ...material, evidence: next };
}
export function fitSynastryMaterial(material: Record<string, unknown>, maxChars: number): Record<string, unknown> {
let next = material;
if (JSON.stringify(next).length > maxChars) next = dropHeavyEvidence(next);
if (JSON.stringify(next).length > maxChars) {
const rest = { ...next };
delete rest.timing;
delete rest.technical;
next = rest;
}
const encoded = JSON.stringify(next);
if (encoded.length <= maxChars) return next;
return { sectionId: next.sectionId, title: next.title, text: String(next.text ?? "").slice(0, 800), reasons: next.reasons };
}
export function synastryChatContextChars(instructions: string, material: unknown): number {
return instructions.length + JSON.stringify(material).length;
}
export function boundSynastryRead(input: {
boundReportId: string;
requestedReportId?: string;
sectionId: SynastryAskSectionId;
includeTechnical?: boolean;
payload: unknown;
language?: "zh" | "en";
}): { reportId: string; material: Record<string, unknown> } {
return {
reportId: input.boundReportId,
material: synastrySectionMaterial({
payload: input.payload,
sectionId: input.sectionId,
includeTechnical: input.includeTechnical,
language: input.language,
}),
};
}
@@ -0,0 +1,76 @@
import { isSynastryAskSection, SECTION_TITLES, type SynastryAskSectionId } from "@/lib/synastry-full-sections";
export class SynastrySessionError extends Error {
constructor(readonly code: "synastry_report_not_owned" | "synastry_section_invalid" | "synastry_report_not_ready") {
super(code);
this.name = "SynastrySessionError";
}
}
export type SynastrySessionBinding = {
title: string;
theme: "marriage";
chart_profile_id: string;
chart_profile_name: string;
chart_profile_role: "self" | "other";
synastry_report_id: string;
synastry_section: SynastryAskSectionId;
synastry_partner_subject_id: string;
rectification_case_id: null;
};
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
export function synastryPairNames(report: unknown): { a: string; b: string } {
const row = record(report);
const names = record(record(row?.report_document)?.names);
const a = typeof names?.a === "string" ? names.a.trim() : "";
const b = typeof names?.b === "string" ? names.b.trim() : "";
if (a && b) return { a: a.slice(0, 40), b: b.slice(0, 40) };
const card = typeof row?.card_summary === "string" ? row.card_summary : "";
const [left, right] = card.split("×").map((part) => part.trim());
return {
a: (left || "甲").slice(0, 40),
b: (right?.split("·")[0]?.trim() || "乙").slice(0, 40),
};
}
export function synastrySectionQuestion(sectionId: SynastryAskSectionId, language: "zh" | "en"): string {
const title = SECTION_TITLES[sectionId][language];
if (language === "en") {
return `In this synastry report, what is the basis for “${title}” in our two charts?`;
}
return `合盘报告里「${title}」这一段,在我们两人的盘里依据是什么?`;
}
export function synastryFollowupTitle(names: { a: string; b: string }): string {
return `${names.a} × ${names.b} · 合盘追问`.slice(0, 160);
}
export async function authoritativeSynastrySession(input: {
reportId: string;
section: string;
loadOwnedReport: (reportId: string) => Promise<unknown>;
}): Promise<SynastrySessionBinding> {
if (!isSynastryAskSection(input.section)) throw new SynastrySessionError("synastry_section_invalid");
const row = record(await input.loadOwnedReport(input.reportId));
if (!row || row.report_type !== "synastry_full") throw new SynastrySessionError("synastry_report_not_owned");
if (row.status !== "ready") throw new SynastrySessionError("synastry_report_not_ready");
const subjectLow = typeof row.subject_low === "string" ? row.subject_low : "";
const subjectHigh = typeof row.subject_high === "string" ? row.subject_high : "";
if (!subjectLow || !subjectHigh) throw new SynastrySessionError("synastry_report_not_owned");
const names = synastryPairNames(row);
return {
title: synastryFollowupTitle(names),
theme: "marriage",
chart_profile_id: subjectLow,
chart_profile_name: `${names.a} × ${names.b}`.slice(0, 80),
chart_profile_role: subjectLow === "self" ? "self" : "other",
synastry_report_id: input.reportId,
synastry_section: input.section,
synastry_partner_subject_id: subjectHigh,
rectification_case_id: null,
};
}
+118
View File
@@ -0,0 +1,118 @@
import { SYNASTRY_CHAT_INSTRUCTIONS, synastryRewriteNote } from "@/mastra/synastry-report-chat";
import {
SYNASTRY_CHAT_CHAR_BUDGET,
synastryChatContextChars,
synastrySectionMaterial,
} from "@/lib/synastry-report-material";
import { settleSynastryDrafts } from "@/lib/synastry-chat-fact-check";
import { SECTION_TITLES, type SynastryAskSectionId } from "@/lib/synastry-full-sections";
type Material = ReturnType<typeof synastrySectionMaterial>;
type MaterialRequest = { section: SynastryAskSectionId; includeTechnical: boolean };
export type SynastryTurnResult = {
text: string;
outcome: "tool_missing" | "pass" | "rewritten" | "fixed";
mismatches: string[];
toolCalls: number;
inputTokens: number;
outputTokens: number;
contextChars: number;
};
type GenerateResult = {
text: string;
toolCalls: number;
inputTokens: number;
outputTokens: number;
};
export async function runSynastryReportTurn(input: {
question: string;
sectionId: SynastryAskSectionId;
history: ReadonlyArray<{ role: "user" | "assistant"; text: string }>;
readMaterial: (request: MaterialRequest) => Material;
generate: (
messages: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string }>,
readMaterial: (request: MaterialRequest) => Material,
) => Promise<GenerateResult>;
}): Promise<SynastryTurnResult> {
const pieces: Material[] = [];
const readMaterial = (request: MaterialRequest) => {
const material = input.readMaterial(request);
pieces.push(material);
return material;
};
const history = input.history.slice(-6).map((message) => `${message.role === "user" ? "用户" : "回答"}:${message.text.slice(0, 500)}`);
const opening = [
`这一节是「${SECTION_TITLES[input.sectionId].zh}」(${input.sectionId})。`,
history.length ? `此前对话:\n${history.join("\n")}` : "",
`用户这一轮:${input.question}`,
"先调用 readSynastrySection,再回答。",
].filter(Boolean).join("\n\n");
const first = await input.generate([
{ role: "system", content: SYNASTRY_CHAT_INSTRUCTIONS },
{ role: "user", content: opening },
], readMaterial);
const material = { sections: pieces };
const contextChars = synastryChatContextChars(SYNASTRY_CHAT_INSTRUCTIONS, material);
if (contextChars > SYNASTRY_CHAT_CHAR_BUDGET) {
throw new Error("synastry_chat_context_too_large");
}
if (first.toolCalls < 1) {
return {
text: "",
outcome: "tool_missing",
mismatches: [],
toolCalls: first.toolCalls,
inputTokens: first.inputTokens,
outputTokens: first.outputTokens,
contextChars,
};
}
const firstAudit = settleSynastryDrafts({
toolCalled: true,
material,
first: first.text,
});
if (firstAudit.outcome === "pass") {
return {
text: firstAudit.text,
outcome: "pass",
mismatches: [],
toolCalls: first.toolCalls,
inputTokens: first.inputTokens,
outputTokens: first.outputTokens,
contextChars,
};
}
const second = await input.generate([
{ role: "system", content: SYNASTRY_CHAT_INSTRUCTIONS },
{ role: "user", content: opening },
{ role: "assistant", content: first.text },
{ role: "user", content: synastryRewriteNote(firstAudit.mismatches) },
], readMaterial);
const settled = settleSynastryDrafts({
toolCalled: true,
material: { sections: pieces },
first: first.text,
second: second.text,
});
const settledChars = synastryChatContextChars(SYNASTRY_CHAT_INSTRUCTIONS, { sections: pieces });
if (settledChars > SYNASTRY_CHAT_CHAR_BUDGET) {
throw new Error("synastry_chat_context_too_large");
}
return {
text: settled.text,
outcome: settled.outcome === "pass" ? "rewritten" : settled.outcome,
mismatches: settled.mismatches,
toolCalls: first.toolCalls + second.toolCalls,
inputTokens: first.inputTokens + second.inputTokens,
outputTokens: first.outputTokens + second.outputTokens,
contextChars: settledChars,
};
}
export function synastryMissingCoverage(text: string): boolean {
return text.includes("这份合盘没算到") || text.includes("没有算到");
}
@@ -0,0 +1,69 @@
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { SYNASTRY_ASK_SECTION_IDS, type SynastryAskSectionId } from "@/lib/synastry-full-sections";
import type { ResolvedLanguageModel } from "./model.ts";
export const SYNASTRY_CHAT_INSTRUCTIONS = [
"你在解释一份已经写好的婚恋合盘报告。用户问的是某一节在两人盘里的依据。",
"回答前必须先调用 readSynastrySection。没拿到材料就不要写盘面。",
"只使用工具返回的内容。可以写星曜、宫位、星座、相位、大运和起止。不要重新排盘,不要补充材料里没有的盘面事实。",
"材料没写到的,直接说「这份合盘没算到」,并建议回到各自的盘单独问。",
"口吻像一个懂行、说话温和的姐姐。说「你们」,不替两个人做决定。",
"不说合不合、注定、必然、灵魂伴侣。不写某年结婚、领证或分开。",
"不要索要或复述出生日期、出生地、经纬度。用户用英文问,就用英文答。",
].join("\n");
export const SYNASTRY_TOOL_MISSING_MESSAGE = "这次没能读到合盘材料,请再试一次。";
export function synastryRewriteNote(mismatches: readonly string[]): string {
return [
"上一稿里这些盘面说法在工具材料里对不上:",
mismatches.slice(0, 8).join(";"),
"请只根据已经读到的材料重写。对不上的地方写成「这份合盘没算到」。",
].join("\n");
}
export async function generateSynastryChatReply(input: {
model: ResolvedLanguageModel;
messages: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string }>;
readMaterial: (request: { section: SynastryAskSectionId; includeTechnical: boolean }) => unknown;
signal?: AbortSignal;
}): Promise<{ text: string; toolCalls: number; inputTokens: number; outputTokens: number }> {
let toolCalls = 0;
const tool = createTool({
id: "read-synastry-section",
description: "读取本会话绑定的合盘报告某一节已经存下的材料。只能读这一份报告。回答前必须先调用。",
inputSchema: z.object({
section: z.enum(SYNASTRY_ASK_SECTION_IDS),
includeTechnical: z.boolean().optional(),
}).strict(),
execute: async (args) => {
toolCalls += 1;
return input.readMaterial({
section: args.section,
includeTechnical: args.includeTechnical === true,
});
},
});
const agent = new Agent({
id: "synastry-report-chat",
name: "Synastry Report Chat",
instructions: SYNASTRY_CHAT_INSTRUCTIONS,
model: input.model.model,
tools: { readSynastrySection: tool },
});
const result = await agent.generate(input.messages as Parameters<Agent["generate"]>[0], {
abortSignal: input.signal,
maxSteps: 4,
});
const usage = await Promise.resolve((result as { usage?: { inputTokens?: number; outputTokens?: number } }).usage);
const text = typeof (result as { text?: unknown }).text === "string" ? (result as { text: string }).text : "";
return {
text,
toolCalls,
inputTokens: Math.max(0, usage?.inputTokens ?? 0),
outputTokens: Math.max(0, usage?.outputTokens ?? 0),
};
}
@@ -0,0 +1,526 @@
begin;
-- Synastry report chat (issue #83). Add-only: a session type, the report
-- binding, and the same chat.standard settlement for that type.
alter table public.chat_sessions
drop constraint if exists chat_sessions_session_type_check;
alter table public.chat_sessions
add constraint chat_sessions_session_type_check
check (session_type in ('consultation', 'birth_time_rectification', 'synastry_report'));
alter table public.chat_sessions
add column if not exists synastry_report_id uuid references public.personal_reports(id) on delete set null,
add column if not exists synastry_section text,
add column if not exists synastry_partner_subject_id text;
alter table public.chat_sessions
drop constraint if exists chat_sessions_synastry_section_check;
alter table public.chat_sessions
add constraint chat_sessions_synastry_section_check
check (
synastry_section is null
or synastry_section in ('climate', 'emotion', 'communication', 'safety', 'conflict', 'daily', 'direction', 'timeline')
);
alter table public.chat_sessions
drop constraint if exists chat_sessions_synastry_partner_subject_check;
alter table public.chat_sessions
add constraint chat_sessions_synastry_partner_subject_check
check (
synastry_partner_subject_id is null
or synastry_partner_subject_id = 'self'
or synastry_partner_subject_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
);
create index if not exists chat_sessions_synastry_report_idx
on public.chat_sessions (synastry_report_id)
where synastry_report_id is not null;
create index if not exists chat_sessions_synastry_partner_idx
on public.chat_sessions (user_id, synastry_partner_subject_id)
where synastry_partner_subject_id is not null;
grant insert (synastry_report_id, synastry_section, synastry_partner_subject_id)
on table public.chat_sessions to authenticated;
create or replace function public.chat_sessions_guard_synastry()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
v_owner uuid;
v_type text;
v_status text;
begin
if new.session_type = 'synastry_report' then
if new.synastry_section is null or new.synastry_section not in (
'climate', 'emotion', 'communication', 'safety', 'conflict', 'daily', 'direction', 'timeline'
) then
raise exception 'synastry_section_invalid' using errcode = '23514';
end if;
if new.synastry_partner_subject_id is null
or (
new.synastry_partner_subject_id <> 'self'
and new.synastry_partner_subject_id !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
) then
raise exception 'synastry_partner_missing' using errcode = '23514';
end if;
if tg_op = 'UPDATE'
and new.synastry_report_id is distinct from old.synastry_report_id
and new.synastry_report_id is not null then
raise exception 'synastry_report_binding_locked' using errcode = '23514';
end if;
if new.synastry_report_id is not null then
select report.user_id, report.report_type, report.status
into v_owner, v_type, v_status
from public.personal_reports as report
where report.id = new.synastry_report_id;
if v_owner is distinct from new.user_id or v_type is distinct from 'synastry_full' then
raise exception 'synastry_report_not_owned' using errcode = '23514';
end if;
if v_status is distinct from 'ready' then
raise exception 'synastry_report_not_ready' using errcode = '23514';
end if;
end if;
return new;
end if;
if new.synastry_report_id is not null
or new.synastry_section is not null
or new.synastry_partner_subject_id is not null then
raise exception 'synastry_fields_forbidden' using errcode = '23514';
end if;
return new;
end;
$$;
drop trigger if exists chat_sessions_guard_synastry on public.chat_sessions;
create trigger chat_sessions_guard_synastry
before insert or update on public.chat_sessions
for each row execute function public.chat_sessions_guard_synastry();
revoke all on function public.chat_sessions_guard_synastry() from public, anon, authenticated;
create or replace function public.reserve_consultation_usage(
p_user_id uuid,
p_request_id text,
p_session_id uuid,
p_requested_model_id text,
p_credit_cost integer
)
returns table(
success boolean,
reservation_id uuid,
source text,
credits integer,
subscription_id uuid,
reason text,
retry_after_seconds integer
)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_authorization record;
begin
if p_user_id is null or p_session_id is null or btrim(coalesce(p_request_id, '')) = '' then
return query select false, null::uuid, null::text, null::integer,
null::uuid, 'invalid_request'::text, null::integer;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0));
if not exists (
select 1
from public.chat_sessions as session
where session.id = p_session_id
and session.user_id = p_user_id
and (session.session_type = 'consultation' or (session.session_type = 'synastry_report' and session.synastry_report_id is not null))
) then
return query select false, null::uuid, null::text,
(select profile.credits from public.profiles as profile where profile.id = p_user_id),
null::uuid, 'session_missing'::text, null::integer;
return;
end if;
if exists (
select 1
from public.consultation_requests as request
where request.user_id = p_user_id
and request.request_id = btrim(p_request_id)
) then
return query select false, null::uuid, null::text,
(select profile.credits from public.profiles as profile where profile.id = p_user_id),
null::uuid, 'request_conflict'::text, null::integer;
return;
end if;
select * into v_authorization
from public.authorize_usage(
p_user_id,
'chat.standard',
p_requested_model_id,
btrim(p_request_id),
p_credit_cost
);
if coalesce(v_authorization.success, false) then
insert into public.consultation_requests(user_id, request_id, session_id, status)
values (p_user_id, btrim(p_request_id), p_session_id, 'reserved');
end if;
return query select
v_authorization.success,
v_authorization.reservation_id,
v_authorization.source,
v_authorization.credits,
v_authorization.subscription_id,
v_authorization.reason,
v_authorization.retry_after_seconds;
end;
$$;
revoke all on function public.reserve_consultation_usage(uuid, text, uuid, text, integer)
from public, anon, authenticated;
grant execute on function public.reserve_consultation_usage(uuid, text, uuid, text, integer)
to service_role;
do $$ begin
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
grant execute on function public.reserve_consultation_usage(uuid, text, uuid, text, integer)
to admin_runtime;
end if;
end $$;
create or replace function public.complete_consultation_response(
p_user_id uuid,
p_request_id text,
p_session_id uuid,
p_response_message jsonb,
p_actual_usage jsonb
)
returns table(success boolean, credits integer, error_code text)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_request public.consultation_requests%rowtype;
v_settlement record;
v_balance integer;
begin
if p_user_id is null or p_session_id is null or btrim(coalesce(p_request_id, '')) = '' then
return query select false, null::integer, 'invalid_request'::text;
return;
end if;
if jsonb_typeof(p_response_message) <> 'object'
or p_response_message->>'role' <> 'assistant'
or btrim(coalesce(p_response_message->>'text', '')) = '' then
return query select false, null::integer, 'invalid_response_message'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0));
select request.* into v_request
from public.consultation_requests as request
where request.user_id = p_user_id
and request.request_id = btrim(p_request_id)
and request.session_id = p_session_id
for update;
if not found then
return query select false, null::integer, 'request_missing'::text;
return;
end if;
if v_request.status = 'cancelled' then
return query select false, null::integer, 'request_cancelled'::text;
return;
end if;
if v_request.status = 'completed' then
select profile.credits into v_balance from public.profiles as profile where profile.id = p_user_id;
return query select v_request.response_message = p_response_message, v_balance,
case when v_request.response_message = p_response_message then null::text else 'response_conflict'::text end;
return;
end if;
update public.chat_sessions as session
set messages = session.messages || jsonb_build_array(p_response_message),
updated_at = clock_timestamp()
where session.id = p_session_id
and session.user_id = p_user_id
and (session.session_type = 'consultation' or (session.session_type = 'synastry_report' and session.synastry_report_id is not null));
if not found then
return query select false, null::integer, 'session_missing'::text;
return;
end if;
update public.consultation_requests
set response_message = p_response_message,
updated_at = clock_timestamp()
where user_id = p_user_id and request_id = btrim(p_request_id);
select * into v_settlement
from public.complete_usage(p_user_id, btrim(p_request_id), coalesce(p_actual_usage, '{}'::jsonb));
if not coalesce(v_settlement.success, false) then
raise exception 'consultation_usage_settlement_failed:%', coalesce(v_settlement.error_code, 'unknown');
end if;
update public.consultation_requests
set status = 'completed', updated_at = clock_timestamp()
where user_id = p_user_id and request_id = btrim(p_request_id);
return query select true, v_settlement.credits, null::text;
end;
$$;
revoke all on function public.complete_consultation_response(uuid, text, uuid, jsonb, jsonb)
from public, anon, authenticated;
grant execute on function public.complete_consultation_response(uuid, text, uuid, jsonb, jsonb)
to service_role;
do $$ begin
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
grant execute on function public.complete_consultation_response(uuid, text, uuid, jsonb, jsonb)
to admin_runtime;
end if;
end $$;
create or replace function public.append_consultation_question(
p_user_id uuid,
p_request_id text,
p_session_id uuid,
p_question_message jsonb
)
returns table(success boolean, error_code text)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_session public.chat_sessions%rowtype;
v_message jsonb;
v_text text;
v_request_id text;
v_count integer;
v_chars integer;
v_new_chars integer;
v_physical_chars bigint;
v_new_physical bigint;
begin
v_request_id := btrim(coalesce(p_request_id, ''));
if p_user_id is null or p_session_id is null or v_request_id = '' then
return query select false, 'invalid_request'::text;
return;
end if;
if jsonb_typeof(p_question_message) <> 'object'
or p_question_message->>'role' <> 'user' then
return query select false, 'invalid_question_message'::text;
return;
end if;
v_text := btrim(coalesce(p_question_message->>'text', ''));
if v_text = '' or char_length(v_text) > 16000 then
return query select false, 'invalid_question_message'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || v_request_id, 0));
select session.* into v_session
from public.chat_sessions as session
where session.id = p_session_id
and session.user_id = p_user_id
and (session.session_type = 'consultation' or (session.session_type = 'synastry_report' and session.synastry_report_id is not null))
for update;
if not found then
return query select false, 'session_missing'::text;
return;
end if;
if exists (
select 1
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem
where elem->>'requestId' = v_request_id
) then
return query select true, null::text;
return;
end if;
v_message := p_question_message || jsonb_build_object('requestId', v_request_id);
v_count := jsonb_array_length(coalesce(v_session.messages, '[]'::jsonb));
select
coalesce(sum(length(coalesce(elem->>'text', ''))), 0),
coalesce(sum(length(elem::text)), 0)
into v_chars, v_physical_chars
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem;
v_new_chars := length(v_text);
v_new_physical := length(v_message::text);
if v_count >= 200
or (v_chars + v_new_chars) > 200000
or (v_physical_chars + v_new_physical) > 1000000 then
return query select false, 'session_full'::text;
return;
end if;
update public.chat_sessions as session
set messages = coalesce(session.messages, '[]'::jsonb) || jsonb_build_array(v_message),
title = case
when coalesce(btrim(session.title), '') in ('', '新对话') then
case
when char_length(v_text) > 14 then left(v_text, 14) || '…'
else v_text
end
else session.title
end,
updated_at = clock_timestamp()
where session.id = p_session_id
and session.user_id = p_user_id
and (session.session_type = 'consultation' or (session.session_type = 'synastry_report' and session.synastry_report_id is not null));
if not found then
return query select false, 'session_missing'::text;
return;
end if;
return query select true, null::text;
end;
$$;
revoke all on function public.append_consultation_question(uuid, text, uuid, jsonb)
from public, anon, authenticated;
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
to service_role;
do $$ begin
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
to admin_runtime;
end if;
end $$;
create or replace function public.complete_consultation_moderated(
p_user_id uuid,
p_request_id text,
p_session_id uuid,
p_response_message jsonb,
p_actual_usage jsonb
)
returns table(success boolean, credits integer, error_code text)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_request public.consultation_requests%rowtype;
v_res public.usage_reservations%rowtype;
v_settlement record;
v_balance integer;
begin
if p_user_id is null or p_session_id is null or btrim(coalesce(p_request_id, '')) = '' then
return query select false, null::integer, 'invalid_request'::text;
return;
end if;
if jsonb_typeof(p_response_message) is distinct from 'object'
or p_response_message->>'role' is distinct from 'assistant'
or p_response_message->>'responseKind' is distinct from 'moderated'
or jsonb_typeof(p_response_message->'text') is distinct from 'string'
or btrim(coalesce(p_response_message->>'text', '')) = ''
or length(p_response_message->>'text') > 200
or (p_response_message - array['role', 'text', 'responseKind']) <> '{}'::jsonb then
return query select false, null::integer, 'invalid_response_message'::text;
return;
end if;
if jsonb_typeof(p_actual_usage) is distinct from 'object' then
return query select false, null::integer, 'invalid_actual_usage'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0));
select request.* into v_request
from public.consultation_requests as request
where request.user_id = p_user_id
and request.request_id = btrim(p_request_id)
and request.session_id = p_session_id
for update;
if not found then
return query select false, null::integer, 'request_missing'::text;
return;
end if;
if v_request.status = 'cancelled' then
return query select false, null::integer, 'request_cancelled'::text;
return;
end if;
if v_request.status = 'completed' then
select profile.credits into v_balance from public.profiles as profile where profile.id = p_user_id;
return query select coalesce(v_request.response_message = p_response_message, false), v_balance,
case when v_request.response_message = p_response_message then null::text else 'response_conflict'::text end;
return; -- Never refund a previously completed paid consultation.
end if;
if v_request.status <> 'reserved' then
return query select false, null::integer, 'invalid_request_status'::text;
return;
end if;
select reservation.* into v_res from public.usage_reservations as reservation
where reservation.user_id = p_user_id and reservation.request_id = btrim(p_request_id)
for update;
if not found or v_res.status <> 'reserved' or v_res.feature_key <> 'chat.standard' then
return query select false, null::integer, 'invalid_reservation'::text;
return;
end if;
select profile.credits into v_balance from public.profiles as profile
where profile.id = p_user_id for update;
if not found then
return query select false, null::integer, 'profile_missing'::text;
return;
end if;
update public.chat_sessions as session
set messages = session.messages || jsonb_build_array(p_response_message), updated_at = clock_timestamp()
where session.id = p_session_id and session.user_id = p_user_id and (session.session_type = 'consultation' or (session.session_type = 'synastry_report' and session.synastry_report_id is not null));
if not found then
return query select false, null::integer, 'session_missing'::text;
return;
end if;
-- complete_usage is the sole cost ledger writer; it does not debit credits.
-- Keep cost even though the reservation is released below (also frees subscription quota).
select * into v_settlement from public.complete_usage(
p_user_id, btrim(p_request_id),
p_actual_usage || jsonb_build_object('metadata', coalesce(p_actual_usage->'metadata', '{}'::jsonb)
|| jsonb_build_object('responseKind', 'moderated', 'freeCompletion', true))
);
if not coalesce(v_settlement.success, false) then
raise exception 'consultation_moderated_usage_settlement_failed:%', coalesce(v_settlement.error_code, 'unknown');
end if;
-- Same refund amount, balance lock and transaction identity as release_usage.
-- Only reachable from a reserved request and reserved usage row under the shared lock.
if v_res.source = 'credits' and v_res.credit_amount > 0 then
update public.profiles as profile
set credits = profile.credits + v_res.credit_amount, updated_at = clock_timestamp()
where profile.id = p_user_id returning profile.credits into v_balance;
insert into public.credit_transactions(user_id, transaction_type, amount, balance_after, request_id, model)
values(p_user_id, 'refund', v_res.credit_amount, v_balance, v_res.request_id, v_res.requested_model_id);
-- No ON CONFLICT: an inconsistent prior refund must roll back everything, never double-credit.
end if;
update public.usage_reservations
set status = 'released', released_at = clock_timestamp(), release_reason = 'consultation_output_moderated'
where id = v_res.id;
update public.consultation_requests
set status = 'completed', response_message = p_response_message, updated_at = clock_timestamp()
where user_id = p_user_id and request_id = btrim(p_request_id);
return query select true, v_balance, null::text;
end;
$$;
revoke all on function public.complete_consultation_moderated(uuid, text, uuid, jsonb, jsonb)
from public, anon, authenticated;
grant execute on function public.complete_consultation_moderated(uuid, text, uuid, jsonb, jsonb)
to service_role;
commit;
@@ -157,7 +157,10 @@ test("standard consultation resolves and settles the session-pinned model versio
// 原值: select includes chart_profile_role but not chart_profile_id or chart_profile_name.
// 新值: the same select also reads chart_profile_id and chart_profile_name.
// 原因: ordinary chat resolves the subject from the stored binding, not from chart_profile_role alone.
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary"\)/);
// 原值: select 到 context_summary 结束
// 新值: 后面加上 synastry_report_id,synastry_section,synastry_partner_subject_id
// 原因: 合盘追问要在同一条会话读取里认出报告和节,普通咨询仍用原来的列
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary,synastry_report_id,synastry_section,synastry_partner_subject_id"\)/);
assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
// 原值: route.ts 的 usagePayload 写 actualModelId 与 modelConfigVersion
// 新值: 这两字段在 settlement.ts 的 usagePayload
+4 -1
View File
@@ -54,9 +54,12 @@ test("generating does not disable the textarea; Enter queues instead of dropping
// 原值:`inputDisabled={sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!profileComplete && ...)}`
// 新值:表达式前增加 `subjectDeleted ||`,其余结构性禁用条件不变。
// 原因:BUG-1000 已删除人物的历史会话只读;BUG-1014 同步合同,不改生成中仍可输入的性质。
// 原值:subjectDeleted 开头,资料不全仍禁用
// 新值:合盘已删除时禁用;合盘追问不因查看者资料不全而禁用
// 原因:#83 报告删除后只读,追问不要求查看者自己的出生资料
assert.match(
page,
/inputDisabled=\{subjectDeleted \|\| sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| activeRectificationSession \|\| \(!profileComplete && \(onboardingStep !== "name" \|\| !presetMessageFinished \|\| profileSaving\)\)\}/,
/inputDisabled=\{synastryClosed \|\| subjectDeleted \|\| sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| activeRectificationSession \|\| \(!synastryChat && !profileComplete && \(onboardingStep !== "name" \|\| !presetMessageFinished \|\| profileSaving\)\)\}/,
);
assert.doesNotMatch(page, /inputDisabled=\{isLoading \|\| sessionMessagesLoading \|\| cancellationPending/);
assert.match(page, /if \(isLoading \|\| cancellationPending\) return void enqueueQueuedDraft\(composerDraftSnapshot\(\)\);/);
@@ -36,9 +36,12 @@ test("session list GET omits messages while detail GET returns them", () => {
// 原值:列表列含 created_at,供副标题用创建时间
// 新值:SESSION_LIST_COLUMNS 不含 created_at
// 原因:副标题改最后活动时间,与排序/分组/游标同源(BUG-988)
// 原值:列表列到 chart_profile_role 后直接是 updated_at
// 新值:中间增加 synastry_report_id,synastry_section,synastry_partner_subject_id
// 原因:合盘追问要在侧栏显示任一方,并在报告删除后仍能读到节与对方
assert.match(
listRoute,
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/,
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,synastry_report_id,synastry_section,synastry_partner_subject_id,updated_at,pinned,archived_at"/,
);
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*created_at/);
// 原值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages
@@ -52,7 +55,7 @@ test("session list GET omits messages while detail GET returns them", () => {
assert.match(itemRoute, /export async function GET/);
assert.match(
itemRoute,
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"/,
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,synastry_report_id,synastry_section,synastry_partner_subject_id,created_at,updated_at,pinned,archived_at"/,
);
assert.match(page, /async function fetchSessionDetail\(/);
assert.match(page, /async function ensureSessionMessages\(/);
+5 -1
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
chatSessionCreateFields,
chatSessionCreateInsertRow,
chatSessionCreateSchema,
chatSessionMetadataPatchSchema,
@@ -56,7 +57,10 @@ test("create schema accepts continued_from_session_id and rejects client-supplie
});
assert.equal(parsed.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
assert.equal("context_summary" in parsed, false);
assert.equal("context_summary" in chatSessionCreateSchema.shape, false);
// 原值:chatSessionCreateSchema.shape
// 新值:chatSessionCreateFields.shape
// 原因:合盘创建在字段表上做交叉校验,ZodEffects 没有 shape;字段表仍不收 context_summary
assert.equal("context_summary" in chatSessionCreateFields.shape, false);
const withSummary = chatSessionCreateSchema.safeParse({
id: sessionId,
@@ -94,8 +94,11 @@ test("the composer keeps its Chinese input, focus and accessibility contract", (
assert.match(composerSource, /title=\{stopTitle\}/);
assert.match(composerSource, /aria-label=\{submitLabel\} disabled=/);
assert.equal(composerSource.match(/size="icon"/g)?.length, 2);
assert.match(pageSource, /inputLabel=\{!profileComplete && onboardingStep === "name" \? "输入你的称呼" : "输入你的问题"\}/);
assert.match(pageSource, /submitLabel=\{!profileComplete && onboardingStep === "name" \? "确认称呼" : "发送"\}/);
// 原值: 资料未完成且停在称呼步时,标签是「输入你的称呼 / 确认称呼」
// 新值: 合盘追问不走这套称呼标签
// 原因: #83 追问要发出预填问题,不能把发送按钮改成确认称呼
assert.match(pageSource, /inputLabel=\{!synastryChat && !profileComplete && onboardingStep === "name" \? "输入你的称呼" : "输入你的问题"\}/);
assert.match(pageSource, /submitLabel=\{!synastryChat && !profileComplete && onboardingStep === "name" \? "确认称呼" : "发送"\}/);
});
test("every external draft writer keeps working through the page-owned setters", () => {
@@ -38,8 +38,11 @@ const postgresFixture = readFileSync(
// 原值:列到 updated_at,pinned,archived_at
// 新值:加上 created_at
// 原因:任务书 T4 侧栏副标题要用创建时间
// 原值:人物字段后直接是 created_at
// 新值:中间加上 synastry_report_id,synastry_section,synastry_partner_subject_id
// 原因:合盘追问的详情要读到报告、节和对方
const SESSION_SELECT =
"id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at";
"id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,synastry_report_id,synastry_section,synastry_partner_subject_id,created_at,updated_at,pinned,archived_at";
function functionBody(source: string): string {
const match = source.match(/as \$\$\r?\n([\s\S]*?)\r?\n\$\$;/);
@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const userId = "83000000-0000-4000-8000-000000000083";
const otherUserId = "83000000-0000-4000-8000-000000000183";
const personId = "83000000-0000-4000-8000-000000000283";
const partnerId = "83000000-0000-4000-8000-000000000383";
const reportId = "83000000-0000-4000-8000-000000000483";
const otherReportId = "83000000-0000-4000-8000-000000000583";
const sessionId = "83000000-0000-4000-8000-000000000683";
const requestId = "83000000-0000-4000-8000-000000000783";
const otherRequestId = "83000000-0000-4000-8000-000000000883";
const fingerprint = "a".repeat(64);
const skillHash = "d".repeat(64);
function dockerAvailable(): boolean {
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8", stdio: "ignore" }).status === 0;
}
function errorText(error: unknown): string {
if (!error || typeof error !== "object") return String(error);
const stderr = "stderr" in error ? String((error as { stderr?: unknown }).stderr ?? "") : "";
const stdout = "stdout" in error ? String((error as { stdout?: unknown }).stdout ?? "") : "";
return `${stderr}\n${stdout}\n${String(error)}`;
}
test("synastry chat binds an owned section, rejects another report, and stays readable after delete", {
skip: dockerAvailable() ? false : "docker unavailable on this host",
}, () => {
const fixture = startPostgresFixture();
try {
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") },
});
assert.equal(migration.status, 0, `${migration.stdout}${migration.stderr}`);
fixture.psql(`
insert into identity.users (id, name, email, email_verified, email_verified_at)
values
('${userId}', 'Fictional Synastry Chat', '${userId}@example.invalid', true, now()),
('${otherUserId}', 'Fictional Other', '${otherUserId}@example.invalid', true, now());
update public.profiles set credits = 10 where id = '${userId}';
insert into public.chart_profiles (id, user_id, role, profile)
values
('${personId}', '${userId}', 'other', '{}'::jsonb),
('${partnerId}', '${userId}', 'other', '{}'::jsonb);
`);
const insertReport = (id: string, owner: string, request: string, person: string, partner: string) => `
insert into public.personal_reports (
id, user_id, chart_profile_id, partner_chart_profile_id, request_id, request_fingerprint,
report_type, status, schema_version, presentation_mode, requested_themes, depth,
skill_name, skill_version, skill_source_commit, skill_snapshot_sha256,
subject_low, subject_high, fingerprint_low, fingerprint_high, synastry_rules_version
) values (
'${id}', '${owner}', '${person}', '${partner}', '${request}', '${fingerprint}',
'synastry_full', 'generating', 'synastry_report.v1', 'default', array[]::text[], 'standard',
'jyotish-full-synastry-reading', '1.0.0', '${"e".repeat(40)}', '${skillHash}',
'${person}', '${partner}', '${fingerprint}', '${"b".repeat(64)}', 'bde5d615:marriage_match_product_v1'
);
`;
fixture.psql(insertReport(reportId, userId, requestId, personId, partnerId));
fixture.psql(`
update public.personal_reports
set status = 'ready', completed_at = now(), report_document = '{"names":{"a":"甲","b":"乙"}}'::jsonb,
calculation_hash = '${skillHash}', evidence_hash = '${skillHash}'
where id = '${reportId}';
update public.personal_report_jobs
set status = 'cancelled', finished_at = now()
where user_id = '${userId}' and status = 'queued';
`);
fixture.psql(insertReport(otherReportId, otherUserId, otherRequestId, personId, partnerId));
fixture.psql(`
insert into public.chat_sessions (
id, user_id, title, theme, messages, session_type,
synastry_report_id, synastry_section, synastry_partner_subject_id,
chart_profile_id, chart_profile_name, chart_profile_role
) values (
'${sessionId}', '${userId}', '甲 × 乙 · 合盘追问', 'marriage', '[]'::jsonb, 'synastry_report',
'${reportId}', 'climate', '${partnerId}',
'${personId}', '甲 × 乙', 'other'
);
`);
assert.throws(() => fixture.psql(`
insert into public.chat_sessions (
id, user_id, title, theme, messages, session_type,
synastry_report_id, synastry_section, synastry_partner_subject_id
) values (
'83000000-0000-4000-8000-000000000983', '${userId}', '越权', 'marriage', '[]'::jsonb, 'synastry_report',
'${otherReportId}', 'climate', '${partnerId}'
);
`), (error: unknown) => /synastry_report_not_owned/.test(errorText(error)));
assert.throws(() => fixture.psql(`
insert into public.chat_sessions (
id, user_id, title, theme, messages, session_type,
synastry_report_id, synastry_section, synastry_partner_subject_id
) values (
'83000000-0000-4000-8000-000000001083', '${userId}', '非法节', 'marriage', '[]'::jsonb, 'synastry_report',
'${reportId}', 'practices', '${partnerId}'
);
`), (error: unknown) => /synastry_section_invalid/.test(errorText(error)));
const reserved = fixture.psql(`
select reason from public.reserve_consultation_usage(
'${userId}', 'chat-83', '${sessionId}', 'model-a', 1
);
`);
assert.equal(reserved.includes("session_missing"), false);
fixture.psql(`delete from public.personal_reports where id = '${reportId}';`);
assert.equal(fixture.psql(`
select (synastry_report_id is null)::text || '|' || synastry_section || '|' || session_type
from public.chat_sessions where id = '${sessionId}';
`), "true|climate|synastry_report");
assert.equal(fixture.psql(`
select reason from public.reserve_consultation_usage(
'${userId}', 'chat-83-after-delete', '${sessionId}', 'model-a', 1
);
`), "session_missing");
} finally {
fixture.stop();
}
});
+4 -1
View File
@@ -121,7 +121,10 @@ test("new chat stays local until the first send and does not reuse a draft row",
// 原因(2):BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
assert.equal(consultRun.indexOf('if (consultEntrypoint === "birth_time_rectification")'), -1);
assert.doesNotMatch(consultRun, /openRectificationFromHomepage/);
const normalSendStart = consultRun.indexOf("const birthPlace = selectedBirthPlace(profile)");
// 原值:const birthPlace = selectedBirthPlace(profile)
// 新值:合盘追问跳过出生地,普通发送仍从这一行开始
// 原因:合盘对话不能因为查看者没有出生地而停住
const normalSendStart = consultRun.indexOf("const birthPlace = synastryTurn ? null : selectedBirthPlace(profile)");
assert.ok(normalSendStart > 0);
const persistBlockStart = consultRun.indexOf("if (isUnsavedEmptyConsultation(currentSession))", normalSendStart);
const persistBlockEnd = consultRun.indexOf("const [year, month, day]", persistBlockStart);
+9 -2
View File
@@ -155,7 +155,10 @@ test("session and report lists keep legacy self rows and hide another person", (
},
or(expression) {
filters.push("or:" + expression);
if (String(expression).includes("chart_profile_id.is.null")) subject = "self";
const text = String(expression);
if (text.includes("chart_profile_id.is.null")) subject = "self";
const other = text.match(/chart_profile_id\.eq\.([^,]+)/);
if (other && !text.includes("chart_profile_id.is.null")) subject = other[1];
return q;
},
order() { return q; },
@@ -196,7 +199,11 @@ test("session and report lists keep legacy self rows and hide another person", (
assert.deepEqual(result.body.selfIds, ["legacy", "self-text"]);
assert.deepEqual(result.body.otherIds, ["other-row"]);
assert.ok(result.filters.some((item) => item.includes("chart_profile_id.is.null")));
assert.ok(result.filters.some((item) => item === `eq:chart_profile_id=${otherId}`));
// 原值:另一人物用 eq:chart_profile_id=<id>
// 新值:or 同时匹配 chart_profile_id 与 synastry_partner_subject_id
// 原因:合盘追问在任一方是当前人物时都要出现
assert.ok(result.filters.some((item) => item.includes(`chart_profile_id.eq.${otherId}`)));
assert.ok(result.filters.some((item) => item.includes(`synastry_partner_subject_id.eq.${otherId}`)));
});
test("a missing report subject fails closed before billing", () => {
+8 -2
View File
@@ -116,7 +116,7 @@ test("old synastry links open the pair page instead of a chat draft", () => {
assert.doesNotMatch(deepLink, /draftSynastryQuestionFromChart|relationship: "other"/);
});
test("report view keeps analysis folded and hides the ask entry", () => {
test("report view keeps analysis folded and offers a section ask entry", () => {
const html = renderToStaticMarkup(createElement(SynastryReportView, {
reportId: "11111111-1111-4111-8111-111111111111",
snapshot: {
@@ -131,7 +131,13 @@ test("report view keeps analysis folded and hides the ask entry", () => {
}));
assert.match(html, /<details>/);
assert.equal(html.includes("<details open"), false);
assert.equal(html.includes("对哪一段想再问清楚"), false);
// 原值:html 不含「对哪一段想再问清楚」
// 新值:报告末尾有这一句,八节可点,共同练习仍只是标题
// 原因:#83 打开合盘追问入口;共同练习不进入对话
assert.equal(html.includes("对哪一段想再问清楚"), true);
assert.match(html, /<button[^>]*>关系基调<\/button>/);
assert.match(html, /<button[^>]*>关系时间线<\/button>/);
assert.doesNotMatch(html, /<button[^>]*>共同练习<\/button>/);
assert.match(html, /所以:靠近时先确认对方听完了。/);
assert.match(html, /外部资料与真实案例核对:未进行/);
assert.match(html, /· 婚恋合盘/);
+277
View File
@@ -0,0 +1,277 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { chatSessionCreateSchema } from "../src/lib/chat-session-write-contract.ts";
import {
chatProfileDisplayName,
chatSessionSubjectLocked,
sessionMatchesSubject,
} from "../src/lib/chat-profile-picker-model.ts";
import { SYNASTRY_CHAT_INSTRUCTIONS } from "../src/mastra/synastry-report-chat.ts";
import {
SYNASTRY_FIXED_REPLY,
auditSynastryAnswer,
settleSynastryDrafts,
} from "../src/lib/synastry-chat-fact-check.ts";
import {
SYNASTRY_CHAT_CHAR_BUDGET,
boundSynastryRead,
synastryChatContextChars,
synastrySectionMaterial,
} from "../src/lib/synastry-report-material.ts";
import { beginSynastrySectionChat } from "../src/lib/synastry-report-chat-client.ts";
import { authoritativeSynastrySession, synastrySectionQuestion } from "../src/lib/synastry-report-session.ts";
import { runSynastryReportTurn, synastryMissingCoverage } from "../src/lib/synastry-report-turn.ts";
import { isListedSidebarSession } from "../src/lib/session-list-filter.ts";
import { synastryReportClosed } from "../src/lib/rectification-session-composer-guard.ts";
import type { ChatSession } from "../src/lib/home-types.ts";
const reportId = "83000000-0000-4000-8000-000000000083";
const otherReportId = "83000000-0000-4000-8000-000000000183";
const sessionId = "83000000-0000-4000-8000-000000000283";
function celebrityNote(): string {
const golden = JSON.parse(readFileSync(new URL("../../tests/golden/upstream_sync2/relationship_einstein.json", import.meta.url), "utf8")) as unknown;
const found = findNote(golden);
if (!found) throw new Error("celebrity note missing");
return found;
}
function findNote(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
if (Array.isArray(value)) {
for (const item of value) {
const found = findNote(item);
if (found) return found;
}
return null;
}
const row = value as Record<string, unknown>;
if (typeof row.note === "string" && row.note.includes("Venus在10宫")) return row.note;
for (const item of Object.values(row)) {
const found = findNote(item);
if (found) return found;
}
return null;
}
function celebrityPayload() {
const note = celebrityNote();
const dasha = "土星大运 1903-01 至 1922-01";
return {
names: { a: "Albert", b: "Mileva" },
zh: {
sections: [{
id: "climate",
title: "关系基调",
text: note,
reasons: [{ basis: `${note};${dasha}`, plain: "感情与事业阶段叠在一起。" }],
}],
},
dossier: {},
};
}
function session(change: Partial<ChatSession>): ChatSession {
return {
id: sessionId,
title: "Albert × Mileva · 合盘追问",
theme: "marriage",
modelId: "m",
messages: [],
updatedAt: 1,
sessionType: "synastry_report",
rectificationCaseId: null,
chartProfileId: "83000000-0000-4000-8000-000000000383",
chartProfileName: "Albert × Mileva",
chartProfileRole: "other",
synastryReportId: reportId,
synastrySection: "climate",
synastryPartnerSubjectId: "self",
pinned: false,
archivedAt: null,
messagesHydrated: true,
...change,
};
}
test("synastry create requires an owned section and refuses context inheritance fields", () => {
const accepted = chatSessionCreateSchema.safeParse({
id: sessionId,
title: "Albert × Mileva · 合盘追问",
theme: "marriage",
model_id: "m",
messages: [],
session_type: "synastry_report",
rectification_case_id: null,
synastry_report_id: reportId,
synastry_section: "climate",
});
assert.equal(accepted.success, true);
assert.equal(chatSessionCreateSchema.safeParse({
id: sessionId,
title: "Albert × Mileva · 合盘追问",
theme: "marriage",
model_id: "m",
messages: [],
session_type: "synastry_report",
rectification_case_id: null,
synastry_section: "practices",
}).success, false);
assert.equal(chatSessionCreateSchema.safeParse({
id: sessionId,
title: "新对话",
theme: "general",
model_id: "m",
messages: [],
session_type: "consultation",
rectification_case_id: null,
synastry_report_id: reportId,
synastry_section: "climate",
}).success, false);
});
test("session binding reads only the caller's ready synastry report", async () => {
const bound = await authoritativeSynastrySession({
reportId,
section: "timeline",
loadOwnedReport: async () => ({
report_type: "synastry_full",
status: "ready",
subject_low: "self",
subject_high: "83000000-0000-4000-8000-000000000383",
report_document: { names: { a: "Albert", b: "Mileva" } },
}),
});
assert.equal(bound.chart_profile_name, "Albert × Mileva");
assert.equal(bound.synastry_partner_subject_id, "83000000-0000-4000-8000-000000000383");
assert.equal(bound.rectification_case_id, null);
await assert.rejects(
authoritativeSynastrySession({ reportId: otherReportId, section: "climate", loadOwnedReport: async () => null }),
/synastry_report_not_owned/,
);
await assert.rejects(
authoritativeSynastrySession({ reportId, section: "practices", loadOwnedReport: async () => ({ report_type: "synastry_full", status: "ready" }) }),
/synastry_section_invalid/,
);
});
test("opening a section chat only creates a session and prefills the question", async () => {
const calls: string[] = [];
const store = new Map<string, string>();
const previous = globalThis.sessionStorage;
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => { store.set(key, value); },
removeItem: (key: string) => { store.delete(key); },
},
});
try {
const opened = await beginSynastrySectionChat({
reportId,
sectionId: "climate",
language: "zh",
names: { a: "Albert", b: "Mileva" },
fetcher: async (input, init) => {
const url = String(input);
calls.push(`${init?.method ?? "GET"} ${url}`);
if (url.endsWith("/api/models")) {
return new Response(JSON.stringify({ defaultModelId: "model-a" }), { status: 200 });
}
const body = JSON.parse(String(init?.body)) as { session_type?: string };
assert.equal(body.session_type, "synastry_report");
return new Response("{}", { status: 201 });
},
open: (href) => { calls.push(href); },
});
assert.equal(calls.some((call) => call.includes("/api/consult")), false);
assert.match(opened.question, /合盘报告里「关系基调」/);
assert.match(calls.at(-1) ?? "", /\/\?c=/);
assert.equal(synastrySectionQuestion("climate", "en").includes("Relationship Climate"), true);
} finally {
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: previous });
}
});
test("celebrity material accepts a stored placement and rejects an invented house or a shifted dasha", async () => {
const payload = celebrityPayload();
const material = synastrySectionMaterial({ payload, sectionId: "climate" });
assert.equal(auditSynastryAnswer(celebrityNote(), material).ok, true);
assert.equal(auditSynastryAnswer("金星在 7 宫。", material).ok, false);
assert.equal(auditSynastryAnswer("土星大运 1904-01 至 1922-01。", material).ok, false);
assert.equal(synastryChatContextChars(SYNASTRY_CHAT_INSTRUCTIONS, material) <= SYNASTRY_CHAT_CHAR_BUDGET, true);
const wrong = await runSynastryReportTurn({
question: "依据是什么?",
sectionId: "climate",
history: [],
readMaterial: () => material,
generate: async () => ({ text: "金星在 7 宫。", toolCalls: 1, inputTokens: 1, outputTokens: 1 }),
});
assert.equal(wrong.outcome, "fixed");
assert.equal(wrong.text, SYNASTRY_FIXED_REPLY);
const shifted = settleSynastryDrafts({
toolCalled: true,
material,
first: "土星大运 1904-01 至 1922-01。",
second: "土星大运 1904-01 至 1923-01。",
});
assert.equal(shifted.outcome, "fixed");
assert.equal(shifted.text, SYNASTRY_FIXED_REPLY);
const outside = await runSynastryReportTurn({
question: "他们哪一年领证?",
sectionId: "climate",
history: [],
readMaterial: () => material,
generate: async () => ({ text: "这份合盘没算到,建议回到各自的盘单独问。", toolCalls: 1, inputTokens: 1, outputTokens: 1 }),
});
assert.equal(outside.outcome, "pass");
assert.equal(synastryMissingCoverage(outside.text), true);
const missing = await runSynastryReportTurn({
question: "依据是什么?",
sectionId: "climate",
history: [],
readMaterial: () => material,
generate: async () => ({ text: "金星在 10 宫。", toolCalls: 0, inputTokens: 1, outputTokens: 1 }),
});
assert.equal(missing.outcome, "tool_missing");
assert.equal(missing.text, "");
});
test("the read tool stays on the bound report and the new files do not recast", () => {
const read = boundSynastryRead({
boundReportId: reportId,
requestedReportId: otherReportId,
sectionId: "climate",
payload: celebrityPayload(),
});
assert.equal(read.reportId, reportId);
assert.notEqual(read.reportId, otherReportId);
for (const path of [
"../src/mastra/synastry-report-chat.ts",
"../src/lib/synastry-report-consult.ts",
"../src/lib/synastry-report-turn.ts",
"../src/lib/synastry-report-material.ts",
]) {
const source = readFileSync(new URL(path, import.meta.url), "utf8");
assert.equal(source.includes("run-jyotish-consultation"), false, path);
}
});
test("sidebar keeps an empty synastry chat for either person and locks the pair", () => {
const row = session({});
assert.equal(isListedSidebarSession(row), true);
assert.equal(sessionMatchesSubject(row, "self"), true);
assert.equal(sessionMatchesSubject(row, row.chartProfileId ?? ""), true);
assert.equal(sessionMatchesSubject(row, otherReportId), false);
assert.equal(chatSessionSubjectLocked(row), true);
assert.equal(chatProfileDisplayName({ session: row, status: "loading", library: [] }), "Albert × Mileva");
assert.equal(synastryReportClosed(session({ synastryReportId: null })), true);
assert.equal(synastryReportClosed(row), false);
});