fix(consult): resolve chat subject from server-owned profile binding
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-22 — 普通聊天按会话绑定的人物排盘
|
||||
|
||||
普通聊天不再在切换人物后仍用登录用户本人的资料。会话锁定的是本人或当前用户名下的另一份资料;对方资料缺失、被删或不完整时会拦住发送,不会退回本人。已有消息的会话不能改绑。报告、每日星语和生时校正仍只用本人资料。Skill 版本不变。
|
||||
|
||||
## 2026-09-21 — 侧栏当前会话是一整条高亮
|
||||
|
||||
选中或悬停会话时,标题和右边的「⋯」共用一块底,不再拆成两个块。Skill 版本不变。
|
||||
|
||||
@@ -13199,3 +13199,19 @@
|
||||
- 相关记录:BUG-976、BUG-977
|
||||
- 复发自:无
|
||||
- 修复版本:`2503c019`
|
||||
|
||||
## BUG-997 | 切换人物后普通聊天仍用登录用户本人的出生资料
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-22
|
||||
- 最近更新:2026-09-22
|
||||
- 影响面:普通聊天 `/api/consult` 的排盘对象,以及 `POST/PATCH /api/sessions` 的人物绑定
|
||||
- 用户现象:会话已经记下另一位人物,继续提问时计算和咨询上下文仍使用当前登录用户的 `profiles`。对方资料被删除后,发送还会退回本人。
|
||||
- 触发条件:会话绑定 `chart_profile_role=other` 与某个 `chart_profile_id` 后调用普通咨询;或客户端同时提交另一套出生字段。
|
||||
- 根因:`chart_profile_id/name/role` 只是客户端可写的展示快照。咨询读取会话时不取 `chart_profile_id`,准备阶段无条件 `loadProfile(userId)` 读取登录用户的 `profiles`。客户端出生字段因此可以和实际排盘对象不一致,缺失或越权的 other 资料也会落回本人。
|
||||
- 修复:新增服务端 subject resolver。`self` 与未绑定的旧会话只读当前用户权威 `profiles`;`other` 只读当前用户拥有的 `chart_profiles` 行,并在服务端重新派生姓名与角色。会话展示名、客户端 `name/year/month/day/hour/minute/city/lat/lon/tz` 都不能覆盖该结果。另一用户的 id、随机 id、角色不一致、删除、缺失或不完整资料一律失败,不退回本人,也不扣点、不调用模型。空会话可以由服务端按 id 填上角色和姓名;已有消息的会话拒绝改绑。刷新与深链只使用库里的绑定,不读取全局 `activeChartId`。未改表结构、计费语义或模型选择,也未接到报告、每日星语或生时校正。
|
||||
- 验证:`frontend/tests/consultation-subject-binding.test.ts` 10 pass / 0 fail,覆盖 self/other、越权与随机 id、角色与伪造姓名、删除/缺失/不完整、客户端出生字段冲突、咨询准备拿到 other 资料、失败不扣点且不进入准备、有消息后改绑拒绝、刷新保留绑定、并发删除后发送失败。与 `consultation-route-service`、`application-billing-contract`、`chat-session-write`、`chat-session-authority`、`consultation-workflow-contract`、`consultation-birth-time-mode` 合跑 88 tests、87 pass / 1 fail。单独再跑 `consultation-birth-time-mode` 为 9 pass / 1 fail;失败项是本机无法创建 skill symlink(EPERM),同文件的咨询 route 顺序断言通过。`tsc --noEmit` 通过;`npm run lint` 0 error。未部署。
|
||||
- 防复发:普通咨询在扣点前必须经 subject resolver。other 查询必须同时约束 `id` 与当前 `user_id`,失败不得调用 `loadProfile` 作为退路。客户端出生字段不得进入 `toolInput`。已有消息的会话绑定必须与库存值一致,否则拒绝整次 PATCH。
|
||||
- 相关记录:BUG-001、BUG-010、BUG-011、BUG-018、BUG-073、BUG-076、BUG-188
|
||||
- 复发自:无
|
||||
- 修复版本:本次分支提交(未推送,未部署)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 进度 · 普通聊天人物资料服务端真值与会话绑定(2026-09-22)
|
||||
|
||||
基线:`10baeb2fa865743c428806e115ac92ca9d92fb71`(开工时 `origin/staging`)。分支 `codex/consultation-subject-binding-20260922`。未推送,未部署。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- 新增 `frontend/src/lib/consultation-subject-resolver.ts`。`self` 和未绑定旧会话只读当前用户 `profiles`。`other` 只读当前用户拥有的 `chart_profiles`,服务端重新派生姓名和角色。
|
||||
- `prepareConsultationRoute` 在扣点前解析 subject。other 不继承登录用户的校正候选区间。客户端出生字段只被传入后丢弃。
|
||||
- `POST /api/consult` 读取 `chart_profile_id/name/role`,失败时返回固定中文错误,不扣点、不调用模型。
|
||||
- 会话创建与 PATCH 由服务端填写或核对绑定。空会话可以绑定;已有消息则拒绝改绑。刷新和深链只看库存绑定,不看 `activeChartId`。
|
||||
|
||||
## 断言变更
|
||||
|
||||
| 测试 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `application-billing-contract.test.ts` 会话 select | `...,title,theme,chart_profile_role,context_summary` | 同一串中在 role 前增加 `chart_profile_id,chart_profile_name` | 普通咨询必须按库存人物 id 解析,不能只看 role |
|
||||
|
||||
其余既有测试名未改。展示名与资料最新姓名不一致时,咨询仍使用资料里的姓名,不因此失败:任务书要求后续聊天用该人物最新资料,展示快照不是排盘真值。伪造姓名不能写入绑定,也不能补全缺姓名的资料;角色不一致则直接失败,且不退回本人。
|
||||
|
||||
## 验证
|
||||
|
||||
- `tsc --noEmit`:通过。
|
||||
- `npm run lint`:0 error(既有 warning 未改)。
|
||||
- `npx tsx --test tests/consultation-subject-binding.test.ts`:10 pass / 0 fail。
|
||||
- 同命令一起跑 `consultation-subject-binding.test.ts`、`consultation-route-service.test.ts`、`application-billing-contract.test.ts`、`chat-session-write.test.ts`、`chat-session-authority.test.ts`、`consultation-birth-time-mode.test.ts`、`consultation-workflow-contract.test.ts`:88 tests,87 pass / 1 fail。
|
||||
- 单独再跑 `consultation-birth-time-mode.test.ts`:9 pass / 1 fail。唯一失败是 `general agent runtime has no Jyotish skill package and no personal chart tool`,本机创建 skill symlink 报 EPERM。同文件里「consult route validates mode before billing」通过。
|
||||
- 未跑全量 `npm test`,未跑 `next build`。
|
||||
|
||||
## 留给下一轮人物选择 UI
|
||||
|
||||
- 不改 `page.tsx`、首屏或聊天顶栏,不做选择器。
|
||||
- 不把 other 接到报告、每日星语或生时校正。
|
||||
- 全局 `activeChartId` 仍只在客户端;服务端不读它。
|
||||
@@ -74,6 +74,11 @@ import {
|
||||
prepareConsultationRoute,
|
||||
type PreparedConsultationRoute,
|
||||
} from "@/lib/consultation-route-service";
|
||||
import {
|
||||
ConsultationSubjectError,
|
||||
consultationSubjectBindingFromSession,
|
||||
consultationSubjectFailureResponse,
|
||||
} from "@/lib/consultation-subject-resolver";
|
||||
import { loadReportCandidateRange } from "@/lib/report-candidate-range";
|
||||
import {
|
||||
loadGeneralDailyPanchangaContext,
|
||||
@@ -297,7 +302,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const { data: chatSession, error: chatSessionError } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role,context_summary")
|
||||
.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.data.sessionId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
@@ -393,6 +398,23 @@ export async function POST(request: Request) {
|
||||
prepared = await prepareConsultationRoute<ModelSelection, ConsultationPlan>({
|
||||
userId,
|
||||
mode: parsed.data.consultationMode,
|
||||
subject: {
|
||||
binding: consultationSubjectBindingFromSession(chatSession),
|
||||
clientBirth: parsed.data,
|
||||
async loadOwnedChartProfile(chartProfileId) {
|
||||
const { data, error } = await supabase
|
||||
.from("chart_profiles")
|
||||
.select("id,user_id,role,profile")
|
||||
.eq("id", chartProfileId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
if (error) throw new ConsultationSubjectError("subject_unavailable");
|
||||
if (!data || typeof data.id !== "string" || typeof data.user_id !== "string" || typeof data.role !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { id: data.id, userId: data.user_id, role: data.role, profile: data.profile };
|
||||
},
|
||||
},
|
||||
async loadProfile(profileUserId) {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
@@ -458,6 +480,10 @@ export async function POST(request: Request) {
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (error instanceof ConsultationSubjectError) {
|
||||
const failure = consultationSubjectFailureResponse(error);
|
||||
return NextResponse.json(failure.body, { status: failure.status });
|
||||
}
|
||||
if (error instanceof ConsultationProfileTruthError) {
|
||||
const modeChanged = error.code === "mode_changed";
|
||||
return NextResponse.json(
|
||||
@@ -483,6 +509,10 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const subjectName = prepared.subject?.name
|
||||
|| prepared.serverChart?.name
|
||||
|| prepared.declaredWindow?.name
|
||||
|| "";
|
||||
const resolvedQuestion = resolveConsultationQuestion({
|
||||
visibleQuestion: parsed.data.question,
|
||||
entrypoint: parsed.data.entrypoint,
|
||||
@@ -593,7 +623,7 @@ export async function POST(request: Request) {
|
||||
model: selectedModel,
|
||||
question: visibleQuestion,
|
||||
history: chatSession.messages,
|
||||
name: parsed.data.name,
|
||||
name: subjectName,
|
||||
signal: request.signal,
|
||||
onObservation(observation) {
|
||||
if (!observation.late) {
|
||||
@@ -803,7 +833,7 @@ export async function POST(request: Request) {
|
||||
model: selectedModel,
|
||||
question: parsed.data.question,
|
||||
theme: consultationTheme,
|
||||
chartRole: chatSession.chart_profile_role === "other" ? "other" : "self",
|
||||
chartRole: prepared.subject?.role === "other" ? "other" : "self",
|
||||
signal: request.signal,
|
||||
}).then(async (title) => {
|
||||
if (!title) return null;
|
||||
@@ -1359,7 +1389,7 @@ export async function POST(request: Request) {
|
||||
|
||||
try {
|
||||
const history = storedHistory;
|
||||
const name = prepared.serverChart?.name ?? prepared.declaredWindow?.name ?? parsed.data.name;
|
||||
const name = subjectName;
|
||||
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
|
||||
const generalDailyContext = shouldLoadGeneralDailyPanchanga({
|
||||
consultationMode,
|
||||
|
||||
@@ -10,6 +10,15 @@ import {
|
||||
import { logIgnoredSessionMessages } from "@/lib/chat-session-observability";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { metadataUpdateValues } from "@/lib/session-metadata-update";
|
||||
import {
|
||||
authoritativeSessionBinding,
|
||||
ConsultationSubjectError,
|
||||
consultationSubjectBindingFromSession,
|
||||
consultationSubjectFailureResponse,
|
||||
ownedChartProfileFromResult,
|
||||
selfNameFromResult,
|
||||
sessionBindingRequested,
|
||||
} from "@/lib/consultation-subject-resolver";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -91,6 +100,34 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
if (!values) {
|
||||
return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
|
||||
}
|
||||
if (sessionBindingRequested(values)) {
|
||||
const { data: existing, error: existingError } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("messages,chart_profile_id,chart_profile_name,chart_profile_role")
|
||||
.eq("id", id)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
if (existingError) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
|
||||
if (!existing) return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
|
||||
const binding = await authoritativeSessionBinding({
|
||||
userId: user.id,
|
||||
requested: values,
|
||||
existingMessages: existing.messages,
|
||||
existingBinding: consultationSubjectBindingFromSession(existing),
|
||||
loadSelfProfile: async (profileUserId) => selfNameFromResult(await supabase
|
||||
.from("profiles")
|
||||
.select("name")
|
||||
.eq("id", profileUserId)
|
||||
.maybeSingle()),
|
||||
loadOwnedChartProfile: async (chartProfileId) => ownedChartProfileFromResult(await supabase
|
||||
.from("chart_profiles")
|
||||
.select("id,user_id,role,profile")
|
||||
.eq("id", chartProfileId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle()),
|
||||
});
|
||||
if (binding) values = { ...values, ...binding };
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from("chat_sessions")
|
||||
.update(values)
|
||||
@@ -102,6 +139,10 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
if (!data) return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ConsultationSubjectError) {
|
||||
const failure = consultationSubjectFailureResponse(error);
|
||||
return NextResponse.json(failure.body, { status: failure.status });
|
||||
}
|
||||
if (error instanceof ChatSessionBodyTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ import {
|
||||
ChatSessionBodyTooLargeError,
|
||||
readChatSessionJson,
|
||||
} from "@/lib/chat-session-write-contract";
|
||||
import {
|
||||
authoritativeSessionBinding,
|
||||
ConsultationSubjectError,
|
||||
consultationSubjectFailureResponse,
|
||||
ownedChartProfileFromResult,
|
||||
selfNameFromResult,
|
||||
} from "@/lib/consultation-subject-resolver";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { resolveInheritedContextSummary } from "@/lib/session-context-summary";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
@@ -93,6 +100,24 @@ 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({
|
||||
userId: user.id,
|
||||
requested: values,
|
||||
existingMessages: values.messages,
|
||||
existingBinding: null,
|
||||
loadSelfProfile: async (profileUserId) => selfNameFromResult(await supabase
|
||||
.from("profiles")
|
||||
.select("name")
|
||||
.eq("id", profileUserId)
|
||||
.maybeSingle()),
|
||||
loadOwnedChartProfile: async (chartProfileId) => ownedChartProfileFromResult(await supabase
|
||||
.from("chart_profiles")
|
||||
.select("id,user_id,role,profile")
|
||||
.eq("id", chartProfileId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle()),
|
||||
});
|
||||
const insertValues = binding ? { ...values, ...binding } : values;
|
||||
const inheritedSummary = await resolveInheritedContextSummary({
|
||||
continuedFromSessionId,
|
||||
loadOwnedSummary: async (sourceId) => {
|
||||
@@ -109,7 +134,7 @@ export async function POST(request: Request) {
|
||||
chatSessionCreateInsertRow({
|
||||
id,
|
||||
userId: user.id,
|
||||
values,
|
||||
values: insertValues,
|
||||
inheritedSummary,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
@@ -117,6 +142,10 @@ 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 ConsultationSubjectError) {
|
||||
const failure = consultationSubjectFailureResponse(error);
|
||||
return NextResponse.json(failure.body, { status: failure.status });
|
||||
}
|
||||
if (error instanceof ChatSessionBodyTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
} from "./consultation-birth-time-mode.ts";
|
||||
import type { GeneralDailyReference } from "./general-daily-panchanga.ts";
|
||||
import { resolveAyanamsa, type AyanamsaName } from "./ayanamsa.ts";
|
||||
import {
|
||||
ConsultationSubjectError,
|
||||
resolveConsultationSubject,
|
||||
type ConsultationSubjectBinding,
|
||||
type OwnedChartProfile,
|
||||
} from "./consultation-subject-resolver.ts";
|
||||
|
||||
export type ConsultationProfileTruthErrorCode =
|
||||
| "profile_unavailable"
|
||||
@@ -133,6 +139,11 @@ type PrepareConsultationRouteInput<Reservation> = Readonly<{
|
||||
resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise<unknown>;
|
||||
beforeReserve?: (context: ConsultationPreReserveContext) => unknown | Promise<unknown>;
|
||||
reserve: () => Promise<Reservation>;
|
||||
subject?: Readonly<{
|
||||
binding: ConsultationSubjectBinding;
|
||||
loadOwnedChartProfile: (chartProfileId: string) => Promise<OwnedChartProfile | null>;
|
||||
clientBirth?: unknown;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
type PrepareConsultationRouteWithGuard<Reservation, GuardResult> = Omit<
|
||||
@@ -142,6 +153,12 @@ type PrepareConsultationRouteWithGuard<Reservation, GuardResult> = Omit<
|
||||
beforeReserve: (context: ConsultationPreReserveContext) => GuardResult | Promise<GuardResult>;
|
||||
}>;
|
||||
|
||||
export type PreparedConsultationSubject = Readonly<{
|
||||
role: "self" | "other";
|
||||
name: string;
|
||||
chartProfileId: string | null;
|
||||
}>;
|
||||
|
||||
export type PreparedConsultationRoute<Reservation, GuardResult = undefined> = Readonly<{
|
||||
consultationMode: ConsultationBirthTimeMode;
|
||||
serverChart: ServerChartConsultation | null;
|
||||
@@ -149,6 +166,7 @@ export type PreparedConsultationRoute<Reservation, GuardResult = undefined> = Re
|
||||
generalDailyReference: GeneralDailyReference | null;
|
||||
reservation: Reservation;
|
||||
preReserveResult: GuardResult;
|
||||
subject: PreparedConsultationSubject | null;
|
||||
}>;
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
@@ -500,10 +518,31 @@ export async function prepareConsultationRoute<Reservation, GuardResult>(
|
||||
input: PrepareConsultationRouteInput<Reservation> | PrepareConsultationRouteWithGuard<Reservation, GuardResult>,
|
||||
): Promise<PreparedConsultationRoute<Reservation, Awaited<GuardResult> | undefined>> {
|
||||
let profile: unknown;
|
||||
let subjectRole: "self" | "other" = "self";
|
||||
let subject: PreparedConsultationSubject | null = null;
|
||||
try {
|
||||
profile = await input.loadProfile(input.userId);
|
||||
if (input.subject) {
|
||||
const resolved = await resolveConsultationSubject({
|
||||
userId: input.userId,
|
||||
binding: input.subject.binding,
|
||||
loadSelfProfile: input.loadProfile,
|
||||
loadOwnedChartProfile: input.subject.loadOwnedChartProfile,
|
||||
clientBirth: input.subject.clientBirth,
|
||||
});
|
||||
subjectRole = resolved.role;
|
||||
subject = {
|
||||
role: resolved.role,
|
||||
name: resolved.name,
|
||||
chartProfileId: resolved.chartProfileId,
|
||||
};
|
||||
if (resolved.profile == null) throw new ConsultationProfileTruthError("profile_unavailable");
|
||||
profile = resolved.profile;
|
||||
} else {
|
||||
profile = await input.loadProfile(input.userId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (input.mode === "general_no_birth_time") profile = null;
|
||||
if (error instanceof ConsultationSubjectError) throw error;
|
||||
if (input.mode === "general_no_birth_time" && subjectRole !== "other") profile = null;
|
||||
else if (error instanceof ConsultationProfileTruthError) throw error;
|
||||
else throw new ConsultationProfileTruthError("profile_unavailable");
|
||||
}
|
||||
@@ -531,6 +570,7 @@ export async function prepareConsultationRoute<Reservation, GuardResult>(
|
||||
if (
|
||||
consultationMode === "verified_chart"
|
||||
&& serverChart.truth.birthTimeStatus === "accepted"
|
||||
&& subjectRole === "self"
|
||||
&& input.loadCandidateRange
|
||||
) {
|
||||
try {
|
||||
@@ -580,5 +620,6 @@ export async function prepareConsultationRoute<Reservation, GuardResult>(
|
||||
generalDailyReference,
|
||||
reservation,
|
||||
preReserveResult,
|
||||
subject,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Ordinary-chat subject truth.
|
||||
*
|
||||
* `self` is the current user's `profiles` row. `other` is a `chart_profiles`
|
||||
* row owned by that user. Session name/role snapshots and client birth fields
|
||||
* are not chart truth. A failed other lookup never falls back to self.
|
||||
*/
|
||||
|
||||
export const consultationSubjectErrorCodes = [
|
||||
"subject_not_found",
|
||||
"subject_role_mismatch",
|
||||
"subject_incomplete",
|
||||
"subject_unavailable",
|
||||
"subject_locked",
|
||||
] as const;
|
||||
|
||||
export type ConsultationSubjectErrorCode = (typeof consultationSubjectErrorCodes)[number];
|
||||
|
||||
export class ConsultationSubjectError extends Error {
|
||||
readonly code: ConsultationSubjectErrorCode;
|
||||
|
||||
constructor(code: ConsultationSubjectErrorCode) {
|
||||
super(`Consultation subject rejected: ${code}`);
|
||||
this.name = "ConsultationSubjectError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsultationSubjectBinding = Readonly<{
|
||||
chartProfileId: string | null;
|
||||
chartProfileRole: "self" | "other" | null;
|
||||
chartProfileName: string | null;
|
||||
}>;
|
||||
|
||||
export type OwnedChartProfile = Readonly<{
|
||||
id: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
profile: unknown;
|
||||
}>;
|
||||
|
||||
export type ResolvedConsultationSubject = Readonly<{
|
||||
role: "self" | "other";
|
||||
chartProfileId: string | null;
|
||||
name: string;
|
||||
profile: unknown;
|
||||
}>;
|
||||
|
||||
export type ConsultationSubjectFailureBody = Readonly<{
|
||||
error: string;
|
||||
message: string;
|
||||
code: "subject_incomplete" | "subject_unavailable" | "subject_locked";
|
||||
}>;
|
||||
|
||||
const bindingKeys = ["chart_profile_id", "chart_profile_name", "chart_profile_role"] as const;
|
||||
|
||||
const emptyBinding: ConsultationSubjectBinding = {
|
||||
chartProfileId: null,
|
||||
chartProfileRole: null,
|
||||
chartProfileName: null,
|
||||
};
|
||||
|
||||
function textOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function recordOf(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function pickText(row: Record<string, unknown>, keys: readonly string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = textOrNull(row[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickNumber(row: Record<string, unknown>, keys: readonly string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = row[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function profileName(value: unknown): string {
|
||||
const row = recordOf(value);
|
||||
const name = row ? pickText(row, ["name"]) : null;
|
||||
return name ? name.slice(0, 80) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Library rows are camelCase `Profile` objects. Consultation truth is the
|
||||
* snake_case account shape. Client request keys (`year`, `lat`, `city`, …)
|
||||
* are not read, so they cannot fill gaps.
|
||||
*/
|
||||
export function chartProfileToConsultationRow(value: unknown): Record<string, unknown> | null {
|
||||
const row = recordOf(value);
|
||||
if (!row) return null;
|
||||
const status = pickText(row, ["birth_time_status", "birthTimeStatus"]);
|
||||
const verified = status === "accepted" || status === "confirmed";
|
||||
const activeTime = verified
|
||||
? pickText(row, ["active_birth_time", "time"])
|
||||
: pickText(row, ["active_birth_time"]);
|
||||
return {
|
||||
name: pickText(row, ["name"]),
|
||||
birth_date: pickText(row, ["birth_date", "date"]),
|
||||
active_birth_date: pickText(row, ["active_birth_date", "activeDate"]),
|
||||
reported_birth_time: pickText(row, ["reported_birth_time", "reportedTime"]),
|
||||
active_birth_time: activeTime,
|
||||
birth_time_source: pickText(row, ["birth_time_source", "birthTimeSource"]),
|
||||
birth_time_period: pickText(row, ["birth_time_period", "birthTimePeriod"]),
|
||||
declared_window_start: pickText(row, ["declared_window_start", "declaredWindowStart"]),
|
||||
declared_window_end: pickText(row, ["declared_window_end", "declaredWindowEnd"]),
|
||||
birth_time_status: status,
|
||||
country_code: pickText(row, ["country_code", "countryCode"]),
|
||||
province_code: pickText(row, ["province_code", "provinceCode"]),
|
||||
city_code: pickText(row, ["city_code", "cityCode"]),
|
||||
district_code: pickText(row, ["district_code", "districtCode"]),
|
||||
latitude: pickNumber(row, ["latitude"]),
|
||||
longitude: pickNumber(row, ["longitude"]),
|
||||
timezone_offset: pickNumber(row, ["timezone_offset", "timezoneOffset"]),
|
||||
active_birth_timezone_offset: pickNumber(row, ["active_birth_timezone_offset", "activeTimezoneOffset"]),
|
||||
birth_place_label: pickText(row, ["birth_place_label", "birthPlaceLabel"]),
|
||||
birth_place_type: pickText(row, ["birth_place_type", "birthPlaceType"]),
|
||||
birth_place_provider: pickText(row, ["birth_place_provider", "birthPlaceProvider"]),
|
||||
birth_place_provider_id: pickText(row, ["birth_place_provider_id", "birthPlaceProviderId"]),
|
||||
timezone_id: pickText(row, ["timezone_id", "timezoneId"]),
|
||||
timezone_source: pickText(row, ["timezone_source", "timezoneSource"]),
|
||||
ayanamsa: pickText(row, ["ayanamsa"]),
|
||||
};
|
||||
}
|
||||
|
||||
function otherProfileComplete(row: Record<string, unknown>): boolean {
|
||||
const name = typeof row.name === "string" && row.name.trim();
|
||||
const birthDate = typeof row.birth_date === "string" && row.birth_date.trim();
|
||||
const source = typeof row.birth_time_source === "string" && row.birth_time_source.trim();
|
||||
const status = typeof row.birth_time_status === "string" && row.birth_time_status.trim();
|
||||
const latitude = typeof row.latitude === "number" && Number.isFinite(row.latitude);
|
||||
const longitude = typeof row.longitude === "number" && Number.isFinite(row.longitude);
|
||||
const label = typeof row.birth_place_label === "string" && row.birth_place_label.trim();
|
||||
const timezoneOffset = typeof row.timezone_offset === "number" && Number.isFinite(row.timezone_offset);
|
||||
const timezoneId = typeof row.timezone_id === "string" && row.timezone_id.trim();
|
||||
return Boolean(name && birthDate && source && status && latitude && longitude && label && (timezoneOffset || timezoneId));
|
||||
}
|
||||
|
||||
export function consultationSubjectBindingFromSession(row: {
|
||||
chart_profile_id?: unknown;
|
||||
chart_profile_name?: unknown;
|
||||
chart_profile_role?: unknown;
|
||||
}): ConsultationSubjectBinding {
|
||||
const role = row.chart_profile_role === "self" || row.chart_profile_role === "other"
|
||||
? row.chart_profile_role
|
||||
: null;
|
||||
return {
|
||||
chartProfileId: textOrNull(row.chart_profile_id),
|
||||
chartProfileRole: role,
|
||||
chartProfileName: textOrNull(row.chart_profile_name),
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionHasMessages(messages: unknown): boolean {
|
||||
if (messages == null) return false;
|
||||
if (Array.isArray(messages)) return messages.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sessionBindingRequested(values: object): boolean {
|
||||
return bindingKeys.some((key) => Object.prototype.hasOwnProperty.call(values, key));
|
||||
}
|
||||
|
||||
export function sameSessionBinding(
|
||||
left: ConsultationSubjectBinding,
|
||||
right: ConsultationSubjectBinding,
|
||||
): boolean {
|
||||
return textOrNull(left.chartProfileId) === textOrNull(right.chartProfileId)
|
||||
&& (left.chartProfileRole ?? null) === (right.chartProfileRole ?? null)
|
||||
&& textOrNull(left.chartProfileName) === textOrNull(right.chartProfileName);
|
||||
}
|
||||
|
||||
async function readOwnedChart(
|
||||
load: (chartProfileId: string) => Promise<OwnedChartProfile | null>,
|
||||
userId: string,
|
||||
chartProfileId: string,
|
||||
): Promise<OwnedChartProfile | null> {
|
||||
try {
|
||||
const row = await load(chartProfileId);
|
||||
if (row === null) return null;
|
||||
if (!row || typeof row.id !== "string" || typeof row.userId !== "string" || typeof row.role !== "string") {
|
||||
throw new ConsultationSubjectError("subject_unavailable");
|
||||
}
|
||||
if (row.userId !== userId) throw new ConsultationSubjectError("subject_not_found");
|
||||
return row;
|
||||
} catch (error) {
|
||||
if (error instanceof ConsultationSubjectError) throw error;
|
||||
throw new ConsultationSubjectError("subject_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOther(
|
||||
input: {
|
||||
userId: string;
|
||||
loadOwnedChartProfile: (chartProfileId: string) => Promise<OwnedChartProfile | null>;
|
||||
},
|
||||
chartProfileId: string,
|
||||
): Promise<ResolvedConsultationSubject> {
|
||||
const row = await readOwnedChart(input.loadOwnedChartProfile, input.userId, chartProfileId);
|
||||
if (!row) throw new ConsultationSubjectError("subject_not_found");
|
||||
if (row.role !== "other") throw new ConsultationSubjectError("subject_role_mismatch");
|
||||
const profile = chartProfileToConsultationRow(row.profile);
|
||||
if (!profile || !otherProfileComplete(profile)) {
|
||||
throw new ConsultationSubjectError("subject_incomplete");
|
||||
}
|
||||
return Object.freeze({
|
||||
role: "other",
|
||||
chartProfileId: row.id,
|
||||
name: profileName(profile),
|
||||
profile: Object.freeze({ ...profile }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveConsultationSubject(input: {
|
||||
userId: string;
|
||||
binding: ConsultationSubjectBinding;
|
||||
loadSelfProfile: (userId: string) => Promise<unknown>;
|
||||
loadOwnedChartProfile: (chartProfileId: string) => Promise<OwnedChartProfile | null>;
|
||||
/** Present so callers can pass the request body. Never read for chart truth. */
|
||||
clientBirth?: unknown;
|
||||
}): Promise<ResolvedConsultationSubject> {
|
||||
const id = textOrNull(input.binding.chartProfileId);
|
||||
const role = input.binding.chartProfileRole ?? null;
|
||||
const explicitOtherId = Boolean(id && id !== "self");
|
||||
|
||||
if (role === "other") {
|
||||
if (!explicitOtherId) throw new ConsultationSubjectError("subject_role_mismatch");
|
||||
return resolveOther(input, id as string);
|
||||
}
|
||||
|
||||
if (explicitOtherId && role !== "self") {
|
||||
return resolveOther(input, id as string);
|
||||
}
|
||||
|
||||
if (role === "self" && explicitOtherId) {
|
||||
const row = await readOwnedChart(input.loadOwnedChartProfile, input.userId, id as string);
|
||||
if (!row) throw new ConsultationSubjectError("subject_not_found");
|
||||
if (row.role !== "self") throw new ConsultationSubjectError("subject_role_mismatch");
|
||||
}
|
||||
|
||||
const selfRow = await input.loadSelfProfile(input.userId);
|
||||
return Object.freeze({
|
||||
role: "self",
|
||||
chartProfileId: "self",
|
||||
name: profileName(selfRow),
|
||||
profile: selfRow,
|
||||
});
|
||||
}
|
||||
|
||||
function mergedBinding(
|
||||
requested: object,
|
||||
existing: ConsultationSubjectBinding | null,
|
||||
): ConsultationSubjectBinding {
|
||||
const record = requested as Record<string, unknown>;
|
||||
const base = existing ?? emptyBinding;
|
||||
const role = record.chart_profile_role;
|
||||
return {
|
||||
chartProfileId: Object.prototype.hasOwnProperty.call(record, "chart_profile_id")
|
||||
? textOrNull(record.chart_profile_id)
|
||||
: base.chartProfileId,
|
||||
chartProfileRole: Object.prototype.hasOwnProperty.call(record, "chart_profile_role")
|
||||
? role === "self" || role === "other" ? role : null
|
||||
: base.chartProfileRole,
|
||||
chartProfileName: Object.prototype.hasOwnProperty.call(record, "chart_profile_name")
|
||||
? textOrNull(record.chart_profile_name)
|
||||
: base.chartProfileName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function authoritativeSessionBinding(input: {
|
||||
userId: string;
|
||||
requested: object;
|
||||
existingMessages: unknown;
|
||||
existingBinding: ConsultationSubjectBinding | null;
|
||||
loadSelfProfile: (userId: string) => Promise<unknown>;
|
||||
loadOwnedChartProfile: (chartProfileId: string) => Promise<OwnedChartProfile | null>;
|
||||
}): Promise<{
|
||||
chart_profile_id: string | null;
|
||||
chart_profile_name: string | null;
|
||||
chart_profile_role: "self" | "other" | null;
|
||||
} | null> {
|
||||
if (!sessionBindingRequested(input.requested)) return null;
|
||||
const requestedBinding = mergedBinding(input.requested, input.existingBinding);
|
||||
const existing = input.existingBinding ?? emptyBinding;
|
||||
if (sessionHasMessages(input.existingMessages)) {
|
||||
if (!sameSessionBinding(requestedBinding, existing)) {
|
||||
throw new ConsultationSubjectError("subject_locked");
|
||||
}
|
||||
return {
|
||||
chart_profile_id: existing.chartProfileId,
|
||||
chart_profile_name: existing.chartProfileName,
|
||||
chart_profile_role: existing.chartProfileRole,
|
||||
};
|
||||
}
|
||||
const resolved = await resolveConsultationSubject({
|
||||
userId: input.userId,
|
||||
binding: requestedBinding,
|
||||
loadSelfProfile: input.loadSelfProfile,
|
||||
loadOwnedChartProfile: input.loadOwnedChartProfile,
|
||||
clientBirth: {
|
||||
name: requestedBinding.chartProfileName,
|
||||
},
|
||||
});
|
||||
return {
|
||||
chart_profile_id: resolved.role === "self" ? "self" : resolved.chartProfileId,
|
||||
chart_profile_name: resolved.name || null,
|
||||
chart_profile_role: resolved.role,
|
||||
};
|
||||
}
|
||||
|
||||
export function ownedChartProfileFromResult(result: {
|
||||
data: unknown;
|
||||
error: unknown;
|
||||
}): OwnedChartProfile | null {
|
||||
if (result.error) throw new ConsultationSubjectError("subject_unavailable");
|
||||
const row = recordOf(result.data);
|
||||
if (!row) return null;
|
||||
if (typeof row.id !== "string" || typeof row.user_id !== "string" || typeof row.role !== "string") {
|
||||
throw new ConsultationSubjectError("subject_unavailable");
|
||||
}
|
||||
return { id: row.id, userId: row.user_id, role: row.role, profile: row.profile };
|
||||
}
|
||||
|
||||
export function selfNameFromResult(result: { data: unknown; error: unknown }): unknown {
|
||||
if (result.error) throw new ConsultationSubjectError("subject_unavailable");
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function consultationSubjectFailureResponse(error: ConsultationSubjectError): {
|
||||
status: number;
|
||||
body: ConsultationSubjectFailureBody;
|
||||
} {
|
||||
if (error.code === "subject_locked") {
|
||||
return {
|
||||
status: 409,
|
||||
body: {
|
||||
error: "不能更换这段对话的人物",
|
||||
message: "这段对话已经开始。要换一个人,请新建对话。",
|
||||
code: "subject_locked",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (error.code === "subject_incomplete") {
|
||||
return {
|
||||
status: 409,
|
||||
body: {
|
||||
error: "这份人物资料还不完整",
|
||||
message: "请补全出生日期、时间和地点后再发送,本次不会扣点。",
|
||||
code: "subject_incomplete",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (error.code === "subject_unavailable") {
|
||||
return {
|
||||
status: 503,
|
||||
body: {
|
||||
error: "暂时无法核对人物资料",
|
||||
message: "请稍后重试,本次不会扣点。",
|
||||
code: "subject_unavailable",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 409,
|
||||
body: {
|
||||
error: "无法使用这份人物资料",
|
||||
message: "这份资料不存在、已删除,或与当前会话不一致。请新建对话后再试,本次不会扣点。",
|
||||
code: "subject_unavailable",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -73,7 +73,10 @@ test("standard consultation resolves and settles the session-pinned model versio
|
||||
assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);
|
||||
// Former value: select("id,model_id,model_config_version,session_type,messages").
|
||||
// First-round session titles need the current title, theme, and chart role.
|
||||
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role,context_summary"\)/);
|
||||
// 原值: 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"\)/);
|
||||
assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
|
||||
assert.match(consultRoute, /actualModelId: selectedModel\.id/);
|
||||
assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/);
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ConsultationProfileTruthError,
|
||||
prepareConsultationRoute,
|
||||
} from "../src/lib/consultation-route-service.ts";
|
||||
import {
|
||||
authoritativeSessionBinding,
|
||||
ConsultationSubjectError,
|
||||
consultationSubjectBindingFromSession,
|
||||
consultationSubjectFailureResponse,
|
||||
resolveConsultationSubject,
|
||||
type OwnedChartProfile,
|
||||
} from "../src/lib/consultation-subject-resolver.ts";
|
||||
|
||||
const selfProfile = Object.freeze({
|
||||
name: "Synthetic Self",
|
||||
birth_date: "1990-01-02",
|
||||
reported_birth_time: "08:15:00",
|
||||
active_birth_time: "08:15:00",
|
||||
birth_time_source: "family_exact",
|
||||
birth_time_status: "reported",
|
||||
birth_place_label: "Synthetic City",
|
||||
latitude: 31.2,
|
||||
longitude: 121.5,
|
||||
timezone_offset: 8,
|
||||
country_code: "CN",
|
||||
province_code: "310000",
|
||||
city_code: "310100",
|
||||
district_code: "",
|
||||
});
|
||||
|
||||
const otherProfile = Object.freeze({
|
||||
name: "Synthetic Other",
|
||||
date: "1988-03-04",
|
||||
reportedTime: "09:20",
|
||||
time: "",
|
||||
birthTimeSource: "family_exact",
|
||||
birthTimeStatus: "reported",
|
||||
birthPlaceLabel: "Synthetic Harbor",
|
||||
latitude: 22.3,
|
||||
longitude: 114.2,
|
||||
timezoneOffset: 8,
|
||||
countryCode: "CN",
|
||||
ayanamsa: "lahiri",
|
||||
});
|
||||
|
||||
const clientBirth = Object.freeze({
|
||||
name: "Client Name",
|
||||
year: 2001,
|
||||
month: 2,
|
||||
day: 3,
|
||||
hour: 4,
|
||||
minute: 5,
|
||||
city: "Client City",
|
||||
lat: 1,
|
||||
lon: 2,
|
||||
tz: 3,
|
||||
});
|
||||
|
||||
function ownedOther(profile: unknown = otherProfile, userId = "user-self"): OwnedChartProfile {
|
||||
return { id: "chart-other", userId, role: "other", profile };
|
||||
}
|
||||
|
||||
test("self resolves from the authoritative profile and other resolves from the owned chart profile", async () => {
|
||||
let selfLoads = 0;
|
||||
let otherLoads = 0;
|
||||
const self = await resolveConsultationSubject({
|
||||
userId: "user-self",
|
||||
binding: { chartProfileId: "self", chartProfileRole: "self", chartProfileName: "Client Name" },
|
||||
clientBirth,
|
||||
loadSelfProfile: async (userId) => {
|
||||
assert.equal(userId, "user-self");
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
loadOwnedChartProfile: async () => {
|
||||
otherLoads += 1;
|
||||
return ownedOther();
|
||||
},
|
||||
});
|
||||
assert.equal(selfLoads, 1);
|
||||
assert.equal(otherLoads, 0);
|
||||
assert.equal(self.role, "self");
|
||||
assert.equal(self.name, "Synthetic Self");
|
||||
assert.equal((self.profile as { birth_date: string }).birth_date, "1990-01-02");
|
||||
|
||||
const legacy = await resolveConsultationSubject({
|
||||
userId: "user-self",
|
||||
binding: { chartProfileId: null, chartProfileRole: null, chartProfileName: null },
|
||||
clientBirth,
|
||||
loadSelfProfile: async () => selfProfile,
|
||||
loadOwnedChartProfile: async () => {
|
||||
otherLoads += 1;
|
||||
return ownedOther();
|
||||
},
|
||||
});
|
||||
assert.equal(otherLoads, 0);
|
||||
assert.equal(legacy.role, "self");
|
||||
assert.equal((legacy.profile as { birth_date: string }).birth_date, "1990-01-02");
|
||||
|
||||
selfLoads = 0;
|
||||
const other = await resolveConsultationSubject({
|
||||
userId: "user-self",
|
||||
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Stale Label" },
|
||||
clientBirth,
|
||||
loadSelfProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
loadOwnedChartProfile: async (chartProfileId) => {
|
||||
assert.equal(chartProfileId, "chart-other");
|
||||
return ownedOther();
|
||||
},
|
||||
});
|
||||
assert.equal(selfLoads, 0);
|
||||
assert.equal(other.role, "other");
|
||||
assert.equal(other.name, "Synthetic Other");
|
||||
assert.equal(other.chartProfileId, "chart-other");
|
||||
const row = other.profile as { birth_date: string; latitude: number; reported_birth_time: string };
|
||||
assert.equal(row.birth_date, "1988-03-04");
|
||||
assert.equal(row.latitude, 22.3);
|
||||
assert.equal(row.reported_birth_time, "09:20");
|
||||
assert.equal("year" in row, false);
|
||||
assert.equal("lat" in row, false);
|
||||
});
|
||||
|
||||
test("another user's id, a random id, a role mismatch, and a deleted or incomplete profile fail closed", async () => {
|
||||
const cases: Array<{ name: string; owned: OwnedChartProfile | null | Error; code: string }> = [
|
||||
{ name: "other user", owned: ownedOther(otherProfile, "user-other"), code: "subject_not_found" },
|
||||
{ name: "random id", owned: null, code: "subject_not_found" },
|
||||
{ name: "deleted", owned: null, code: "subject_not_found" },
|
||||
{
|
||||
name: "role mismatch",
|
||||
owned: { id: "chart-other", userId: "user-self", role: "self", profile: otherProfile },
|
||||
code: "subject_role_mismatch",
|
||||
},
|
||||
{
|
||||
name: "missing",
|
||||
owned: null,
|
||||
code: "subject_not_found",
|
||||
},
|
||||
{
|
||||
name: "incomplete",
|
||||
owned: ownedOther({ name: "Synthetic Other", year: 2001, lat: 1, city: "Client City" }),
|
||||
code: "subject_incomplete",
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
let selfLoads = 0;
|
||||
const error = await resolveConsultationSubject({
|
||||
userId: "user-self",
|
||||
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" },
|
||||
clientBirth,
|
||||
loadSelfProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
loadOwnedChartProfile: async () => {
|
||||
if (item.owned instanceof Error) throw item.owned;
|
||||
return item.owned;
|
||||
},
|
||||
}).then(() => null, (caught: unknown) => caught);
|
||||
assert.ok(error instanceof ConsultationSubjectError, item.name);
|
||||
assert.equal(error.code, item.code, item.name);
|
||||
assert.equal(error.message.includes("Client Name"), false, item.name);
|
||||
assert.equal(error.message.includes("Synthetic"), false, item.name);
|
||||
assert.equal(selfLoads, 0, item.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("a forged display name cannot complete or replace the server profile", async () => {
|
||||
let selfLoads = 0;
|
||||
const missingName = await resolveConsultationSubject({
|
||||
userId: "user-self",
|
||||
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" },
|
||||
clientBirth,
|
||||
loadSelfProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
loadOwnedChartProfile: async () => ownedOther({ ...otherProfile, name: "" }),
|
||||
}).then(() => null, (caught: unknown) => caught);
|
||||
assert.ok(missingName instanceof ConsultationSubjectError);
|
||||
assert.equal(missingName.code, "subject_incomplete");
|
||||
assert.equal(missingName.message.includes("Client Name"), false);
|
||||
assert.equal(selfLoads, 0);
|
||||
|
||||
const claimedSelf = await resolveConsultationSubject({
|
||||
userId: "user-self",
|
||||
binding: { chartProfileId: "chart-other", chartProfileRole: "self", chartProfileName: "Synthetic Self" },
|
||||
clientBirth,
|
||||
loadSelfProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
loadOwnedChartProfile: async () => ownedOther(),
|
||||
}).then(() => null, (caught: unknown) => caught);
|
||||
assert.ok(claimedSelf instanceof ConsultationSubjectError);
|
||||
assert.equal(claimedSelf.code, "subject_role_mismatch");
|
||||
assert.equal(selfLoads, 0);
|
||||
});
|
||||
|
||||
test("database failures stay closed and do not leak internals", async () => {
|
||||
const error = await resolveConsultationSubject({
|
||||
userId: "user-self",
|
||||
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null },
|
||||
loadSelfProfile: async () => selfProfile,
|
||||
loadOwnedChartProfile: async () => {
|
||||
throw new Error("duplicate key value violates unique constraint for user-other@example");
|
||||
},
|
||||
}).then(() => null, (caught: unknown) => caught);
|
||||
assert.ok(error instanceof ConsultationSubjectError);
|
||||
assert.equal(error.code, "subject_unavailable");
|
||||
assert.equal(error.message.includes("duplicate"), false);
|
||||
assert.equal(error.message.includes("example"), false);
|
||||
const response = consultationSubjectFailureResponse(error);
|
||||
const encoded = JSON.stringify(response);
|
||||
assert.equal(encoded.includes("duplicate"), false);
|
||||
assert.equal(encoded.includes("user-other"), false);
|
||||
assert.equal(encoded.includes("@"), false);
|
||||
assert.match(response.body.message, /不会扣点/);
|
||||
});
|
||||
|
||||
test("conflicting client birth fields still lose to the server profile", async () => {
|
||||
let reserves = 0;
|
||||
let selfLoads = 0;
|
||||
const prepared = await prepareConsultationRoute({
|
||||
userId: "user-self",
|
||||
mode: "unverified_birth_time",
|
||||
subject: {
|
||||
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" },
|
||||
clientBirth,
|
||||
loadOwnedChartProfile: async () => ownedOther(),
|
||||
},
|
||||
loadProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
beforeReserve({ serverChart, consultationMode }) {
|
||||
assert.equal(consultationMode, "unverified_birth_time");
|
||||
assert.equal(serverChart?.name, "Synthetic Other");
|
||||
assert.equal(serverChart?.toolInput.year, 1988);
|
||||
assert.equal(serverChart?.toolInput.month, 3);
|
||||
assert.equal(serverChart?.toolInput.day, 4);
|
||||
assert.equal(serverChart?.toolInput.hour, 9);
|
||||
assert.equal(serverChart?.toolInput.minute, 20);
|
||||
assert.equal(serverChart?.toolInput.city, "Synthetic Harbor");
|
||||
assert.equal(serverChart?.toolInput.lat, 22.3);
|
||||
assert.equal(serverChart?.toolInput.lon, 114.2);
|
||||
assert.equal(serverChart?.toolInput.tz, 8);
|
||||
assert.equal(serverChart?.toolInput.ayanamsa, "lahiri");
|
||||
},
|
||||
reserve: async () => {
|
||||
reserves += 1;
|
||||
return { charged: true };
|
||||
},
|
||||
});
|
||||
assert.equal(selfLoads, 0);
|
||||
assert.equal(reserves, 1);
|
||||
assert.equal(prepared.subject?.role, "other");
|
||||
assert.equal(prepared.subject?.name, "Synthetic Other");
|
||||
assert.notEqual(prepared.serverChart?.toolInput.year, clientBirth.year);
|
||||
});
|
||||
|
||||
test("consult preparation receives the other profile and a resolver failure does not charge or prepare a model call", async () => {
|
||||
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
assert.match(route, /chart_profile_id,chart_profile_name,chart_profile_role/);
|
||||
assert.match(route, /clientBirth: parsed\.data/);
|
||||
assert.match(route, /loadOwnedChartProfile/);
|
||||
assert.match(route, /consultationSubjectBindingFromSession\(chatSession\)/);
|
||||
assert.doesNotMatch(route, /parsed\.data\.name/);
|
||||
const failureHandler = route.slice(
|
||||
route.indexOf("instanceof ConsultationSubjectError"),
|
||||
route.indexOf("const modelSelection"),
|
||||
);
|
||||
assert.match(failureHandler, /consultationSubjectFailureResponse/);
|
||||
assert.doesNotMatch(failureHandler, /getJyotishAgent|streamAgentResponse|streamTextResponse/);
|
||||
|
||||
let reserves = 0;
|
||||
let selfLoads = 0;
|
||||
let preparations = 0;
|
||||
const error = await prepareConsultationRoute({
|
||||
userId: "user-self",
|
||||
mode: "unverified_birth_time",
|
||||
subject: {
|
||||
binding: consultationSubjectBindingFromSession({
|
||||
chart_profile_id: "chart-missing",
|
||||
chart_profile_role: "other",
|
||||
chart_profile_name: "Synthetic Other",
|
||||
}),
|
||||
clientBirth,
|
||||
loadOwnedChartProfile: async () => null,
|
||||
},
|
||||
loadProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
beforeReserve() {
|
||||
preparations += 1;
|
||||
},
|
||||
reserve: async () => {
|
||||
reserves += 1;
|
||||
return { charged: true };
|
||||
},
|
||||
}).then(() => null, (caught: unknown) => caught);
|
||||
assert.ok(error instanceof ConsultationSubjectError);
|
||||
assert.equal(error.code, "subject_not_found");
|
||||
assert.equal(selfLoads, 0);
|
||||
assert.equal(preparations, 0);
|
||||
assert.equal(reserves, 0);
|
||||
assert.equal(error instanceof ConsultationProfileTruthError, false);
|
||||
});
|
||||
|
||||
test("an other chart does not inherit the account candidate range", async () => {
|
||||
let rangeLoads = 0;
|
||||
const prepared = await prepareConsultationRoute({
|
||||
userId: "user-self",
|
||||
mode: "verified_chart",
|
||||
subject: {
|
||||
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null },
|
||||
loadOwnedChartProfile: async () => ownedOther({
|
||||
...otherProfile,
|
||||
time: "09:20",
|
||||
birthTimeStatus: "confirmed",
|
||||
}),
|
||||
},
|
||||
loadProfile: async () => selfProfile,
|
||||
loadCandidateRange: async () => {
|
||||
rangeLoads += 1;
|
||||
return { startTime: "08:00", endTime: "08:30" };
|
||||
},
|
||||
reserve: async () => "reserved",
|
||||
});
|
||||
assert.equal(rangeLoads, 0);
|
||||
assert.equal(prepared.serverChart?.toolInput.hour, 9);
|
||||
assert.equal("candidate_range" in (prepared.serverChart?.toolInput ?? {}), false);
|
||||
});
|
||||
|
||||
test("a session with messages rejects rebinding and an empty session stores the server name", async () => {
|
||||
const stored = consultationSubjectBindingFromSession({
|
||||
chart_profile_id: "chart-other",
|
||||
chart_profile_role: "other",
|
||||
chart_profile_name: "Synthetic Other",
|
||||
});
|
||||
let selfLoads = 0;
|
||||
const locked = await authoritativeSessionBinding({
|
||||
userId: "user-self",
|
||||
requested: {
|
||||
chart_profile_id: "chart-next",
|
||||
chart_profile_role: "other",
|
||||
chart_profile_name: "Client Name",
|
||||
},
|
||||
existingMessages: [{ role: "user", text: "synthetic question" }],
|
||||
existingBinding: stored,
|
||||
loadSelfProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
loadOwnedChartProfile: async () => ownedOther(),
|
||||
}).then(() => null, (caught: unknown) => caught);
|
||||
assert.ok(locked instanceof ConsultationSubjectError);
|
||||
assert.equal(locked.code, "subject_locked");
|
||||
assert.equal(selfLoads, 0);
|
||||
const lockedResponse = consultationSubjectFailureResponse(locked);
|
||||
assert.equal(lockedResponse.status, 409);
|
||||
assert.equal(JSON.stringify(lockedResponse).includes("Client Name"), false);
|
||||
|
||||
const forgedRole = await authoritativeSessionBinding({
|
||||
userId: "user-self",
|
||||
requested: {
|
||||
chart_profile_id: "chart-other",
|
||||
chart_profile_role: "self",
|
||||
chart_profile_name: "Synthetic Self",
|
||||
},
|
||||
existingMessages: [],
|
||||
existingBinding: null,
|
||||
loadSelfProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
loadOwnedChartProfile: async () => ownedOther(),
|
||||
}).then(() => null, (caught: unknown) => caught);
|
||||
assert.ok(forgedRole instanceof ConsultationSubjectError);
|
||||
assert.equal(forgedRole.code, "subject_role_mismatch");
|
||||
assert.equal(selfLoads, 0);
|
||||
|
||||
const created = await authoritativeSessionBinding({
|
||||
userId: "user-self",
|
||||
requested: {
|
||||
chart_profile_id: "chart-other",
|
||||
chart_profile_role: "other",
|
||||
chart_profile_name: "Client Name",
|
||||
},
|
||||
existingMessages: [],
|
||||
existingBinding: null,
|
||||
loadSelfProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
loadOwnedChartProfile: async () => ownedOther(),
|
||||
});
|
||||
assert.equal(selfLoads, 0);
|
||||
assert.deepEqual(created, {
|
||||
chart_profile_id: "chart-other",
|
||||
chart_profile_name: "Synthetic Other",
|
||||
chart_profile_role: "other",
|
||||
});
|
||||
});
|
||||
|
||||
test("refresh and deep link keep the stored binding instead of a global active chart", async () => {
|
||||
const activeChartId = "self";
|
||||
const stored = consultationSubjectBindingFromSession({
|
||||
chart_profile_id: "chart-other",
|
||||
chart_profile_name: "Synthetic Other",
|
||||
chart_profile_role: "other",
|
||||
});
|
||||
assert.notEqual(activeChartId, stored.chartProfileId);
|
||||
let selfLoads = 0;
|
||||
const resolved = await resolveConsultationSubject({
|
||||
userId: "user-self",
|
||||
binding: stored,
|
||||
loadSelfProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
loadOwnedChartProfile: async () => ownedOther(),
|
||||
});
|
||||
assert.equal(selfLoads, 0);
|
||||
assert.equal((resolved.profile as { birth_date: string }).birth_date, "1988-03-04");
|
||||
|
||||
const refreshed = await authoritativeSessionBinding({
|
||||
userId: "user-self",
|
||||
requested: {
|
||||
title: "synthetic title",
|
||||
chart_profile_id: stored.chartProfileId,
|
||||
chart_profile_name: stored.chartProfileName,
|
||||
chart_profile_role: stored.chartProfileRole,
|
||||
},
|
||||
existingMessages: [{ role: "assistant", text: "synthetic reply" }],
|
||||
existingBinding: stored,
|
||||
loadSelfProfile: async () => {
|
||||
throw new Error("refresh must not read profiles");
|
||||
},
|
||||
loadOwnedChartProfile: async () => {
|
||||
throw new Error("refresh must not reread chart_profiles");
|
||||
},
|
||||
});
|
||||
assert.equal(refreshed?.chart_profile_id, "chart-other");
|
||||
assert.equal(refreshed?.chart_profile_role, "other");
|
||||
assert.equal(refreshed?.chart_profile_name, "Synthetic Other");
|
||||
|
||||
const sessions = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
const sessionItem = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
|
||||
assert.match(sessions, /authoritativeSessionBinding/);
|
||||
assert.match(sessionItem, /authoritativeSessionBinding/);
|
||||
assert.match(sessionItem, /sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role/);
|
||||
assert.doesNotMatch(sessionItem, /activeChartId/);
|
||||
assert.doesNotMatch(sessions, /activeChartId/);
|
||||
});
|
||||
|
||||
test("a concurrent delete observed by the send fails closed and does not fall back to self", async () => {
|
||||
let visible: OwnedChartProfile | null = ownedOther();
|
||||
let selfLoads = 0;
|
||||
let reserves = 0;
|
||||
let preparations = 0;
|
||||
const pending = prepareConsultationRoute({
|
||||
userId: "user-self",
|
||||
mode: "unverified_birth_time",
|
||||
subject: {
|
||||
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Synthetic Other" },
|
||||
clientBirth,
|
||||
loadOwnedChartProfile: async () => {
|
||||
await Promise.resolve();
|
||||
return visible;
|
||||
},
|
||||
},
|
||||
loadProfile: async () => {
|
||||
selfLoads += 1;
|
||||
return selfProfile;
|
||||
},
|
||||
beforeReserve() {
|
||||
preparations += 1;
|
||||
},
|
||||
reserve: async () => {
|
||||
reserves += 1;
|
||||
return { charged: true };
|
||||
},
|
||||
});
|
||||
visible = null;
|
||||
const error = await pending.then(() => null, (caught: unknown) => caught);
|
||||
assert.ok(error instanceof ConsultationSubjectError);
|
||||
assert.equal(error.code, "subject_not_found");
|
||||
assert.equal(selfLoads, 0);
|
||||
assert.equal(preparations, 0);
|
||||
assert.equal(reserves, 0);
|
||||
|
||||
const source: { date: string; name: string } = { ...otherProfile };
|
||||
const resolved = await resolveConsultationSubject({
|
||||
userId: "user-self",
|
||||
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null },
|
||||
loadSelfProfile: async () => selfProfile,
|
||||
loadOwnedChartProfile: async () => ownedOther(source),
|
||||
});
|
||||
source.date = "2000-01-01";
|
||||
source.name = "Changed After Read";
|
||||
assert.equal((resolved.profile as { birth_date?: string }).birth_date, "1988-03-04");
|
||||
assert.equal(resolved.name, "Synthetic Other");
|
||||
});
|
||||
Reference in New Issue
Block a user