fix(rectification): plain-word opening with examples in the stem, plain de-duplicated step receipt (BUG-1049, BUG-1050)
- BUG-1049 (recurrence of BUG-504): the opening stem carries six examples and an example answer again and is server-owned on the zero-evidence opening; the body is two plain sentences (no 大运/盘面/代表分钟/精确到秒, no year, no question). Stem de-dup compares whole sentences / near-equality instead of a 12-char prefix, which had deleted the body's examples sentence sinceaa7ccb30(BUG-604) +dd8f35f7(BUG-648). - BUG-1050: plain step labels; a finished step label shows once and 「已完成 N 步」counts shown rows; failed rows read 「…未完成」 from the in-progress wording. - Skill 10.0.30 -> 10.0.31 (OpeningPolicy); 10.0.30 kept as deprecated. - VOICE / DESIGN / CHANGELOG / BUG_HISTORY / PROGRESS / real-device checklist and screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e53052a258
commit
8fc1a5bded
@@ -36,7 +36,30 @@ export type CollectDomain =
|
||||
| "health_pressure"
|
||||
| "other";
|
||||
|
||||
export const GENERIC_COLLECT_QUESTION = "先说你最容易想起的一两件,年月大概就行。";
|
||||
/** Fixed illustrative answer in the opening stem. Not derived from the birth date. */
|
||||
export const OPENING_ANSWER_EXAMPLE = "2015 年夏天换了工作";
|
||||
|
||||
/** Six everyday examples named in the opening stem. Not years. */
|
||||
export const OPENING_COLLECT_DOMAINS = [
|
||||
"上大学",
|
||||
"第一份工作",
|
||||
"搬到别的城市",
|
||||
"谈恋爱或结婚",
|
||||
"家里添丁",
|
||||
"生病住院",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Zero-evidence opening stem (question slot of the first collect focus). It
|
||||
* carries the six examples and one example answer (BUG-504 / BUG-1049): the
|
||||
* reader must see what kind of thing to say and how precise the date needs to
|
||||
* be. The year inside 「」 is a fixed illustration, never derived from the
|
||||
* birth date, and it only lives in the stem — the opening body stays year-free.
|
||||
* Server-owned: the opening set-focus stores this text regardless of what the
|
||||
* model writes in `spokenPrompt`. Kept literal (source locks); tests assert it
|
||||
* names every OPENING_COLLECT_DOMAINS entry and OPENING_ANSWER_EXAMPLE.
|
||||
*/
|
||||
export const GENERIC_COLLECT_QUESTION = "先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。";
|
||||
|
||||
/** One-time suffix on the first dated-collect stem. Not a chase-more turn. */
|
||||
export const FIRST_DATED_COLLECT_INVITE = "想到别的也可以一起说。";
|
||||
@@ -47,34 +70,60 @@ export function withFirstDatedCollectInvite(stem: string): string {
|
||||
return `${spoken}${FIRST_DATED_COLLECT_INVITE}`;
|
||||
}
|
||||
|
||||
/** Six everyday examples named in the opening body. Not years. */
|
||||
export const OPENING_COLLECT_DOMAINS = [
|
||||
"上大学",
|
||||
"第一份工作",
|
||||
"搬到别的城市",
|
||||
"谈恋爱或结婚",
|
||||
"家里添丁或长辈住院",
|
||||
"生病受伤",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Opening body in plain words (BUG-1049, product 2026-09-26): what we are doing
|
||||
* and how, no question, no years, no jargon. The examples live in the stem
|
||||
* right below it, so the body does not list them again.
|
||||
*/
|
||||
export function openingSpokenBody(range: readonly [string, string] | null): string {
|
||||
const window = formatClockRange(range) ?? "当前这段";
|
||||
const window = formatClockRange(range);
|
||||
return [
|
||||
`眼下按 ${window} 来核对,用你记得的经历对照大运和盘面变化。`,
|
||||
"最后给区间和代表分钟,不给精确到秒。",
|
||||
`先说你最容易想起的一两件,比如${OPENING_COLLECT_DOMAINS.join("、")},年月大概就行。`,
|
||||
window
|
||||
? `我们来把你的出生时间缩小到更准的范围,现在先在 ${window} 之间找。`
|
||||
: "我们来把你的出生时间缩小到更准的范围。",
|
||||
"做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。",
|
||||
].join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Words the opening body must not use (BUG-1049). The reader has not been told
|
||||
* what any of these mean yet. The delivery card still carries the
|
||||
* representative-minute boundary (REPRESENTATIVE_MINUTE_DISCLAIMER).
|
||||
*/
|
||||
export const OPENING_BODY_JARGON = [
|
||||
"大运",
|
||||
"盘面",
|
||||
"分盘",
|
||||
"代表分钟",
|
||||
"精确到秒",
|
||||
"候选",
|
||||
"区间",
|
||||
"Dasha",
|
||||
"dasha",
|
||||
] as const;
|
||||
|
||||
const OPENING_YEAR = /(?:19|20)\d{2}/;
|
||||
|
||||
export function isAcceptableOpeningBody(body: string): boolean {
|
||||
/**
|
||||
* A model-written opening body is kept only when it says what we do and how
|
||||
* (range + 星盘 + 年月), in plain words, without a question, years or the
|
||||
* stem's examples. Anything else is replaced by `openingSpokenBody`.
|
||||
*/
|
||||
export function isAcceptableOpeningBody(
|
||||
body: string,
|
||||
range: readonly [string, string] | null = null,
|
||||
): boolean {
|
||||
const spoken = body.trim();
|
||||
if (!spoken || OPENING_YEAR.test(spoken)) return false;
|
||||
if (/[??]/.test(spoken)) return false;
|
||||
if (OPENING_BODY_JARGON.some((word) => spoken.includes(word))) return false;
|
||||
const sentences = splitAfterSentencePunctuation(spoken, "。!?").map((part) => part.trim()).filter(Boolean);
|
||||
if (sentences.length === 0 || sentences.length > 4) return false;
|
||||
if (sentences.length === 0 || sentences.length > 3) return false;
|
||||
if (!spoken.includes("星盘") || !spoken.includes("年月")) return false;
|
||||
if (range && !(spoken.includes(range[0]) && spoken.includes(range[1]))) return false;
|
||||
// Listing the examples again would repeat the stem shown right below.
|
||||
const hits = OPENING_COLLECT_DOMAINS.filter((domain) => spoken.includes(domain)).length;
|
||||
return hits >= 5;
|
||||
return hits <= 2;
|
||||
}
|
||||
|
||||
/** Everyday words a collect spoken prompt must mention for its server domain. */
|
||||
|
||||
@@ -582,7 +582,7 @@ export async function streamV9Attempt(
|
||||
}
|
||||
if (action === "opening") {
|
||||
const openingRange = openingRangeFromCandidateRange(latestDossier.case.candidateRange);
|
||||
if (!isAcceptableOpeningBody(answerText)) {
|
||||
if (!isAcceptableOpeningBody(answerText, openingRange)) {
|
||||
answerText = openingSpokenBody(openingRange);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string
|
||||
...(clue
|
||||
? [`家人或本人关于出生时段的线索(仅旁白建议,不得改搜索窗口):${clue}`]
|
||||
: []),
|
||||
`做法要点:一句当前窗口与核对做法;一句「最后给区间和代表分钟,不给精确到秒」;一句「想到几件说几件,有大概年月就行」并点出${OPENING_COLLECT_DOMAINS.join("、")}。一条消息可以报多件,想到几件说几件。不得写具体年份,不得要求先准备材料。不要提问。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,题干写成「先说你最容易想起的一两件,年月大概就行」。`,
|
||||
`做法要点:正文两句大白话,照这个意思写:「我们来把你的出生时间缩小到更准的范围${window ? `,现在先在 ${window} 之间找` : ""}。」「做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不写具体年份,不要提问,不列举例子,不要求先准备材料。一条消息可以报多件,想到几件说几件。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,写「先说一两件你记得的大事」即可:开场题干由服务端固定,会列出${OPENING_COLLECT_DOMAINS.join("、")},并给一个带大概年月的回答示例,正文不要重复这些例子。`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -91,4 +91,5 @@ export const MAX_RESUMABLE_CASES_PER_USER = 1;
|
||||
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
|
||||
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
|
||||
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
|
||||
export const RECTIFICATION_SKILL_VERSION = "10.0.30";
|
||||
// 原值: "10.0.30" / 新值: "10.0.31" / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
|
||||
export const RECTIFICATION_SKILL_VERSION = "10.0.31";
|
||||
|
||||
@@ -48,11 +48,59 @@ export function trimSpokenTurnForInterview(body: string, terminalNote: boolean):
|
||||
: trimEvidenceTurnBody(body);
|
||||
}
|
||||
|
||||
function normalizeSentence(text: string): string {
|
||||
return text.replace(/\s+/g, "").replace(/[。..!!??;;,,、]+$/u, "");
|
||||
}
|
||||
|
||||
function bigrams(text: string): string[] {
|
||||
const chars = [...text];
|
||||
const grams: string[] = [];
|
||||
for (let index = 0; index + 1 < chars.length; index += 1) {
|
||||
grams.push(`${chars[index]}${chars[index + 1]}`);
|
||||
}
|
||||
return grams;
|
||||
}
|
||||
|
||||
/** Dice coefficient over character bigrams, 0–1. */
|
||||
function sentenceSimilarity(left: string, right: string): number {
|
||||
const a = bigrams(left);
|
||||
const b = bigrams(right);
|
||||
if (a.length === 0 || b.length === 0) return left === right ? 1 : 0;
|
||||
const pool = new Map<string, number>();
|
||||
for (const gram of b) pool.set(gram, (pool.get(gram) ?? 0) + 1);
|
||||
let shared = 0;
|
||||
for (const gram of a) {
|
||||
const remaining = pool.get(gram) ?? 0;
|
||||
if (remaining > 0) {
|
||||
shared += 1;
|
||||
pool.set(gram, remaining - 1);
|
||||
}
|
||||
}
|
||||
return (2 * shared) / (a.length + b.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* A body sentence restates the stem only when it is the stem, one of the
|
||||
* stem's sentences, or nearly the same sentence (rewording a word or two).
|
||||
* Sharing an opening phrase is not enough: before BUG-1049 a 12-character
|
||||
* prefix match deleted the opening examples sentence, which began like the
|
||||
* stem but carried different content.
|
||||
*/
|
||||
export const STEM_NEAR_EQUAL_THRESHOLD = 0.8;
|
||||
|
||||
function isQuestionSentence(text: string, stem: string): boolean {
|
||||
if (!text) return false;
|
||||
if (stem && text === stem) return true;
|
||||
const prefix = stem.slice(0, 12);
|
||||
if (prefix && text.startsWith(prefix)) return true;
|
||||
if (stem) {
|
||||
const sentence = normalizeSentence(text);
|
||||
const stemParts = [stem, ...splitCollectBody(stem)]
|
||||
.map((part) => normalizeSentence(part.trim()))
|
||||
.filter(Boolean);
|
||||
for (const part of stemParts) {
|
||||
if (sentence === part) return true;
|
||||
if (sentenceSimilarity(sentence, part) >= STEM_NEAR_EQUAL_THRESHOLD) return true;
|
||||
}
|
||||
}
|
||||
return /[??]$/.test(text);
|
||||
}
|
||||
|
||||
|
||||
@@ -1370,6 +1370,26 @@ export function shouldAttachChoiceFrame(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The zero-evidence opening collect question (BUG-504). Its stem is
|
||||
* `GENERIC_COLLECT_QUESTION`, which carries the examples and an example answer;
|
||||
* the set-focus tool stores that stem instead of the model's rewording
|
||||
* (BUG-1049), so the examples cannot be paraphrased away.
|
||||
*/
|
||||
export function isOpeningCollectFollowup(
|
||||
followup: MethodFollowup | null | undefined,
|
||||
evidence: readonly MethodFollowupEvidence[] = [],
|
||||
): boolean {
|
||||
if (!followup || followup.choice_frame || followup.date_reliability_evidence_id) return false;
|
||||
return followup.intent === "collect_method_evidence"
|
||||
&& !followup.spoken_prompt?.trim()
|
||||
&& followup.source === "method_coverage"
|
||||
&& followup.method_id === "dasha_events"
|
||||
&& followup.domain === "other"
|
||||
&& followup.collect_retry !== true
|
||||
&& !evidence.some(isConfirmedDated);
|
||||
}
|
||||
|
||||
export function spokenFollowupForUser(
|
||||
followup: MethodFollowup | null,
|
||||
evidence: readonly MethodFollowupEvidence[] = [],
|
||||
@@ -1380,11 +1400,7 @@ export function spokenFollowupForUser(
|
||||
if (followup.intent !== "collect_method_evidence") return null;
|
||||
if (followup.spoken_prompt?.trim()) return followup.spoken_prompt.trim();
|
||||
const domain = followup.domain ?? "";
|
||||
const openingOther = followup.source === "method_coverage"
|
||||
&& followup.method_id === "dasha_events"
|
||||
&& domain === "other"
|
||||
&& followup.collect_retry !== true
|
||||
&& !evidence.some(isConfirmedDated);
|
||||
const openingOther = isOpeningCollectFollowup(followup, evidence);
|
||||
if (domain === "other") {
|
||||
return openingOther ? GENERIC_COLLECT_QUESTION : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user