diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed54bd1..a3282cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-19 — 合盘报告可以按节追问依据 + +- 报告末尾可以选择前八节里的一节,打开一条绑定这份合盘的新对话。问题预先写好,由用户自己发送。打开对话不扣点,发出去的每一轮按普通对话 `chat.standard` 计费。 +- 回答只读这份报告已经存下的计算。每轮先读该节材料,盘面对不上就改写一次,仍对不上就回复「这一点在这份合盘里没有算到,我不能确定」。材料没写到的,说「这份合盘没算到」。 +- 侧栏标题是「甲 × 乙 · 合盘追问」,两个人各自的列表都能看到。报告删除后对话还在,输入框变为「这份合盘已删除」,不能继续。普通单人对话的提示、证据卡和工具没有改。Skill 版本不变。 + ## 2026-10-19 — 完整合盘报告第三次打回后的修复 - 「我的报告」翻页恢复成一次查询,按创建时间和编号往下翻。 diff --git a/docs/tasks/PROGRESS-synastry-report-chat-20261018.md b/docs/tasks/PROGRESS-synastry-report-chat-20261018.md new file mode 100644 index 00000000..6ecee723 --- /dev/null +++ b/docs/tasks/PROGRESS-synastry-report-chat-20261018.md @@ -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` 开头,合盘追问不看查看者资料是否齐全 | 报告删除后只读;追问不要求查看者出生资料 | +| 称呼按钮 | 资料停在称呼步时按钮是「确认称呼」 | 合盘追问仍是「发送」,也不再被出生资料表单挡住 | 预填问题要能发出;查看者自己的资料没填完也能问 | diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 1779bf39..ee3b3e8e 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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`;动表 | diff --git a/docs/testing/synastry-report-chat-20261018.md b/docs/testing/synastry-report-chat-20261018.md new file mode 100644 index 00000000..8440753b --- /dev/null +++ b/docs/testing/synastry-report-chat-20261018.md @@ -0,0 +1,11 @@ +# 合盘报告追问 · 真机清单(2026-10-19) + +自动化没有登录态,也没有模型凭据。下面这些要在 staging 部署之后用一份公开名人合盘看。 + +1. 打开一份已经写好的中文合盘报告,滚到末尾。能看到「对哪一段想再问清楚?」和八个节名,没有「共同练习」。 +2. 点「关系基调」。进入一条新对话,输入框里已经写好「合盘报告里「关系基调」这一段,在我们两人的盘里依据是什么?」。这一步没有自动发出,余额不变。 +3. 自己发送。回答里的宫位、星曜或大运都能在这一节的材料里找到。再追问一轮,仍然只讲这份报告。 +4. 问一个报告没写的问题,例如「他们哪一年领证」。回答里出现「这份合盘没算到」这一类说法。 +5. 删掉这份合盘报告,再打开刚才的对话。原文还在,输入框是「这份合盘已删除」,不能发送。 +6. 用英文版报告点同一节。预填和回答是英文。 +7. 换到合盘里的另一个人,侧栏仍能看到标题为「甲 × 乙 · 合盘追问」的这条对话。人物选择器锁住,显示两个人的名字。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index d1331236..08406b6f 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -2,7 +2,7 @@ ## 婚恋合盘报告(2026-10-19) -选人页用自定义列表,最多约二十人,列表本身可滚动。资料不全的人灰掉,不能选。标题和按钮同一行两端对齐。正文与技术依据在窄屏上换行,不把页面撑出横向滚动。九节里的「分析逻辑」默认收起。英文准备期间沿用报告页的等待区,揭幕后不再出现加载指示。 +选人页用自定义列表,最多约二十人,列表本身可滚动。资料不全的人灰掉,不能选。标题和按钮同一行两端对齐。正文与技术依据在窄屏上换行,不把页面撑出横向滚动。九节里的「分析逻辑」默认收起。英文准备期间沿用报告页的等待区,揭幕后不再出现加载指示。报告末尾一行「对哪一段想再问清楚?」,下面是八枚描边胶囊按钮(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送,也不出现新的加载动画。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。 ## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途) diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 5d2cf178..cd8685f2 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -34,7 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 吵完之后,怎样递出台阶才不会变成认输? - 过日子的安排里,哪一件需要现在就说清楚? -「对哪一段想再问清楚?」先不显示。英文还没写好时只说「英文版即将提供」。选人页按钮是「开始合盘」。点数从配置读,没有配置时说「计费配置不可用」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。等待时分别说「正在排两人的星盘」「正在写关系画像」「正在核对表述」。 +报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文报告用「合盘报告里「这一段」这一段,在我们两人的盘里依据是什么?」;英文报告用英文。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页按钮是「开始合盘」。点数从配置读,没有配置时说「计费配置不可用」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。等待时分别说「正在排两人的星盘」「正在写关系画像」「正在核对表述」。 ## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306) diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 4bd2b9e2..07a9bd5c 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -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) { 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() {
{activeSession?.title || "新对话"} - {activeSession?.sessionType === "consultation" ? ( + {activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? ( {!activeSession?.messages.length ? (
- {!profileComplete ? ( + {!synastryChat && !profileComplete ? ( void reopenRectificationSession(activeSession?.id || rectificationErrorSessionId || "")} /> : null} 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({ userId, - mode: parsed.data.consultationMode, + mode: parsed.data.consultationMode as Exclude, subject: { binding: consultationSubjectBindingFromSession(chatSession), clientBirth: parsed.data, diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index e0cc01e5..9757b3f6 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -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 } { diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index 09fded84..bf2e00ad 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -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 }); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index bab94bf5..5cfdd299 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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%; diff --git a/frontend/src/components/chat-transcript.tsx b/frontend/src/components/chat-transcript.tsx index 30e75021..4f9f0566 100644 --- a/frontend/src/components/chat-transcript.tsx +++ b/frontend/src/components/chat-transcript.tsx @@ -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>; 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>; copiedMessageKey: string | null; diff --git a/frontend/src/components/synastry/synastry-report-view.tsx b/frontend/src/components/synastry/synastry-report-view.tsx index e4a97b2c..a95c2c31 100644 --- a/frontend/src/components/synastry/synastry-report-view.tsx +++ b/frontend/src/components/synastry/synastry-report-view.tsx @@ -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(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: { ))} + + {askError ?

{askError}

: null}

{SYNASTRY_FOOTNOTE}

); diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index 8ed8fba7..ee06a93c 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -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, diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index bd3c0330..ffed1272 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -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(null); diff --git a/frontend/src/lib/chat-profile-picker-model.ts b/frontend/src/lib/chat-profile-picker-model.ts index 3c75aab1..c6487e2c 100644 --- a/frontend/src/lib/chat-profile-picker-model.ts +++ b/frontend/src/lib/chat-profile-picker-model.ts @@ -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, + session: Pick & { + 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; + session: Pick & { 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; diff --git a/frontend/src/lib/chat-session-write-contract.ts b/frontend/src/lib/chat-session-write-contract.ts index 1bc9514d..32331807 100644 --- a/frontend/src/lib/chat-session-write-contract.ts +++ b/frontend/src/lib/chat-session-write-contract.ts @@ -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; diff --git a/frontend/src/lib/consultation-entrypoint.ts b/frontend/src/lib/consultation-entrypoint.ts index 6638e6b5..ea1d6daa 100644 --- a/frontend/src/lib/consultation-entrypoint.ts +++ b/frontend/src/lib/consultation-entrypoint.ts @@ -5,6 +5,7 @@ export const consultationEntrypointSchema = z.enum([ "birth_time_rectification", // 入口已于 2026-09-15 下线,枚举值保留用于读回历史会话 "guided_topic", + "synastry_report", ]); export type ConsultationEntrypoint = z.infer; @@ -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; diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index 9b1d70e3..783bec15 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -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, diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts index 992d2a17..c7ecbfbe 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -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; diff --git a/frontend/src/lib/rectification-session-composer-guard.ts b/frontend/src/lib/rectification-session-composer-guard.ts index fbcf71c9..b94647d9 100644 --- a/frontend/src/lib/rectification-session-composer-guard.ts +++ b/frontend/src/lib/rectification-session-composer-guard.ts @@ -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) { diff --git a/frontend/src/lib/session-list-filter.ts b/frontend/src/lib/session-list-filter.ts index d9ecfb98..ac222f0d 100644 --- a/frontend/src/lib/session-list-filter.ts +++ b/frontend/src/lib/session-list-filter.ts @@ -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; diff --git a/frontend/src/lib/synastry-chat-fact-check.ts b/frontend/src/lib/synastry-chat-fact-check.ts new file mode 100644 index 00000000..e0043a3c --- /dev/null +++ b/frontend/src/lib/synastry-chat-fact-check.ts @@ -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 = [ + ["北交点", "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 = [ + ["白羊", "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> = { + 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10, 十一: 11, 十二: 12, +}; + +const ASPECTS: ReadonlyArray = [ + ["合相", "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 { + const claims = new Set(); + 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 }; +} diff --git a/frontend/src/lib/synastry-full-sections.ts b/frontend/src/lib/synastry-full-sections.ts index e6c50067..1312f804 100644 --- a/frontend/src/lib/synastry-full-sections.ts +++ b/frontend/src/lib/synastry-full-sections.ts @@ -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>> = { climate: { zh: "关系基调", en: "Relationship Climate" }, emotion: { zh: "情感节奏", en: "Emotional Rhythm" }, diff --git a/frontend/src/lib/synastry-report-chat-client.ts b/frontend/src/lib/synastry-report-chat-client.ts new file mode 100644 index 00000000..138b203c --- /dev/null +++ b/frontend/src/lib/synastry-report-chat-client.ts @@ -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 }; +} diff --git a/frontend/src/lib/synastry-report-consult.ts b/frontend/src/lib/synastry-report-consult.ts new file mode 100644 index 00000000..2dc87a6f --- /dev/null +++ b/frontend/src/lib/synastry-report-consult.ts @@ -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; + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : 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, onError: () => Promise) { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + 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 { + 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, { 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>; + 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)); +} diff --git a/frontend/src/lib/synastry-report-material.ts b/frontend/src/lib/synastry-report-material.ts new file mode 100644 index 00000000..0f69f9ac --- /dev/null +++ b/frontend/src/lib/synastry-report-material.ts @@ -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 | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : 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 = {}; + 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, language: "zh" | "en"): LanguageBody | null { + const source = language === "en" ? record(payload.english) : record(payload.zh); + return source as LanguageBody | null; +} + +function timingLines(payload: Record): unknown { + const timing = record(payload.timingPeriods); + if (!timing) return null; + const picked: Record = {}; + 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 { + 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 = { + 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): Record { + 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, maxChars: number): Record { + 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 } { + return { + reportId: input.boundReportId, + material: synastrySectionMaterial({ + payload: input.payload, + sectionId: input.sectionId, + includeTechnical: input.includeTechnical, + language: input.language, + }), + }; +} diff --git a/frontend/src/lib/synastry-report-session.ts b/frontend/src/lib/synastry-report-session.ts new file mode 100644 index 00000000..806c08bd --- /dev/null +++ b/frontend/src/lib/synastry-report-session.ts @@ -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 | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : 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; +}): Promise { + 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, + }; +} diff --git a/frontend/src/lib/synastry-report-turn.ts b/frontend/src/lib/synastry-report-turn.ts new file mode 100644 index 00000000..fab9a4c7 --- /dev/null +++ b/frontend/src/lib/synastry-report-turn.ts @@ -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; +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; +}): Promise { + 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("没有算到"); +} diff --git a/frontend/src/mastra/synastry-report-chat.ts b/frontend/src/mastra/synastry-report-chat.ts new file mode 100644 index 00000000..a254bfaa --- /dev/null +++ b/frontend/src/mastra/synastry-report-chat.ts @@ -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[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), + }; +} diff --git a/frontend/supabase/migrations/20261019020000_synastry_report_chat.sql b/frontend/supabase/migrations/20261019020000_synastry_report_chat.sql new file mode 100644 index 00000000..c3f3b47a --- /dev/null +++ b/frontend/supabase/migrations/20261019020000_synastry_report_chat.sql @@ -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; diff --git a/frontend/tests/application-billing-contract.test.ts b/frontend/tests/application-billing-contract.test.ts index ab3b92c1..46891b35 100644 --- a/frontend/tests/application-billing-contract.test.ts +++ b/frontend/tests/application-billing-contract.test.ts @@ -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 diff --git a/frontend/tests/chat-composer-queue.test.ts b/frontend/tests/chat-composer-queue.test.ts index eacb7f5a..503c947f 100644 --- a/frontend/tests/chat-composer-queue.test.ts +++ b/frontend/tests/chat-composer-queue.test.ts @@ -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\(\)\);/); diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts index f8561e8d..1d7d7b46 100644 --- a/frontend/tests/chat-session-authority.test.ts +++ b/frontend/tests/chat-session-authority.test.ts @@ -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\(/); diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index 6b26900e..a63d9d03 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -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, diff --git a/frontend/tests/composer-isolation-contract.test.ts b/frontend/tests/composer-isolation-contract.test.ts index 5691ec7d..14ca8619 100644 --- a/frontend/tests/composer-isolation-contract.test.ts +++ b/frontend/tests/composer-isolation-contract.test.ts @@ -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", () => { diff --git a/frontend/tests/consultation-session-capacity.test.ts b/frontend/tests/consultation-session-capacity.test.ts index c90fd8a4..d4a0e138 100644 --- a/frontend/tests/consultation-session-capacity.test.ts +++ b/frontend/tests/consultation-session-capacity.test.ts @@ -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\$\$;/); diff --git a/frontend/tests/database-synastry-report-chat.test.ts b/frontend/tests/database-synastry-report-chat.test.ts new file mode 100644 index 00000000..cee16bf9 --- /dev/null +++ b/frontend/tests/database-synastry-report-chat.test.ts @@ -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(); + } +}); diff --git a/frontend/tests/session-list-filter.test.ts b/frontend/tests/session-list-filter.test.ts index aeb5de28..eff2f6bd 100644 --- a/frontend/tests/session-list-filter.test.ts +++ b/frontend/tests/session-list-filter.test.ts @@ -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); diff --git a/frontend/tests/subject-route-birth.test.ts b/frontend/tests/subject-route-birth.test.ts index 1c596cd4..3f9d2697 100644 --- a/frontend/tests/subject-route-birth.test.ts +++ b/frontend/tests/subject-route-birth.test.ts @@ -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= + // 新值: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", () => { diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts index 10d96cde..7c81389a 100644 --- a/frontend/tests/synastry-full-report.test.ts +++ b/frontend/tests/synastry-full-report.test.ts @@ -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, /
/); assert.equal(html.includes("
]*>关系基调<\/button>/); + assert.match(html, /]*>关系时间线<\/button>/); + assert.doesNotMatch(html, /]*>共同练习<\/button>/); assert.match(html, /所以:靠近时先确认对方听完了。/); assert.match(html, /外部资料与真实案例核对:未进行/); assert.match(html, /· 婚恋合盘/); diff --git a/frontend/tests/synastry-report-chat.test.ts b/frontend/tests/synastry-report-chat.test.ts new file mode 100644 index 00000000..a023d0a9 --- /dev/null +++ b/frontend/tests/synastry-report-chat.test.ts @@ -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; + 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 { + 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(); + 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); +});