fix(synastry): drop a section title repeated at the start of the body

The gate now rejects that opening and asks for a rewrite, and the saved text no longer keeps the duplicate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-21 18:09:19 +08:00
co-authored by Cursor
parent dc80a43aae
commit e1afd0ea3d
8 changed files with 151 additions and 8 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-10-21 — 合盘正文不再重复节标题
- 某一节的正文如果以这一节的标题开头,质检会要求重写这一节。保存前也会把开头重复的标题去掉。中文和英文都是这样。Skill 版本不变(BUG-1406)。
## 2026-10-21 — 合盘报告有自己的地址
- 合盘报告改走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍是「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,英文链接保留 `?lang=en`。Skill 版本不变(BUG-1405)。
+16
View File
@@ -37047,3 +37047,19 @@
- 相关记录:issue #85。BUG-1404。
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
## BUG-1406 | 合盘正文开头重复节标题
- 状态:resolved(待 staging 真机复测)
- 首次发现:2026-10-21(产品负责人真机)
- 最近更新:2026-10-21
- 影响面:合盘报告各节正文,中文和英文
- 用户现象:节标题下面,正文又用同一句话开头,标题等于出现了两次。
- 触发条件:模型把这一节的标题写进正文开头。保存和质检都没有拦住。
- 根因:每一节单独生成,提示里有节名,但保存前不剥离开头的标题,质检也不把这件事当成失败。
- 修复:正文以该节标题开头(可重复一次,后面是空白)时,质检记 `repeated_heading` 并只重写这一节。重写提示写明不要以节标题开头。通过之后,保存前再剥掉开头重复的标题。中文和英文同一条规则。标题后面紧接着别的字、不是空白也不是再来一遍标题时,不剥,避免误伤。
- 验证:`frontend/tests/synastry-full-report.test.ts`:正文以「沟通与理解」开头时先重写,保存结果不再以这个标题开头;英文标题同样剥掉;「沟通与理解力」不剥。`tests/test_synastry_full_reader_gate.py`:同一中文样例和英文「Communication and Understanding」被质检判为失败。
- 防复发:上述两条。不要只在页面上藏起来,生成和质检都要认。
- 相关记录:issue #85。
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
@@ -59,3 +59,8 @@
- 合盘揭幕后的打印样式加上 `media="print"`,平时侧栏还在。
- 失败态带上 `reportType`。个人报告失败断言的原值是没有这个字段,新值带上 `personal_full`,因为合盘地址要能认出个人报告。
- 真机(执行方未做,不得写成已通过):点「开始合盘」后,侧栏当前项仍是「婚恋合盘」。
## T9 重复标题(BUG-1406)
- 正文以该节标题开头时,质检返回 `repeated_heading`,只重写这一节。通过后保存前再剥掉开头的标题。中文和英文同一条规则。「沟通与理解力」这种紧挨着的字不剥。
- 真机(执行方未做,不得写成已通过):打开一份新写的合盘,各节标题下面的第一句不要再重复标题。
+50 -1
View File
@@ -113,6 +113,36 @@ function deliveryFrom(parts: ReadonlyMap<SynastrySectionId, Record<string, unkno
return { sections: SYNASTRY_SECTION_IDS.map((id) => parts.get(id) ?? { id }) };
}
/** A body that opens with its own section title, including a repeated title. */
export function startsWithSectionTitle(text: string, title: string): boolean {
const heading = title.trim();
const body = text.replace(/^\s+/, "");
if (!heading || !body.startsWith(heading)) return false;
const rest = body.slice(heading.length);
return rest.length === 0 || /^[\s\u3000]/.test(rest) || rest.startsWith(heading);
}
export function stripLeadingSectionTitle(text: string, title: string): string {
const heading = title.trim();
if (!heading) return text;
let rest = text.replace(/^\s+/, "");
for (let guard = 0; guard < 4 && startsWithSectionTitle(rest, heading); guard += 1) {
rest = rest.slice(heading.length).replace(/^\s+/, "");
}
return rest;
}
function headingFailures(parts: ReadonlyMap<SynastrySectionId, Record<string, unknown>>): { section: string; code: string }[] {
const failures: { section: string; code: string }[] = [];
for (const id of SYNASTRY_PROSE_IDS) {
const row = parts.get(id);
const title = typeof row?.title === "string" ? row.title : "";
const text = typeof row?.text === "string" ? row.text : "";
if (startsWithSectionTitle(text, title)) failures.push({ section: id, code: "repeated_heading" });
}
return failures;
}
export async function writeSynastryLanguage(input: Readonly<{
language: "zh" | "en";
names: Readonly<{ a: string; b: string }>;
@@ -195,16 +225,35 @@ export async function writeSynastryLanguage(input: Readonly<{
await input.onProgress?.("checking_report");
let parts = materialize();
let gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
const firstHeadings = headingFailures(parts);
if (firstHeadings.length > 0) {
const seen = new Set(gated.failures.map((failure) => `${failure.section}:${failure.code}`));
gated = {
ok: false,
failures: [...gated.failures, ...firstHeadings.filter((failure) => !seen.has(`${failure.section}:${failure.code}`))],
};
}
if (!gated.ok) {
const failed = new Set(gated.failures.map((failure) => failure.section).filter((section) => SYNASTRY_SECTION_IDS.includes(section as SynastrySectionId)));
const targets = failed.size > 0 ? [...failed] as SynastrySectionId[] : [...SYNASTRY_SECTION_IDS];
await pool(targets, 3, (sectionId) => writeOne(sectionId, gated.failures.filter((failure) => failure.section === sectionId).map((failure) => failure.code)));
parts = materialize();
gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
const secondHeadings = headingFailures(parts);
if (secondHeadings.length > 0) {
const seen = new Set(gated.failures.map((failure) => `${failure.section}:${failure.code}`));
gated = {
ok: false,
failures: [...gated.failures, ...secondHeadings.filter((failure) => !seen.has(`${failure.section}:${failure.code}`))],
};
}
}
if (!gated.ok) throw new Error("synastry_gate_rejected");
const sections = SYNASTRY_PROSE_IDS.map((id) => parts.get(id) as { id: SynastrySectionId; title: string; text: string; reasons: SynastryReason[] });
const sections = SYNASTRY_PROSE_IDS.map((id) => {
const section = parts.get(id) as { id: SynastrySectionId; title: string; text: string; reasons: SynastryReason[] };
return { ...section, text: stripLeadingSectionTitle(section.text, section.title) };
});
const timeline = (parts.get("timeline")?.segments ?? []) as SynastrySnapshot["timeline"];
const practices = (parts.get("practices")?.items ?? []) as SynastrySnapshot["practices"];
const technical = buildTechnicalBasis({
+12 -6
View File
@@ -197,7 +197,7 @@ const ZH_SYSTEM = `你在写一份婚恋合盘的关系画像。把盘面翻译
不要报分数。不要用「克 / 煞 / 凶 / 吉」贴标签。不要说「注定」「必然」「命中」。不要把一方说成问题源。不要替当事人做决定。
示例:${ZH_EXAMPLE}
每个判断给两到三条分析。盘面行必须逐字来自证据里的某个完整字段,不要改写。证据互相冲突时把 label 降为 parameter_sensitive 或 blocked,并写成条件,不要写成应期。不要点名补充方法。
结论、所以、时间线的阶段名和描述、共同练习里不得出现星曜、宫位、星座、分盘、大运、分数、Koota,也不得出现「克 / 吉 / 注定 / 不合 / 适合结婚」。不要写出生日期、时间、地点。不要写某年结婚、领证或分开。第一句不要写成「这段关系的核心不是……」。
结论、所以、时间线的阶段名和描述、共同练习里不得出现星曜、宫位、星座、分盘、大运、分数、Koota,也不得出现「克 / 吉 / 注定 / 不合 / 适合结婚」。不要写出生日期、时间、地点。不要写某年结婚、领证或分开。第一句不要写成「这段关系的核心不是……」,正文也不要以这一节的标题开头。
质检硬规则:label 只能是 ${EVIDENCE_LABELS.join("、")}。中文每一节结论至少 ${SYNASTRY_LENGTH.zhSection} 字,七节合计至少 ${SYNASTRY_LENGTH.zhTotal} 字。evidence 必须等于该节证据 JSON 里的某个完整字符串字段。`;
const EN_SYSTEM = `You write a relationship portrait of two people. Translate the evidence into how the relationship feels. Do not judge the people.
@@ -207,7 +207,7 @@ Five rules: speak about the pair, not about who someone is. Speak in tendencies,
Do not quote a score. Do not call one person the problem. Do not decide for them.
Example: ${EN_EXAMPLE}
Give two or three supports for each judgment. The chart line must copy one complete evidence field verbatim. When supports conflict, lower the label to parameter_sensitive or blocked and write a condition, not a dated event. Do not name supplementary methods.
The conclusion, the "so" line, the timeline phase and description, and the shared practices must not name planets, houses, signs, nakshatras, divisional charts, dashas, scores, koota, kuja, nadi, lagna, UL, or DK. Do not include birth dates, times, or places. Do not predict a wedding or a breakup in a named year. Do not open with "The core of this relationship is not".
The conclusion, the "so" line, the timeline phase and description, and the shared practices must not name planets, houses, signs, nakshatras, divisional charts, dashas, scores, koota, kuja, nadi, lagna, UL, or DK. Do not include birth dates, times, or places. Do not predict a wedding or a breakup in a named year. Do not open with "The core of this relationship is not". Do not start the body with the section title.
Gate: label must be one of ${EVIDENCE_LABELS.join(", ")}. Each English section is at least ${SYNASTRY_LENGTH.enSection} words, and the seven sections together are at least ${SYNASTRY_LENGTH.enTotal} words. evidence must equal one complete string field from that section's evidence JSON.`;
export function systemPrompt(language: "zh" | "en"): string {
@@ -223,14 +223,20 @@ export function userPrompt(input: Readonly<{
}>): string {
const title = SECTION_TITLES[input.sectionId][input.language];
const evidence = JSON.stringify(input.evidence);
const notes = (input.failures ?? []).map((code) => {
if (code !== "repeated_heading") return code;
return input.language === "en"
? "repeated_heading (do not start the body with the section title)"
: "repeated_heading(正文不要以节标题开头)";
});
if (input.language === "en") {
const repair = input.failures && input.failures.length > 0
? `\nThe previous draft failed this section only: ${input.failures.join("; ")}`
const repair = notes.length > 0
? `\nThe previous draft failed this section only: ${notes.join("; ")}`
: "";
return [`Names: ${input.names.a} and ${input.names.b}`, `Section: ${title}`, `Evidence: ${evidence}`, repair].filter(Boolean).join("\n");
}
const repair = input.failures && input.failures.length > 0
? `\n上次没有通过,只改这一节:${input.failures.join(";")}`
const repair = notes.length > 0
? `\n上次没有通过,只改这一节:${notes.join(";")}`
: "";
return [
`两人称呼:${input.names.a}、${input.names.b}`,
+42 -1
View File
@@ -9,7 +9,7 @@ import { createElement } from "react";
import { SynastryReportView } from "../src/components/synastry/synastry-report-view.tsx";
import { SynastryPicker } from "../src/components/synastry/synastry-picker.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts";
import { stripLeadingSectionTitle, writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts";
import { EVIDENCE_LABELS, PRIVATE_KEY_NAMES, sectionEvidence, SYNASTRY_LENGTH, systemPrompt, userPrompt, SYNASTRY_SECTION_IDS, type SynastrySectionId } from "../src/lib/synastry-full-sections.ts";
import { buildTechnicalBasis } from "../src/lib/synastry-full-technical.ts";
import { buildTimelineSegments } from "../src/lib/synastry-full-timeline.ts";
@@ -98,6 +98,47 @@ test("a failed gate rewrites only the failed section once", async () => {
assert.match(result.snapshotParts.technical[5]?.lines.at(-1) ?? "", /外部资料与真实案例核对:未进行/);
});
test("a section that opens with its title is saved without the duplicate", async () => {
const calls: SynastrySectionId[] = [];
let checks = 0;
const clean = prose();
const result = await writeSynastryLanguage({
language: "zh",
names: { a: "甲", b: "乙" },
dossier: {},
segments: [
{ id: "s1", start: "2026-01", end: "2027-01", basis: "甲:木星大运 2026-01 至 2027-01。乙:金星大运 2026-01 至 2027-01。" },
{ id: "s2", start: "2027-01", end: "2028-01", basis: "甲:木星大运 2027-01 至 2028-01。乙:金星大运 2027-01 至 2028-01。" },
{ id: "s3", start: "2028-01", end: "2029-01", basis: "甲:木星大运 2028-01 至 2029-01。乙:金星大运 2028-01 至 2029-01。" },
],
signal: new AbortController().signal,
timeoutMs: 1000,
write: async ({ sectionId }) => {
calls.push(sectionId);
if (sectionId === "timeline") {
return { body: { segments: [{ id: "s1", phase: "靠近", text: "这段时间先把日常节奏说清楚。" }, { id: "s2", phase: "磨合", text: "这段时间把分歧说完再放下。" }, { id: "s3", phase: "协作", text: "这段时间一起把安排写下来。" }] }, inputTokens: 1, outputTokens: 1 };
}
if (sectionId === "practices") {
return { body: { items: [{ text: "每周留一次不被打断的谈话。", evidence: "证据甲" }] }, inputTokens: 1, outputTokens: 1 };
}
const titled = sectionId === "communication" && calls.filter((id) => id === "communication").length === 1;
return { body: titled ? { ...clean, text: `沟通与理解 沟通与理解 ${clean.text}` } : clean, inputTokens: 1, outputTokens: 1 };
},
gate: async () => {
checks += 1;
return { ok: true, failures: [] };
},
});
assert.equal(checks, 2);
assert.equal(calls.filter((id) => id === "communication").length, 2);
const saved = result.snapshotParts.sections.find((section) => section.id === "communication");
assert.equal(saved?.text, clean.text);
assert.equal(saved?.text.startsWith("沟通与理解"), false);
assert.equal(stripLeadingSectionTitle("沟通与理解 沟通与理解 沟通的天气很顺。", "沟通与理解"), "沟通的天气很顺。");
assert.equal(stripLeadingSectionTitle("Communication and Understanding The two of you talk.", "Communication and Understanding"), "The two of you talk.");
assert.equal(stripLeadingSectionTitle("沟通与理解力还在。", "沟通与理解"), "沟通与理解力还在。");
});
test("technical appendix has six program blocks and the fixed closing line", () => {
const blocks = buildTechnicalBasis({
dossier: { execution_receipt: { pair_execution_tasks: [{ task: "Kuja Dosha", status: "not_applicable" }] } },
@@ -225,6 +225,15 @@ def _scan_prose(text: str, language: str, section: str, *, opening: bool) -> lis
return failures
def _opens_with_title(text: str, title: str) -> bool:
heading = title.strip()
body = text.lstrip()
if not heading or not body.startswith(heading):
return False
rest = body[len(heading):]
return rest == "" or rest[:1].isspace() or rest.startswith(heading)
def validate_synastry_reader(
delivery: dict[str, Any],
section_inputs: dict[str, Any] | None = None,
@@ -254,6 +263,9 @@ def validate_synastry_reader(
failures.append(_fail(section_id, "missing_section", section_id))
continue
text = str(row.get("text") or "")
title = str(row.get("title") or "")
if _opens_with_title(text, title):
failures.append(_fail(section_id, "repeated_heading", title))
prose_characters += len(re.sub(r"\s+", "", text))
prose_words += len(re.findall(r"\b[\w']+\b", text))
minimum = ZH_SECTION_MINIMUM if language == "zh" else EN_SECTION_MINIMUM
+10
View File
@@ -180,6 +180,16 @@ def test_english_kuja_fails():
assert "banned_term" in _one(delivery, inputs, "en")
def test_a_body_that_opens_with_the_section_title_fails():
delivery, inputs = _passing("zh")
delivery["sections"][2]["text"] = "沟通与理解 沟通与理解 " + delivery["sections"][2]["text"]
assert "repeated_heading" in _one(delivery, inputs)
delivery, inputs = _passing("en")
delivery["sections"][2]["title"] = "Communication and Understanding"
delivery["sections"][2]["text"] = "Communication and Understanding " + delivery["sections"][2]["text"]
assert "repeated_heading" in _one(delivery, inputs, "en")
def test_english_banned_word_fails():
delivery, inputs = _passing("en")
delivery["sections"][1]["text"] += " Venus "