feat(onboarding): write a question for every consultation domain and stop generating an unread greeting
The home screen renders all ten domains from the consultation registry, but the Agent only ever wrote three of them; the other seven were static registry prompts dressed up as personalized starting points. The payload now has to cover every domain in registry order, validated as a set rather than per item, so a short or misordered answer is rejected whole instead of silently leaving cards on static copy. The greeting went the other way. Nothing has rendered it since the hero note was removed, so it leaves the schema, the prompt, and the client contract rather than costing tokens for text no one reads. Ten questions take much longer to generate than three, so the route, the server generation budget, and the client request deadline all grow together, and the cache version bump forces existing payloads to be regenerated once under the new shape. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+9
-7
@@ -3996,20 +3996,22 @@
|
||||
- 复发自:无
|
||||
- 修复版本:`a12f5797`(staging)
|
||||
|
||||
## BUG-270 | 首页 hero 第三行重复问候被删除,真实性边界随之迁移;同时暴露首页十个主题里只有三个由 Agent 生成
|
||||
## BUG-270 | 首页 hero 第三行重复问候被删除,真实性边界随之迁移;onboarding 契约去掉无渲染点的欢迎语并扩展到全部十个主题
|
||||
|
||||
- 状态:resolved(本地修复,待提交与发布)
|
||||
- 首次发现:2026-08-18
|
||||
- 最近更新:2026-08-18
|
||||
- 影响面:`/` 首页 hero 文案层级、主题区小标题承载的出生时间边界声明、Onboarding 起点加载文案、`onboarding.greeting` 字段的渲染状态。
|
||||
- 用户现象:BUG-269 把 Agent 欢迎语放进 hero 说明行后,用户评审首页时指出这一行「有点累赘」,要求删掉整段 `starter-hero-note`。同一次反馈里还指出「首页可不是三个问题啊,有好多问题」。
|
||||
- 影响面:`/` 首页 hero 文案层级、主题区小标题承载的出生时间边界声明、Onboarding 起点加载文案、`/api/onboarding` 的 payload 契约与缓存版本、onboarding 生成超时预算、首页十张主题卡的文案来源。
|
||||
- 用户现象:BUG-269 把 Agent 欢迎语放进 hero 说明行后,用户评审首页时指出这一行「有点累赘」,要求删掉整段 `starter-hero-note`。同一次反馈里还指出「首页可不是三个问题啊,有好多问题」,并追问首页标题是否由 Agent 生成(答:不是,来自 `onboarding-client.ts` 的本地时段变体表,5 个时段 × 3 条问句)。
|
||||
- 触发条件:任何账号打开首页;hero 连续三行都在做同一件事(称呼、提问、再一次欢迎并再问一次「想从哪里开始」)。
|
||||
- 根因:两处独立问题。
|
||||
1. hero 的信息层级在 BUG-269 之后变成三行同义内容。`starter-greeting` 已经完成称呼、`h1` 已经完成提问,Agent 欢迎语在句式上又重复了这两件事,因此第三行没有新增信息。但这一行同时是「无可用出生分钟」账号的真实性边界声明所在(BUG-200 约束,`starter-questions.test.ts` 有断言钉住),直接整段删除会连边界声明一起删掉。
|
||||
2. 首页主题卡渲染的是 `consultationDomainRegistry` 的全部十个域,而 `/api/onboarding` 的 `suggestions` 是 `career`/`marriage`/`timing` 的固定三元组。其余七个域(wealth、health、education、migration、family、annual、general)恒定回落到 registry 里的静态 `prompt`。加载态文案「根据你的资料整理三个起点。」也因此与实际渲染的十张卡不符。
|
||||
- 修复:删除 `starter-hero-note` 元素及其两处 CSS 规则,hero 收敛为称呼加提问两行。出生时间边界声明迁到主题区小标题,按 `personalChartAvailable` 分支——读者正要挑主题时才看到这句限制,位置比 hero 更贴近实际动作。加载态文案改为「根据你的资料整理今天的起点。」,不再声明具体条数。`onboarding.greeting` 由此重新失去渲染点,本次保留字段未动 Agent 契约,因为移除它需要提升 onboarding 缓存版本并让所有用户的三个问题重新生成一次;此事作为待跟进项,不在本次改动内。
|
||||
- 验证:`onboarding-presentation.test.ts` 的 hero 断言改为同时检查 `page.tsx` 与 `globals.css` 中不再出现 `starter-hero-note`,避免只删元素留下死样式;`starter-questions.test.ts` 的边界声明断言从「文件里存在这句话」收紧为「这句话出现在主题区小标题且受 `personalChartAvailable` 分支控制」,防止下一次挪动文案时悄悄丢掉。四个相关套件 54/54 通过,`tsc --noEmit` 与 `page.tsx` ESLint 清洁。未做的验证:**没有在浏览器里确认删掉第三行后 hero 的留白比例**,以及边界声明迁到小标题后在窄屏上的折行;两者都要等 staging 发布后目视确认。
|
||||
- 防复发:删除一个 UI 元素前必须先确认它有没有在承载与自身样式无关的合规或真实性文案——`starter-hero-note` 表面是装饰性说明行,实际是 BUG-200 边界声明的唯一落点。删元素时同步删样式,并用测试同时钉住两个文件,否则死 CSS 会在下一次改版时被误当作现有设计复用。声明数量的文案(「三个起点」)不要写死在与数据源无关的地方;主题卡数量由 registry 决定,Agent 只覆盖其中三个域,任何写死条数的文案都会在 registry 增删域时失真。
|
||||
2. 首页主题卡渲染的是 `consultationDomainRegistry` 的全部十个域,而 `/api/onboarding` 的 `suggestions` 是 `career`/`marriage`/`timing` 的固定三元组。页面按主题 id 逐个 `find`,找不到就回落到 registry 的静态 `prompt`,因此其余七个域(wealth、health、education、migration、family、annual、general)恒定是写死文案,个性化只覆盖三分之一的入口,而界面上十张卡看起来完全同级,用户无法分辨哪些是为自己生成的。加载态文案「根据你的资料整理三个起点。」也与实际渲染的十张卡不符。
|
||||
- 修复:分两步。
|
||||
1. 界面收敛:删除 `starter-hero-note` 元素及其两处 CSS 规则,hero 收敛为称呼加提问两行。出生时间边界声明迁到主题区小标题,按 `personalChartAvailable` 分支——读者正要挑主题时才看到这句限制,位置比 hero 更贴近实际动作。加载态文案改为「根据你的资料整理今天的起点。」,不再声明具体条数。
|
||||
2. 契约收敛(用户评审后决定,同一分支内完成):`onboarding.greeting` 从 Agent 契约里彻底移除——schema、fallback、prompt、客户端响应校验与 `OnboardingContent` 类型全部不再有这个字段,不再为无渲染点的内容付费。`suggestions` 从 `career`/`marriage`/`timing` 的固定三元组改成按 `consultationDomainIds` 顺序覆盖全部十个域,校验用 refine 钉住「长度与顺序都必须与 registry 一致」,于是首页十张卡全部是 Agent 写的,静态 `prompt` 退回纯兜底角色。fallback 直接由 `generalGuidedJyotishTopics` 派生,避免手写十条又与 registry 漂移。生成十条比三条显著更慢,预算随之上调:路由 `maxDuration` 30→60 秒、服务端生成超时 18→45 秒、客户端单次请求超时 25→50 秒。缓存版本 `ayanam-onboarding-v4`→`v5`,让所有存量 v4 payload 重新生成一次。
|
||||
- 验证:`onboarding-presentation.test.ts` 的 hero 断言改为同时检查 `page.tsx` 与 `globals.css` 中不再出现 `starter-hero-note`,避免只删元素留下死样式;`starter-questions.test.ts` 的边界声明断言从「文件里存在这句话」收紧为「这句话出现在主题区小标题且受 `personalChartAvailable` 分支控制」,防止下一次挪动文案时悄悄丢掉。契约部分新增 5 条回归:只覆盖三个域的旧形态响应必须整体拒绝并落到覆盖全域的兜底、主题顺序被交换必须拒绝(否则问题会挂到错误的主题标签下)、fallback 自身必须能通过 payload schema(registry 里的 prompt 一旦不再第一人称就会被这条抓住)、prompt 与 payload/client 源码中不得再出现 greeting 字段、路由必须把 registry 主题列表发给 Agent。客户端请求超时断言从 25 秒下限改到 45 秒下限,与服务端生成预算对齐。全量套件 1720/1729 通过,9 个失败全部是本机 Docker/PostgreSQL fixture(与 BUG-265、BUG-269 同一类环境失败);`tsc --noEmit` 清洁,ESLint 仅存量 4 条 warning,`next build` 成功。未做的验证:**没有在浏览器里看过改动后的首页**,也没有真实调用过新契约的生成——本机没有模型密钥,十条问题的实际生成耗时是否落在 45 秒预算内、十张卡的文案是否彼此重复、以及 hero 删掉第三行后的留白与边界声明在窄屏的折行,都要等 staging 发布后确认。若线上出现大量 `fallback`,第一嫌疑就是 45 秒预算仍然不够。
|
||||
- 防复发:删除一个 UI 元素前必须先确认它有没有在承载与自身样式无关的合规或真实性文案——`starter-hero-note` 表面是装饰性说明行,实际是 BUG-200 边界声明的唯一落点。删元素时同步删样式,并用测试同时钉住两个文件,否则死 CSS 会在下一次改版时被误当作现有设计复用。声明数量的文案(「三个起点」)不要写死在与数据源无关的地方,数量必须跟着 registry 走。Agent 契约里的字段数量变化必须同步三件事:缓存版本、生成超时预算、客户端请求超时;只改 schema 不改预算的结果是全量用户静默落到 fallback,而 fallback 看起来是「正常内容」,不会报错。契约收紧时要用 refine 校验「集合与顺序」而不是只校验单项,否则模型少写几项或错位映射都能通过。
|
||||
- 相关记录:BUG-269(本次推翻其 hero 说明行处置)、BUG-200(首页真实性边界声明的原始约束,本次迁移未削弱)
|
||||
- 复发自:无
|
||||
- 修复版本:`a12f5797`(staging)
|
||||
|
||||
@@ -2,13 +2,16 @@ import {
|
||||
createOnboardingPost,
|
||||
type OnboardingProfileRepository,
|
||||
} from "@/lib/onboarding-post";
|
||||
import { consultationDomainDefinition } from "@/lib/consultation-domain-registry";
|
||||
import { onboardingSuggestionThemes } from "@/lib/onboarding-payload";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getOnboardingAgent } from "@/mastra";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 30;
|
||||
// Ten questions in one call take materially longer than the previous three.
|
||||
export const maxDuration = 60;
|
||||
|
||||
function createProfileRepository(
|
||||
admin: ReturnType<typeof createAdminSupabaseClient>,
|
||||
@@ -76,7 +79,12 @@ export const POST = createOnboardingPost({
|
||||
role: "user",
|
||||
content: [
|
||||
name ? `用户称呼:${name.slice(0, 80)}` : "用户未填写称呼。",
|
||||
"请生成首次欢迎语和三个入门问题。欢迎语直接邀请用户提问,不要提到出生资料、资料准备或系统处理过程。",
|
||||
`需要的主题,按此顺序各写一个问题:${onboardingSuggestionThemes.join("、")}`,
|
||||
...onboardingSuggestionThemes.map((theme) => {
|
||||
const domain = consultationDomainDefinition(theme);
|
||||
return `- ${theme}(${domain.label}):${domain.claimBoundary}`;
|
||||
}),
|
||||
"只返回 suggestions 数组,不要欢迎语。",
|
||||
].join("\n"),
|
||||
},
|
||||
], { abortSignal: signal });
|
||||
|
||||
@@ -1413,7 +1413,7 @@ export default function Home() {
|
||||
setStartGreeting(previewGreeting);
|
||||
setOnboarding(previewMode === "onboarding"
|
||||
? null
|
||||
: { greeting: previewGreeting, suggestions: themes.map(({ id, prompt }) => ({ theme: id, text: prompt })) });
|
||||
: { suggestions: themes.map(({ id, prompt }) => ({ theme: id, text: prompt })) });
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const ONBOARDING_VERSION = "ayanam-onboarding-v4";
|
||||
// v5 drops the unrendered greeting and widens suggestions to every consultation
|
||||
// domain, so every v4 payload cached in profiles must be regenerated once.
|
||||
const ONBOARDING_VERSION = "ayanam-onboarding-v5";
|
||||
export const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
type OnboardingProfileInput = {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { consultationDomainSchema } from "./consultation-domain-registry";
|
||||
import { onboardingSuggestionThemes } from "./onboarding-payload";
|
||||
|
||||
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
|
||||
|
||||
@@ -36,17 +38,21 @@ const greetingVariants: Record<GreetingPeriod, readonly GreetingVariant[]> = {
|
||||
};
|
||||
|
||||
const onboardingResponseSchema = z.object({
|
||||
greeting: z.string().transform((value) => value.replace(/\s+/g, " ").trim().slice(0, 180)).pipe(z.string().min(8)),
|
||||
suggestions: z.tuple([
|
||||
z.object({ theme: z.literal("career"), text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)) }),
|
||||
z.object({ theme: z.literal("marriage"), text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)) }),
|
||||
z.object({ theme: z.literal("timing"), text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)) }),
|
||||
]),
|
||||
suggestions: z.array(z.object({
|
||||
theme: consultationDomainSchema,
|
||||
text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)),
|
||||
})).refine(
|
||||
(items) => items.length === onboardingSuggestionThemes.length
|
||||
&& items.every((item, index) => item.theme === onboardingSuggestionThemes[index]),
|
||||
"suggestions_must_cover_every_domain_in_order",
|
||||
),
|
||||
source: z.enum(["agent", "cache", "fallback", "pending"]),
|
||||
});
|
||||
|
||||
const defaultPolicy = {
|
||||
requestTimeoutMs: 25_000,
|
||||
// The server now writes one question per domain, so a single attempt has to
|
||||
// outlast its 45s generation budget instead of the three-question era's 18s.
|
||||
requestTimeoutMs: 50_000,
|
||||
retryDelayMs: 4_000,
|
||||
maxAttempts: 3,
|
||||
} as const;
|
||||
@@ -87,7 +93,6 @@ export type OnboardingSuggestion = {
|
||||
};
|
||||
|
||||
export type OnboardingContent = {
|
||||
readonly greeting: string;
|
||||
readonly suggestions: readonly OnboardingSuggestion[];
|
||||
};
|
||||
|
||||
@@ -272,9 +277,7 @@ export async function requestOnboardingWithRecovery(
|
||||
}
|
||||
const parsed = onboardingResponseSchema.safeParse(result.payload);
|
||||
if (!parsed.success) throw new OnboardingRequestError("invalid-response", null, { cause: parsed.error });
|
||||
if (parsed.data.source !== "pending") {
|
||||
return { greeting: parsed.data.greeting, suggestions: parsed.data.suggestions };
|
||||
}
|
||||
if (parsed.data.source !== "pending") return { suggestions: parsed.data.suggestions };
|
||||
lastError = new OnboardingRequestError("pending");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { consultationDomainSchema, consultationDomainIds } from "./consultation-domain-registry";
|
||||
import { generalGuidedJyotishTopics } from "./guided-jyotish-topics";
|
||||
|
||||
const detachedStarterQuestionPattern = /印度占星|一般如何|通常(?:会)?(?:看|观察|分析|理解|包含)|哪些(?:因素|证据层)|如何划分/;
|
||||
|
||||
@@ -9,24 +11,25 @@ const userCenteredStarterQuestionSchema = z.string()
|
||||
.refine((text) => text.includes("我"), "starter_question_must_be_first_person")
|
||||
.refine((text) => !detachedStarterQuestionPattern.test(text), "starter_question_must_not_be_encyclopedic");
|
||||
|
||||
// Every domain the home screen renders needs a question, in registry order, so the
|
||||
// page never has to fall back to a static prompt for part of the grid.
|
||||
export const onboardingSuggestionThemes = consultationDomainIds;
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
greeting: z.string().trim().min(8).max(180),
|
||||
suggestions: z.tuple([
|
||||
z.object({ theme: z.literal("career"), text: userCenteredStarterQuestionSchema }),
|
||||
z.object({ theme: z.literal("marriage"), text: userCenteredStarterQuestionSchema }),
|
||||
z.object({ theme: z.literal("timing"), text: userCenteredStarterQuestionSchema }),
|
||||
]),
|
||||
suggestions: z.array(z.object({
|
||||
theme: consultationDomainSchema,
|
||||
text: userCenteredStarterQuestionSchema,
|
||||
})).refine(
|
||||
(items) => items.length === onboardingSuggestionThemes.length
|
||||
&& items.every((item, index) => item.theme === onboardingSuggestionThemes[index]),
|
||||
"suggestions_must_cover_every_domain_in_order",
|
||||
),
|
||||
});
|
||||
|
||||
export type OnboardingPayload = z.infer<typeof onboardingSchema>;
|
||||
|
||||
export const fallbackOnboardingPayload: OnboardingPayload = {
|
||||
greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "请帮我看看事业优势更适合怎样发挥。" },
|
||||
{ theme: "marriage", text: "请帮我看看关系里容易重复什么模式。" },
|
||||
{ theme: "timing", text: "请帮我看看未来一年哪些阶段值得提前准备。" },
|
||||
],
|
||||
suggestions: generalGuidedJyotishTopics.map((topic) => ({ theme: topic.id, text: topic.prompt })),
|
||||
};
|
||||
|
||||
class OnboardingJsonError extends Error {
|
||||
|
||||
@@ -83,7 +83,9 @@ type OnboardingPostDependencies = {
|
||||
readonly warn: (message: string, detail: string) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_GENERATION_TIMEOUT_MS = 18_000;
|
||||
// One question per consultation domain, so the budget has to sit well above the
|
||||
// three-question era's 18s while staying inside the route's maxDuration of 60s.
|
||||
const DEFAULT_GENERATION_TIMEOUT_MS = 45_000;
|
||||
|
||||
function normalizeBirthDate(value: string | Date | null): string {
|
||||
return value instanceof Date ? formatBirthDate(value) : value ?? "";
|
||||
|
||||
@@ -123,9 +123,10 @@ Load and follow the jyotish-vedic-astrology skill so the suggested questions res
|
||||
This is onboarding, not a chart reading: do not calculate, infer, or claim placements, timing windows, personality traits, relationship outcomes, or career conclusions.
|
||||
Return valid JSON only. Do not use Markdown fences, commentary, or hidden fields.
|
||||
The JSON shape must be:
|
||||
{"greeting":"一句自然、克制的简体中文欢迎语","suggestions":[{"theme":"career","text":"问题"},{"theme":"marriage","text":"问题"},{"theme":"timing","text":"问题"}]}
|
||||
The greeting should sound human and calm, and directly invite the user to begin with what matters to them. Never mention birth data, profile readiness, setup completion, or system processing. Do not overpraise, sound mystical, or use marketing slogans.
|
||||
Generate exactly three concise questions, one for each required theme in the given order. Write every question as the user's own first-person request and include “我”, such as “请帮我看看……”. The question must ask for useful help with the user's situation, not for a lesson about astrology.
|
||||
{"suggestions":[{"theme":"服务器给出的主题 id","text":"问题"}]}
|
||||
The server lists the required themes. Return one question per listed theme, in exactly that order, with no extra, missing, renamed, or reordered themes. Do not add a greeting or any other field.
|
||||
Write every question as the user's own first-person request and include “我”, such as “请帮我看看……”. Each question must ask for useful help with the user's situation, not for a lesson about astrology, and must stay under 40 Chinese characters.
|
||||
Keep each question specific to its own theme so the set does not read as rewordings of one another. Never mention birth data, profile readiness, setup completion, or system processing.
|
||||
Never generate detached or encyclopedic wording such as “印度占星一般如何……”, “通常会看哪些因素”, “包含哪些证据层”, or “如何划分主题”.
|
||||
The questions must use everyday Simplified Chinese and be answerable through the skill. Avoid jargon, fear, deterministic promises, medical/legal/investment claims, and unsupported precision.`;
|
||||
|
||||
|
||||
@@ -5,14 +5,13 @@ import {
|
||||
OnboardingRequestError,
|
||||
requestOnboardingWithRecovery,
|
||||
} from "../src/lib/onboarding-client.ts";
|
||||
import { onboardingSuggestionThemes } from "../src/lib/onboarding-payload.ts";
|
||||
|
||||
const personalizedOnboarding = {
|
||||
greeting: "林遥,欢迎回来。想先从哪个方向开始?",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "我现在的事业选择应该优先考虑什么?" },
|
||||
{ theme: "marriage", text: "我该怎样理解近期的关系模式?" },
|
||||
{ theme: "timing", text: "未来一年哪些阶段适合主动推进?" },
|
||||
],
|
||||
suggestions: onboardingSuggestionThemes.map((theme, index) => ({
|
||||
theme,
|
||||
text: `请帮我看看${theme}方向第${index + 1}个重点。`,
|
||||
})),
|
||||
source: "cache",
|
||||
} as const;
|
||||
|
||||
@@ -36,8 +35,10 @@ test("default request deadline leaves enough room for server-side Agent generati
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
// One question per consultation domain takes far longer than the three-question era,
|
||||
// so a single attempt must outlast the route's own generation budget.
|
||||
assert.ok(observedRequestDeadline !== null);
|
||||
assert.ok(observedRequestDeadline >= 25_000, `request deadline was only ${observedRequestDeadline}ms`);
|
||||
assert.ok(observedRequestDeadline >= 45_000, `request deadline was only ${observedRequestDeadline}ms`);
|
||||
});
|
||||
|
||||
test("returns personalized cache content after a timeout and pending response", async () => {
|
||||
@@ -69,10 +70,7 @@ test("returns personalized cache content after a timeout and pending response",
|
||||
);
|
||||
|
||||
// Then: slow fallback is shown once and terminal personalized content wins.
|
||||
assert.deepEqual(content, {
|
||||
greeting: personalizedOnboarding.greeting,
|
||||
suggestions: personalizedOnboarding.suggestions,
|
||||
});
|
||||
assert.deepEqual(content, { suggestions: personalizedOnboarding.suggestions });
|
||||
assert.equal(slowCount, 1);
|
||||
assert.equal(requestCount, 3);
|
||||
} finally {
|
||||
@@ -185,10 +183,18 @@ test("throws a typed HTTP error after non-authentication failures are exhausted"
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a malformed terminal response with a typed error", async () => {
|
||||
// Given: a successful HTTP response violates the onboarding schema.
|
||||
test("rejects a partially covered terminal response with a typed error", async () => {
|
||||
// Given: a cached payload from the era when only three domains were generated.
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = () => Promise.resolve(Response.json({ source: "cache", greeting: "short" }));
|
||||
globalThis.fetch = () => Promise.resolve(Response.json({
|
||||
source: "cache",
|
||||
greeting: "林遥,欢迎回来。",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "请帮我看看事业方向。" },
|
||||
{ theme: "marriage", text: "请帮我看看关系模式。" },
|
||||
{ theme: "timing", text: "请帮我看看时机安排。" },
|
||||
],
|
||||
}));
|
||||
|
||||
try {
|
||||
// When: the response crosses the client boundary.
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { z } from "zod";
|
||||
import { createOnboardingCacheIdentity } from "../src/lib/onboarding-cache-policy.ts";
|
||||
import { createOnboardingPost } from "../src/lib/onboarding-post.ts";
|
||||
import {
|
||||
fallbackOnboardingPayload,
|
||||
onboardingSuggestionThemes,
|
||||
parseOnboardingPayload,
|
||||
} from "../src/lib/onboarding-payload.ts";
|
||||
import {
|
||||
completeProfileRow,
|
||||
StatefulOnboardingProfileRepository,
|
||||
} from "./onboarding-route-fake.ts";
|
||||
|
||||
const payloadA = {
|
||||
greeting: "林遥,欢迎开始今天的咨询。",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "请帮我梳理目前的事业方向。" },
|
||||
{ theme: "marriage", text: "请帮我看看关系中容易重复什么模式。" },
|
||||
{ theme: "timing", text: "请帮我看看什么时候适合采取行动。" },
|
||||
],
|
||||
} as const;
|
||||
// Both payloads cover every consultation domain; the marker is what tells one
|
||||
// profile's generated content from the other's when checking for stale leaks.
|
||||
function payloadCovering(marker: string) {
|
||||
return {
|
||||
suggestions: onboardingSuggestionThemes.map((theme) => ({
|
||||
theme,
|
||||
text: `请帮我看看${theme}方向的${marker}重点。`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const payloadB = {
|
||||
greeting: "周宁,欢迎开始今天的咨询。",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "请帮我看看下一步的事业重点。" },
|
||||
{ theme: "marriage", text: "请帮我看看目前的关系重点。" },
|
||||
{ theme: "timing", text: "请帮我看看哪些阶段适合主动推进。" },
|
||||
],
|
||||
} as const;
|
||||
const payloadA = payloadCovering("甲组");
|
||||
const payloadB = payloadCovering("乙组");
|
||||
|
||||
function generatedText(payload: typeof payloadA | typeof payloadB): string {
|
||||
function generatedText(payload: ReturnType<typeof payloadCovering>): string {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
@@ -39,10 +41,9 @@ function deferred<Value>() {
|
||||
}
|
||||
|
||||
const responseBodySchema = z.object({
|
||||
greeting: z.string(),
|
||||
suggestions: z.array(z.object({ theme: z.string(), text: z.string() })),
|
||||
source: z.enum(["agent", "cache", "fallback", "pending"]),
|
||||
});
|
||||
}).strict();
|
||||
|
||||
async function responseBody(response: Response): Promise<z.infer<typeof responseBodySchema>> {
|
||||
return responseBodySchema.parse(await response.json());
|
||||
@@ -64,6 +65,36 @@ function createPost(
|
||||
});
|
||||
}
|
||||
|
||||
test("the safe fallback satisfies the payload schema it is meant to replace", () => {
|
||||
// The fallback is built from the domain registry, so a registry prompt that stops
|
||||
// being first-person would otherwise ship a payload the schema itself rejects.
|
||||
assert.notEqual(parseOnboardingPayload(fallbackOnboardingPayload), null);
|
||||
assert.deepEqual(
|
||||
fallbackOnboardingPayload.suggestions.map((item) => item.theme),
|
||||
[...onboardingSuggestionThemes],
|
||||
);
|
||||
});
|
||||
|
||||
test("no onboarding surface generates or transports a greeting", () => {
|
||||
const instructions = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
|
||||
const onboardingPrompt = instructions.slice(
|
||||
instructions.indexOf("const onboardingInstructions"),
|
||||
instructions.indexOf("const onboardingAgents"));
|
||||
const route = readFileSync(new URL("../src/app/api/onboarding/route.ts", import.meta.url), "utf8");
|
||||
const payload = readFileSync(new URL("../src/lib/onboarding-payload.ts", import.meta.url), "utf8");
|
||||
const client = readFileSync(new URL("../src/lib/onboarding-client.ts", import.meta.url), "utf8");
|
||||
|
||||
// Given: the hero renders a locally composed salutation, so a generated greeting has
|
||||
// nowhere to go and must not cost tokens.
|
||||
assert.doesNotMatch(onboardingPrompt, /"greeting"/);
|
||||
assert.match(onboardingPrompt, /Do not add a greeting/);
|
||||
assert.doesNotMatch(payload, /greeting/);
|
||||
assert.doesNotMatch(client, /greeting: (?:parsed|z\.string)/);
|
||||
|
||||
// And: the route names the required themes so the Agent covers the whole grid.
|
||||
assert.match(route, /onboardingSuggestionThemes/);
|
||||
});
|
||||
|
||||
test("slow Agent generation is aborted and a terminal fallback is cached before the client deadline", async () => {
|
||||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
|
||||
let generationAborted = false;
|
||||
@@ -94,22 +125,17 @@ test("slow Agent generation is aborted and a terminal fallback is cached before
|
||||
assert.equal(generationAborted, true);
|
||||
const body = await responseBody(outcome);
|
||||
assert.equal(body.source, "fallback");
|
||||
assert.deepEqual(repository.snapshot().onboarding_payload, {
|
||||
greeting: body.greeting,
|
||||
suggestions: body.suggestions,
|
||||
});
|
||||
assert.deepEqual(repository.snapshot().onboarding_payload, { suggestions: body.suggestions });
|
||||
});
|
||||
|
||||
|
||||
test("detached Agent questions are rejected in favor of user-centered fallbacks", async () => {
|
||||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
|
||||
const post = createPost(repository, async () => JSON.stringify({
|
||||
greeting: "欢迎开始今天的咨询,可以先选择一个主题。",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "印度占星一般会从哪些因素理解事业方向?" },
|
||||
{ theme: "marriage", text: "印度占星一般如何分析关系模式?" },
|
||||
{ theme: "timing", text: "印度占星中的时间推运通常会看哪些因素?" },
|
||||
],
|
||||
suggestions: onboardingSuggestionThemes.map((theme) => ({
|
||||
theme,
|
||||
text: `印度占星一般会从哪些因素理解${theme}?`,
|
||||
})),
|
||||
}));
|
||||
|
||||
const body = await responseBody(await post());
|
||||
@@ -119,6 +145,41 @@ test("detached Agent questions are rejected in favor of user-centered fallbacks"
|
||||
assert.ok(body.suggestions.every((item) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(item.text)));
|
||||
});
|
||||
|
||||
test("the home receives one question per consultation domain, never a partial set", async () => {
|
||||
// Given: the Agent answers with only the three themes the old contract required.
|
||||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
|
||||
const post = createPost(repository, async () => JSON.stringify({
|
||||
suggestions: [
|
||||
{ theme: "career", text: "请帮我看看事业方向。" },
|
||||
{ theme: "marriage", text: "请帮我看看关系模式。" },
|
||||
{ theme: "timing", text: "请帮我看看时机安排。" },
|
||||
],
|
||||
}));
|
||||
|
||||
// When: the partial answer crosses the payload boundary.
|
||||
const body = await responseBody(await post());
|
||||
|
||||
// Then: it is rejected wholesale, and the fallback still covers every domain in order
|
||||
// so no home card is left without a question.
|
||||
assert.equal(body.source, "fallback");
|
||||
assert.deepEqual(body.suggestions.map((item) => item.theme), [...onboardingSuggestionThemes]);
|
||||
});
|
||||
|
||||
test("a reordered set is rejected so questions cannot land under the wrong theme", async () => {
|
||||
// Given: every domain is present, but two themes are swapped against their questions.
|
||||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
|
||||
const swapped = [...onboardingSuggestionThemes];
|
||||
[swapped[0], swapped[1]] = [swapped[1]!, swapped[0]!];
|
||||
const post = createPost(repository, async () => JSON.stringify({
|
||||
suggestions: swapped.map((theme) => ({ theme, text: `请帮我看看${theme}方向的重点。` })),
|
||||
}));
|
||||
|
||||
const body = await responseBody(await post());
|
||||
|
||||
assert.equal(body.source, "fallback");
|
||||
assert.deepEqual(body.suggestions.map((item) => item.theme), [...onboardingSuggestionThemes]);
|
||||
});
|
||||
|
||||
test("PostgreSQL Date birth_date is normalized before onboarding validation", async () => {
|
||||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow({
|
||||
birth_date: new Date(1990, 5, 15),
|
||||
@@ -217,7 +278,7 @@ test("stale A generation returns pending after profile B replaces its claim", as
|
||||
// Then: B remains cached and A is provisional, never a stale terminal payload.
|
||||
assert.deepEqual(bodyB, { ...payloadB, source: "agent" });
|
||||
assert.equal(bodyA.source, "pending");
|
||||
assert.doesNotMatch(JSON.stringify(bodyA), /林遥/);
|
||||
assert.doesNotMatch(JSON.stringify(bodyA), /甲组/);
|
||||
assert.deepEqual(repository.snapshot().onboarding_payload, payloadB);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user