fix: make starter questions user-centered
Independent Staging Quality Gate / validate (push) Failing after 12m39s
Independent Staging Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-15 21:52:02 +08:00
parent 47a22319fd
commit 12b6398c7b
8 changed files with 94 additions and 46 deletions
+15
View File
@@ -3363,3 +3363,18 @@
- 防复发:IANA 时区是地点真相,`timezone_offset` 是依赖出生日期与参考时刻的派生值;所有需要 offset 的服务必须先走共享解析边界,再区分真正资料不完整与下游服务异常。
- 相关记录:BUG-127、BUG-198
- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging health 验收结果为准)
## BUG-200 | 无准确出生分钟时首页主题退化为第三方占星百科问题
- 状态:resolved(本地候选,待 staging 发布与视觉验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:首页“每日运势”、十个主题问题、Onboarding Agent 生成的事业/关系/时运建议,以及已有 onboarding 缓存。
- 用户现象:用户进入首页后看到“印度占星一般如何……”“通常会看哪些因素”“包含哪些证据层”等教学式问题;卡片在介绍占星方法,而不是帮助用户直接询问自己的每日运势、事业、关系或未来一年重点。
- 触发条件:Profile 没有可用于个人星盘的准确出生分钟,首页选择 `generalGuidedJyotishTopics`;或 Onboarding Agent 生成未使用第一人称的客观式问题。
- 根因:无分钟降级主题被写成“不依赖个人出生分钟”的占星知识入口,错误地把真实性边界实现成百科模式;同时 Onboarding Agent 只被要求介绍产品能力,服务端也未校验问题是否以用户本人为中心,因此模型输出和缓存都可能继续保存第三方视角文案。
- 修复:把每日运势和十个无分钟主题统一改成“请帮我……”的任务式请求;首页明确提示出生时间不足的部分会说明限制,但仍允许用户从自己的问题开始。Onboarding Prompt 强制每个建议包含“我”并禁止百科式句型,服务器解析层再次拒绝客观教学文案并回退到安全的第一人称问题;缓存版本升级到 `ayanam-onboarding-v4`,使旧问题重新生成。
- 验证:新增回归测试锁定无分钟主题全部为用户视角,并证明客观式 Agent 输出会被拒绝且使用第一人称 fallback;聚焦测试 25/25 通过,目标 ESLint 与 TypeScript `--noEmit` 通过。真实 staging 视觉验收待发布后完成。
- 防复发:缺少准确出生分钟只限制分钟敏感的个性化结论,不得把用户入口改写成占星教学。首页卡片和 Agent 推荐问题必须直接表达用户要解决的事;Prompt 约束之外必须保留服务端输出校验和版本化缓存失效。
- 相关记录:BUG-194、BUG-197、BUG-198
- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging health 验收结果为准)
+10 -8
View File
@@ -2332,7 +2332,9 @@ export default function Home() {
function draftDailyStarlanguageQuestion() {
chooseSuggestedQuestion(
personalChartAvailable ? "深入看今日" : "印度占星通常如何观察每日趋势?",
personalChartAvailable
? "深入看今日"
: "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。",
"timing",
personalChartAvailable ? "daily_starlanguage" : null,
);
@@ -3344,7 +3346,7 @@ export default function Home() {
<h1 id="starter-heading"></h1>
<p className="starter-hero-note">{personalChartAvailable
? "从此刻最在意的事开始,我会结合你的星盘证据,帮你把问题拆得更清楚。"
: "你可以先了解一般占星知识;完成生时校正后,再讨论个人星盘结论。"}</p>
: "从你现在最关心的事开始;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}</p>
</div>
</section>
@@ -3353,17 +3355,17 @@ export default function Home() {
<button
className="product-entrypoint-hitarea"
type="button"
aria-label={personalChartAvailable ? "深入看今日" : "了解每日趋势的分析方法"}
aria-label={personalChartAvailable ? "深入看今日" : "查看今日运势"}
disabled={productEntrypointsDisabled}
onClick={draftDailyStarlanguageQuestion}
/>
<div className="product-entrypoint-copy">
<h2 id="daily-starlanguage-title">{personalChartAvailable ? "今日星语" : "如何看每日势"}</h2>
<p>{personalChartAvailable ? dailyStarlanguage?.trend : "了解印度占星通常会用哪些因素观察一天的主题。"}</p>
<h2 id="daily-starlanguage-title">{personalChartAvailable ? "今日星语" : "每日势"}</h2>
<p>{personalChartAvailable ? dailyStarlanguage?.trend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
</div>
<div className="product-entrypoint-footer">
<small>{personalChartAvailable ? dailyStarlanguage?.action : "不依赖个人出生分钟。"}</small>
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? "深入看今日" : "了解方法"} <ArrowUpRight className="starter-arrow" /></span>
<small>{personalChartAvailable ? dailyStarlanguage?.action : "不支持的个人判断会明确说明,不会补造出生时间。"}</small>
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? "深入看今日" : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
@@ -3392,7 +3394,7 @@ export default function Home() {
<section className="starter-themes" aria-labelledby="starter-themes-heading">
<div className="starter-section-heading">
<h2 id="starter-themes-heading"></h2>
<p></p>
<p></p>
</div>
<div className="starter-theme-accordion">
{starterSuggestions.map((item) => {
+10 -10
View File
@@ -24,16 +24,16 @@ export const defaultGuidedJyotishTopics: GuidedJyotishTopic[] = consultationDoma
}));
const generalPromptByTheme: Partial<Record<ConsultationTheme, string>> = {
career: "印度占星一般会从哪些因素理解事业方向?",
marriage: "印度占星一般如何分析关系模式",
wealth: "印度占星一般如何分析财富结构与风险?",
health: "印度占星如何在非医疗诊断边界内理解身心压力?",
education: "印度占星一般如何理解学习方式与进阶节奏?",
migration: "印度占星一般如何分析迁居、置业海外发展",
family: "印度占星一般如何理解家庭关系与责任模式?",
annual: "印度占星中的年运分析通常包含哪些证据层?",
timing: "印度占星中的时间推运通常会看哪些因素?",
general: "印度占星综合咨询会如何划分不同主题与证据边界?",
career: "请帮我梳理目前的事业方向和下一步重点。",
marriage: "请帮我看看我在关系中容易重复什么模式",
wealth: "请帮我分析目前的财富重点、风险和更稳妥的选择。",
health: "请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。",
education: "请帮我看看我更适合怎样学习,以及如何安排下一步。",
migration: "请帮我分析现阶段是否适合迁居、置业或考虑海外发展",
family: "请帮我看看家庭关系中最需要处理的问题和责任边界。",
annual: "请帮我看看未来一年的主要趋势、机会和需要留意的阶段。",
timing: "请帮我看看目前适合推进什么,哪些事情需要再等等。",
general: "请结合我的情况,帮我找出现在最值得优先处理的三个问题。",
};
export const generalGuidedJyotishTopics: GuidedJyotishTopic[] = defaultGuidedJyotishTopics.map((topic) => ({
+1 -1
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
const ONBOARDING_VERSION = "ayanam-onboarding-v3";
const ONBOARDING_VERSION = "ayanam-onboarding-v4";
export const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000;
type OnboardingProfileInput = {
+15 -6
View File
@@ -1,11 +1,20 @@
import { z } from "zod";
const detachedStarterQuestionPattern = /印度占星|一般如何|通常(?:会)?(?:看|观察|分析|理解|包含)|哪些(?:因素|证据层)|如何划分/;
const userCenteredStarterQuestionSchema = z.string()
.trim()
.min(4)
.max(80)
.refine((text) => text.includes("我"), "starter_question_must_be_first_person")
.refine((text) => !detachedStarterQuestionPattern.test(text), "starter_question_must_not_be_encyclopedic");
const onboardingSchema = z.object({
greeting: z.string().trim().min(8).max(180),
suggestions: z.tuple([
z.object({ theme: z.literal("career"), text: z.string().trim().min(4).max(80) }),
z.object({ theme: z.literal("marriage"), text: z.string().trim().min(4).max(80) }),
z.object({ theme: z.literal("timing"), text: z.string().trim().min(4).max(80) }),
z.object({ theme: z.literal("career"), text: userCenteredStarterQuestionSchema }),
z.object({ theme: z.literal("marriage"), text: userCenteredStarterQuestionSchema }),
z.object({ theme: z.literal("timing"), text: userCenteredStarterQuestionSchema }),
]),
});
@@ -14,9 +23,9 @@ export type OnboardingPayload = z.infer<typeof onboardingSchema>;
export const fallbackOnboardingPayload: OnboardingPayload = {
greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。",
suggestions: [
{ theme: "career", text: "我的事业优势更适合怎样发挥" },
{ theme: "marriage", text: "我在关系里容易重复什么模式" },
{ theme: "timing", text: "未来一年哪些阶段值得提前准备" },
{ theme: "career", text: "请帮我看看事业优势更适合怎样发挥" },
{ theme: "marriage", text: "请帮我看看关系里容易重复什么模式" },
{ theme: "timing", text: "请帮我看看未来一年哪些阶段值得提前准备" },
],
};
+3 -1
View File
@@ -121,7 +121,9 @@ 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. They must help a first-time user understand the product's abilities, use everyday Simplified Chinese, and be answerable through the skill. Avoid jargon, fear, deterministic promises, medical/legal/investment claims, and unsupported precision.`;
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.
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.`;
const onboardingAgents = new Map<string, Agent>();
+25 -6
View File
@@ -11,18 +11,18 @@ import {
const payloadA = {
greeting: "林遥,欢迎开始今天的咨询。",
suggestions: [
{ theme: "career", text: "林遥的事业方向是什么?" },
{ theme: "marriage", text: "林遥的关系模式是什么?" },
{ theme: "timing", text: "林遥何时适合采取行动" },
{ theme: "career", text: "请帮我梳理目前的事业方向" },
{ theme: "marriage", text: "请帮我看看关系中容易重复什么模式。" },
{ theme: "timing", text: "请帮我看看什么时候适合采取行动" },
],
} as const;
const payloadB = {
greeting: "周宁,欢迎开始今天的咨询。",
suggestions: [
{ theme: "career", text: "周宁的事业方向是什么?" },
{ theme: "marriage", text: "周宁的关系模式是什么?" },
{ theme: "timing", text: "周宁何时适合采取行动?" },
{ theme: "career", text: "请帮我看看下一步的事业重点。" },
{ theme: "marriage", text: "请帮我看看目前的关系重点。" },
{ theme: "timing", text: "请帮我看看哪些阶段适合主动推进。" },
],
} as const;
@@ -100,6 +100,25 @@ test("slow Agent generation is aborted and a terminal fallback is cached before
});
});
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: "印度占星中的时间推运通常会看哪些因素?" },
],
}));
const body = await responseBody(await post());
assert.equal(body.source, "fallback");
assert.ok(body.suggestions.every((item) => /我/.test(item.text)));
assert.ok(body.suggestions.every((item) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(item.text)));
});
test("PostgreSQL Date birth_date is normalized before onboarding validation", async () => {
const repository = new StatefulOnboardingProfileRepository(completeProfileRow({
birth_date: new Date(1990, 5, 15),
+15 -14
View File
@@ -71,19 +71,19 @@ test("default starter questions derive every canonical domain with evidence and
assert.match(domainById.get("timing")?.requiredLayers.join(" ") ?? "", /negative holdout gate/);
});
test("profiles without a usable birth minute receive all canonical general-knowledge prompts", () => {
test("profiles without a usable birth minute receive user-centered starter prompts", () => {
assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]);
assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.prompt), [
"印度占星一般会从哪些因素理解事业方向?",
"印度占星一般如何分析关系模式",
"印度占星一般如何分析财富结构与风险?",
"印度占星如何在非医疗诊断边界内理解身心压力?",
"印度占星一般如何理解学习方式与进阶节奏?",
"印度占星一般如何分析迁居、置业海外发展",
"印度占星一般如何理解家庭关系与责任模式?",
"印度占星中的年运分析通常包含哪些证据层?",
"印度占星中的时间推运通常会看哪些因素?",
"印度占星综合咨询会如何划分不同主题与证据边界?",
"请帮我梳理目前的事业方向和下一步重点。",
"请帮我看看我在关系中容易重复什么模式",
"请帮我分析目前的财富重点、风险和更稳妥的选择。",
"请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。",
"请帮我看看我更适合怎样学习,以及如何安排下一步。",
"请帮我分析现阶段是否适合迁居、置业或考虑海外发展",
"请帮我看看家庭关系中最需要处理的问题和责任边界。",
"请帮我看看未来一年的主要趋势、机会和需要留意的阶段。",
"请帮我看看目前适合推进什么,哪些事情需要再等等。",
"请结合我的情况,帮我找出现在最值得优先处理的三个问题。",
]);
for (const topic of generalGuidedJyotishTopics) {
const personalTopic = defaultGuidedJyotishTopics.find((candidate) => candidate.id === topic.id);
@@ -95,10 +95,11 @@ test("profiles without a usable birth minute receive all canonical general-knowl
assert.equal(topic.claimBoundary, personalTopic.claimBoundary);
assert.notEqual(topic.prompt, personalTopic.prompt);
}
assert.ok(generalGuidedJyotishTopics.every((topic) => !/我的|我近期|未来一年,事业和收入/.test(topic.prompt)));
assert.ok(generalGuidedJyotishTopics.every((topic) => /我/.test(topic.prompt)));
assert.ok(generalGuidedJyotishTopics.every((topic) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(topic.prompt)));
assert.match(pageSource, /const starterThemes = personalChartAvailable \? themes : generalGuidedJyotishTopics/);
assert.doesNotMatch(pageSource, /回答一般占星知识/);
assert.match(pageSource, /完成生时校正后,再讨论个人星盘结论/);
assert.match(pageSource, /出生时间不足以支持的部分,我会明确说明,不会补造具体分钟/);
assert.match(pageSource, /请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么/);
assert.match(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/);
});