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