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:
@@ -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.`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user