diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 49f10cea..ccb1ed4d 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -44,8 +44,9 @@ import { consultationPublicActivityEvent, streamAgentResponse } from "@/lib/stre import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events"; import { consultationContinueMessages, - dailyAnswerShapeInstruction, - natalAnswerShapeInstruction, + followUpAnswerShapeInstruction, + hasPriorAssistantAnswer, + natalUserTurnShape, natalConsultationThinkingPlan, type PublicThinkingSection, } from "@/lib/consultation-thinking-plan"; @@ -991,9 +992,11 @@ export async function POST(request: Request) { : "如需新的个人星盘结论,必须调用服务器绑定的排盘工具。"; // The loop's own step after the calculation writes the answer (BUG-1053), // so the answer shape the removed compose prompt used to carry is here. - const natalInstruction = consultEntrypoint === "daily_starlanguage" - ? `${natalToolInstruction}${dailyAnswerShapeInstruction()}` - : `${natalToolInstruction}${natalAnswerShapeInstruction()}`; + // A session that already holds an answer gets the follow-up shape + // (BUG-1071): a direct answer, no skeleton. That is read from the stored + // history, never from the wording of the question. + const followUpTurn = hasPriorAssistantAnswer(history); + const natalInstruction = `${natalToolInstruction}${natalUserTurnShape({ entrypoint: consultEntrypoint, history })}`; const adoptedRangeNote = consultationMode === "verified_chart" && prepared.serverChart?.truth.birthTimeStatus === "accepted" && prepared.serverChart.toolInput.candidate_range @@ -1311,6 +1314,7 @@ export async function POST(request: Request) { if (!prepared.serverChart) throw new Error("server_chart_truth_missing"); state.thinkingPlan = natalConsultationThinkingPlan({ domains: [consultationTheme], + followUp: followUpTurn, }); const agentContext = createConsultationAgentContext({ userId, @@ -1320,6 +1324,7 @@ export async function POST(request: Request) { plan: prepared.preReserveResult, theme: consultationTheme, entrypoint: consultEntrypoint, + followUpTurn, serverChart: prepared.serverChart, abortSignal: agentAbortSignal, state, @@ -1511,6 +1516,11 @@ export async function POST(request: Request) { { birthTimeSource: prepared.serverChart.truth.birthTimeSource }, ); const workflowReceipt = consultationWorkflowReceipt(workflowContext); + // The grounded fallback writes from the injected workflow; same first-turn + // / follow-up split as the agentic path (BUG-1071), one statement of it. + const legacyNatalInstruction = hasPriorAssistantAnswer(history) + ? followUpAnswerShapeInstruction() + : "先用不超过 400 字口语直接回答下面的问题,不要加标题;形状为:先给反差(表面 A,底下 B,都是盘上的结构,命名成一个格局)、再说清谁在推谁在修、然后「别去应 X 的象,去扮演 Y 的象」;开场不写行动。然后按三个二级标题写:## 盘里支持这个判断的地方(原始结构、六步宫位、Yoga 表作为依据,只写开场没说过的,表格与正文二选一)、## 时间怎么看、## 这周可以做的一件事(最多三条行动,只在这里出现一次)。全文不超过 900 字。骨架不可省略;不写「先回答你的问题」这个标题,文末技法审计表由产品界面折叠展示,不写进正文。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。"; const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model); const legacyHistory = history.map((message) => message.role === "user" @@ -1527,7 +1537,7 @@ export async function POST(request: Request) { content: consultationUserTurnContent({ currentTime: currentTimeContext(requestTime), name, - instruction: "先用不超过 400 字口语直接回答下面的问题,不要加标题;形状为:先给反差(表面 A,底下 B,命名成一个格局)、再说清谁在推谁在修、然后「别去应 X 的象,去扮演 Y 的象」、收尾最多三条短行动(破折号短句,每条不超过 20 字);然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。", + instruction: legacyNatalInstruction, summaryText: historyWindow.summaryText, droppedCount: historyWindow.droppedCount, question: resolvedQuestion.modelQuestion, @@ -1552,7 +1562,7 @@ export async function POST(request: Request) { content: consultationUserTurnContent({ currentTime: currentTimeContext(requestTime), name, - instruction: "先用不超过 400 字口语直接回答下面的问题,不要加标题;形状为:先给反差(表面 A,底下 B,命名成一个格局)、再说清谁在推谁在修、然后「别去应 X 的象,去扮演 Y 的象」、收尾最多三条短行动(破折号短句,每条不超过 20 字);然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。", + instruction: legacyNatalInstruction, summaryText: historyWindow.summaryText, droppedCount: historyWindow.droppedCount, question: resolvedQuestion.modelQuestion, diff --git a/frontend/src/lib/consultation-thinking-plan.ts b/frontend/src/lib/consultation-thinking-plan.ts index 3c455a72..cd0ae798 100644 --- a/frontend/src/lib/consultation-thinking-plan.ts +++ b/frontend/src/lib/consultation-thinking-plan.ts @@ -9,13 +9,24 @@ export type ThinkingStepStatus = (typeof THINKING_STEP_STATUSES)[number]; export const VISIBLE_THINKING_STEP_LIMIT = 4; +/** + * The natal body headings (TASK-consult-answer-the-question-20260927 D8 / + * BUG-1073). The heading-free spoken opener is the answer, so the body no + * longer restarts with 「先回答你的问题」: it holds only what the opener did not + * say, the timing, and the actions, once. + */ export const REPORT_HEADING = { - question: "先回答你的问题", support: "盘里支持这个判断的地方", timing: "时间怎么看", action: "这周可以做的一件事", } as const; +/** The declared-window reply still opens its stable-layer section with this heading. */ +export const WINDOW_OPEN_HEADING = "先回答你的问题"; + +/** The thinking-bar section that stands for the spoken opener (D5: it answers the question, it does not guess it). */ +export const OPENER_THINKING_TITLE = "先回答你问的这件事"; + export const DAILY_HEADING = { trend: "今日趋势", actAvoid: "适合推进 / 需要避开", @@ -74,7 +85,6 @@ function layerSteps(layers: readonly string[] | undefined, prefix: string): Arra export function consultationReportHeadings(_domains: readonly ConsultationDomain[] = []): string[] { return [ - REPORT_HEADING.question, REPORT_HEADING.support, REPORT_HEADING.timing, REPORT_HEADING.action, @@ -91,6 +101,8 @@ export function natalConsultationThinkingPlan(input: { domains: readonly ConsultationDomain[]; requiredBlocks?: readonly string[]; mustUseLayers?: readonly string[]; + /** A follow-up turn (the session already holds a natal answer): the opener step is the whole answer. */ + followUp?: boolean; }): PublicThinkingSection[] { const domains = input.domains.slice(0, 6); const blocks = new Set(input.requiredBlocks ?? Object.keys(BLOCK_STEP_LABELS)); @@ -111,12 +123,18 @@ export function natalConsultationThinkingPlan(input: { ]); return [ + // The opener has no heading of its own; it is finished when the first body + // H2 appears, so this section shares the support heading (BUG-1072/1073). publicThinkingSectionSchema.parse({ id: "question", - title: "先抓住你真正在问的事", - heading: REPORT_HEADING.question, + title: OPENER_THINKING_TITLE, + heading: REPORT_HEADING.support, steps: [ - { id: "question-read", label: "用盘上的反差回答这个问题", status: "pending" }, + { + id: "question-read", + label: input.followUp ? "直接回答这句话" : "用盘上的反差回答这个问题", + status: "pending", + }, ], }), publicThinkingSectionSchema.parse({ @@ -194,7 +212,7 @@ export function windowConsultationThinkingPlan(): PublicThinkingSection[] { return [publicThinkingSectionSchema.parse({ id: "window", title: "接下来根据声明窗口整理稳定层", - heading: REPORT_HEADING.question, + heading: WINDOW_OPEN_HEADING, steps: [ { id: "compare", label: "比较声明窗口内的稳定层", status: "pending" }, { id: "boundary", label: "核对应答边界", status: "pending" }, @@ -276,7 +294,7 @@ export function consultationSpokenHeadingRule(kind: "natal" | "general" | "windo const activity = "Do not invent a thinking-process checklist. Activity, progress, and receipts are server-owned."; if (kind === "natal") { return [ - `After a 3-6 sentence spoken reply with no heading that answers the user's question (反差(表面 A,底下 B,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象 → 最多三条短行动,各 ≤ 20 characters; total ≤ 400 characters), write the rest with these exact Markdown H2 headings in order: ## ${REPORT_HEADING.question}, then ## ${REPORT_HEADING.support}, then ## ${REPORT_HEADING.timing}, then ## ${REPORT_HEADING.action}. Do not write 统一参数与原始结构 or 技法审计表 into the body; those stay on the evidence panel.`, + `On the first natal answer of a session: after a 3-6 sentence spoken reply with no heading that answers the user's question (反差(表面 A,底下 B,都是盘上的结构,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象; no action list in the opener; total ≤ 400 characters), write the rest with these exact Markdown H2 headings in order: ## ${REPORT_HEADING.support}, then ## ${REPORT_HEADING.timing}, then ## ${REPORT_HEADING.action}. The body never restarts the answer under a 先回答 heading; the support section holds only what the opener did not say; the actions appear once, in the last section. Do not write 统一参数与原始结构 or 技法审计表 into the body; those stay on the evidence panel. A follow-up turn follows the shape instruction in the user turn instead.`, "Parallel points such as today's transits, 适合推进, 需要避开, and candidate windows must be Markdown bullet lists. Bold a short label, then one clause; do not stack those as plain paragraphs.", activity, secrets, @@ -292,7 +310,7 @@ export function consultationSpokenHeadingRule(kind: "natal" | "general" | "windo } if (kind === "window") { return [ - `When describing stable window structure, start with ## ${REPORT_HEADING.question}.`, + `When describing stable window structure, start with ## ${WINDOW_OPEN_HEADING}.`, activity, secrets, ].join(" "); @@ -324,7 +342,7 @@ export function consultationContinuePrompt(output: string): string { return [ "上一轮用户可见正文因长度在标题处停下。从最后一个完整二级标题之后继续写完,不要重复已写出的段落,不要写思考过程清单。", last ? `最后一个完整标题是:${last}` : "上一轮还没有写出完整的二级标题。", - `必须继续使用这些二级标题(尚未写到的才写):## ${REPORT_HEADING.question}、## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}。不要写统一参数或技法审计表。`, + `必须继续使用这些二级标题(尚未写到的才写):## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}。不要写「先回答你的问题」这个标题,不要写统一参数或技法审计表。`, "已写出的末尾摘录:", output.slice(-800), ].join("\n"); @@ -338,15 +356,55 @@ export function consultationContinuePrompt(output: string): string { */ const ANSWER_FROM_EVIDENCE = "拿到本轮计算结果后直接写给用户的回答,只用结果里的盘面事实下判断;调用工具前后都不要写过程说明。"; -/** Natal answer shape (VOICE §7): heading-free opener, then the four headings in one pass. */ +/** Natal first-turn shape (VOICE §7, D8): heading-free opener, then three headings, each thing said once. */ export function natalAnswerShapeInstruction(): string { return [ ANSWER_FROM_EVIDENCE, - `开场不要标题,先用口语回答问题;然后一次写完这四个二级标题:## ${REPORT_HEADING.question}、## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}。`, + "开场不要标题,先用不超过 400 字口语回答问题:反差只写盘上的结构(表面 A、底下 B 都是星、宫、大运或分盘),说清谁在推谁在修,然后「别去应 X 的象,去扮演 Y 的象」;开场不写行动清单。", + `然后一次写完这三个二级标题:## ${REPORT_HEADING.support}(只写开场没说过的依据,表格与正文二选一,不把开场的判断再列一遍)、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}(最多三条行动,只在这里出现一次)。全文不超过 900 字。`, + "不要写「先回答你的问题」这个标题,不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。", + ].join(""); +} + +/** + * Follow-up turn shape (TASK-consult-answer-the-question-20260927 D3 / + * BUG-1071): the session already holds a natal answer, so this turn answers + * the sentence that was asked and does not reopen the skeleton. The server + * decides that this is a follow-up from the stored history alone + * (`hasPriorAssistantAnswer`); whether the sentence is a yes/no question or a + * change of domain is the model's reading, never a regex. + */ +export function followUpAnswerShapeInstruction(): string { + return [ + ANSWER_FROM_EVIDENCE, + "这是追问轮:本会话前面已经写过完整解读。不超过 200 字,第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据);只引与这句话直接相关的盘面事实,最多三处;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;不解释用户为什么问,不替用户说他想要什么。", + `只有用户明确换了领域或要求完整看一下,才回到口语开场加三个二级标题(## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action})的完整形状。`, "不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。", ].join(""); } +/** The stored history already holds an assistant answer: this turn is a follow-up (D6, no intent regex). */ +export function hasPriorAssistantAnswer( + history: ReadonlyArray>, +): boolean { + return history.some((message) => message.role === "assistant" && message.text.trim().length > 0); +} + +/** + * The shape instruction the natal user turn carries: the daily entrypoint keeps + * its three sections; a natal follow-up turn answers directly; the first natal + * answer of a session writes the opener and the three-heading skeleton. + */ +export function natalUserTurnShape(input: { + entrypoint?: string; + history: ReadonlyArray>; +}): string { + if (input.entrypoint === "daily_starlanguage") return dailyAnswerShapeInstruction(); + return hasPriorAssistantAnswer(input.history) + ? followUpAnswerShapeInstruction() + : natalAnswerShapeInstruction(); +} + /** Daily entrypoint answer shape: three sections, audit table and boundary line in the last. */ export function dailyAnswerShapeInstruction(): string { return [ diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index a9ade76f..3512e2a2 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -425,6 +425,8 @@ export type ConsultationAgentContext = Readonly<{ plan?: ConsultationPlan; theme?: ConsultationDomain; entrypoint?: ConsultationEntrypoint; + /** The session already holds a natal answer (D6: a stored-history fact, not an intent guess). */ + followUpTurn?: boolean; serverChart: ServerChartConsultation; abortSignal?: AbortSignal; state: ConsultationRuntimeState; @@ -1110,6 +1112,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { ? dailyConsultationThinkingPlan() : natalConsultationThinkingPlan({ domains: executions.map((execution) => execution.domain), + followUp: ctx.followUpTurn, requiredBlocks: modelContext.presentation?.required_blocks, mustUseLayers: Array.isArray(modelContext.evidence_contract?.must_use_layers) ? modelContext.evidence_contract.must_use_layers.filter((item): item is string => typeof item === "string") diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index c52d547d..90d5a836 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -18,12 +18,12 @@ export type { ConsultationInput } from "./consultation-workflow.ts"; const jyotishInstructions = `You are the guide for a conversational Vedic astrology product. ${productConversationVoice} ${natalSpokenReportContract} -Write in Simplified Chinese: a heading-free spoken opener first (反差(表面 A,底下 B,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象 → 最多三条短行动,各 ≤ 20 characters; ≤ 400 characters), then the skill Level 2 report skeleton for natal domain questions. Use Markdown tables for raw structure and, when the card carries yogas, for the Yoga table; the Technique Audit Table is folded by the product UI and never written in the body. +Write in Simplified Chinese. On the first natal answer of a session: a heading-free spoken opener first (反差(表面 A,底下 B,都是盘上的结构,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象; no action list in the opener; ≤ 400 characters), then the skill Level 2 report skeleton under the three body headings (## 盘里支持这个判断的地方, ## 时间怎么看, ## 这周可以做的一件事), each thing said once, first-turn total ≤ 900 characters. A follow-up turn follows the shape instruction in the user turn. Use a Markdown table for raw structure only when the prose does not already carry it and, when the card carries yogas, for the Yoga table; the Technique Audit Table is folded by the product UI and never written in the body. ${jyotishSkillMethodBlock} The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat. Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn. Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. List every domain the question actually needs, in priority order, up to six. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum. A question about one's parents uses parents and one about one's children uses children; family is only for the household as a whole. The server canonicalizes aliases, rejects unsupported/product domains, executes as many accepted domains as the wall clock can pay for (about ${MAX_CONSULTATION_DOMAINS}), and returns the rest in omitted_domains. The actual executed domains are in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters. -The tool result's methodology field is the domain checklist for the routes that actually ran. For career, marriage and wealth it is the condensed consultation checklist (must-see items, the three layers to keep apart, forbidden statements, and for marriage the gender-unknown rule), condensed from the skill's strict route and event-judgment file; every other route's section is quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above. +The tool result's methodology field is the domain checklist for the routes that actually ran. For career, marriage and wealth it is the condensed consultation checklist (must-see items, the three layers to keep apart, forbidden statements, and for marriage the gender-unknown rule), condensed from the skill's strict route and event-judgment file; every other route's section is quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. The checklist is a list of things to check, not a list of paragraphs to write: put into the body only the structures that bear on this question, and leave the checked-but-irrelevant ones out. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above. The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written; consultations carries each domain's own contract. Never treat an absent top-level field as permission to answer without a contract. The evidence card is the chart evidence for this answer. claim_cards holds it: natal_foundation (ascendant, the twelve house signs, planet placements with degrees, functional benefics/malefics with the houses each planet rules, and d9: the D9 lagna, each planet's D9 sign and dignity, Vargottama and major D1/D9 dignity reversals — use it to confirm or weaken a D1 promise), timing (the running Vimshottari mahadasha, antardasha and pratyantardasha with dates, the antardashas inside the mahadasha, the running Narayana period) and domain (one section per executed domain: its vargas, houses with occupants and lords, focus planets and the layers that domain uses). The values are copied from this request's calculation; quote them as given. A value tagged parameter_sensitive (the annual Tajika chart) or observation_only (Punarphoo) is structure to weigh, not a verdict; an annual_chart whose status is 年盘未接入 means there is no annual chart this turn, so say so and never stand natal data in for it. Chara Dasha and KP are never on the card; KP exact cusps are still blocked, so a KP lookup is reference only and never the basis of a conclusion. The server calculated the full spectrum and keeps the rest; evidence_card.supplementable_sections names what else this calculation holds. When the question genuinely needs one of those sections (for example a varga the card does not carry, a Western layer, the yoga details, Chara Dasha, Karakamsha, Argala, the dispositor chain, the D1/D9/D10 linkage, or the Moon transit for a month- or day-level question), call read-consultation-evidence once, before writing any answer text; it returns that section of this calculation or says it is unavailable, and it never calculates. Answer from the card, plus that one lookup when you used it. evidence_card.backstage says global web evidence and real-case calibration were not done for this answer, so confidence is capped: keep conclusions conditional, and mention it only when the user asks about method or certainty. diff --git a/frontend/src/mastra/product-voice.ts b/frontend/src/mastra/product-voice.ts index 29fe6088..3d39e3c3 100644 --- a/frontend/src/mastra/product-voice.ts +++ b/frontend/src/mastra/product-voice.ts @@ -1,9 +1,14 @@ export const productConversationVoice = `VISIBLE VOICE PERSONA: 把对面的人当一个人认真对待——不是工单,不是要被安抚的情绪,不是一张等着填完的表。你不是只会点头的工具人,更像他身边一个行动力很强、嘴有点毒但靠谱的同事:给你一个问题,你自己找路、自己补信息、把脏活干完,把结论丢回他面前。 直接,有立场,带一点锋利。毒只对处境,不对人:每一句锋利都要有盘上的证据撑着(哪颗星、哪个宫、哪段大运),没有证据就不许刻薄。 -有偏好:喜欢清晰的问题、直接的反馈、做完就能看到改变的事;最烦一句话丢过来让人猜半天。问题含糊时不猜、也不反问一串——写「这句我按『……』理解了,不对你纠正我」,然后照这个理解把答案给完,不要停在反问上。 +有偏好:喜欢清晰的问题、直接的反馈、做完就能看到改变的事;最烦一句话丢过来让人猜半天。问题含糊时不猜、也不反问一串:只有当这句话有两种实质不同的读法时,才写一句「我按 A 答」,答完不再解释为什么这样理解;单义的问题直接答,不改写它,不停在反问上。 每次收尾只给一到三件能做的事,做完就能看到改变。 +不揣测(回答对象是用户问的那句话): +- 不得写「你其实想问的是」「你真正在意的是」「你要的是」「你是否需要」「你问这个是因为」「你更想知道」,以及任何替用户或当事人陈述动机、需求、感受的句子。 +- 盘上有证据可以写「4 宫主落 10 宫,母亲的注意力在你的前途上」;不得写「她问的是结果,你要的是被听」——前者是宫位,后者是读心。 +- 是非题(是不是 / 会不会 / 能不能 / 有没有 / ……吗):第一句就是「是」「不是」或「看情况:……」,第二句给盘上的依据;不先讲背景,不把是非题改写成「你需要什么」。 + Five principles: answer the person before unfolding method; say progress when the numbers are already on the table; explain why a next step matters in one clause; restate the same constraint in different words; spoken Simplified Chinese to 你, warm but not cute. No 亲爱的, no 感谢你的信任, no emoji, no greeting spam. HOPE DISCIPLINE: 盘是客观的,活出它哪一个象是他的选择。不顺就说不顺,但每次都要指出这段时间可以扮演哪个象。 @@ -16,16 +21,16 @@ Never write 一切都会好, 相信自己, 加油, 你值得更好的, or 宇宙 OPENER SHAPE (natal / general / declared-window 三种模式共用) 开场不是一句判词,是一个形状;全程无标题,总长 ≤ 400 Chinese characters: -1. 反差:先说「表面 A,底下 B」,并把这个反差命名成一个格局(例:外松内紧)。不要用一句判词开场。 +1. 反差:先说「表面 A,底下 B」,并把这个反差命名成一个格局(例:外松内紧)。A 和 B 都必须是盘上的结构——哪颗星、哪个宫、哪段大运、哪张分盘;问的是别人(父母、伴侣、子女、同事)时,反差写那个人在你盘上对应的宫位与代表星,不写那个人的心思。不要用一句判词开场。 2. 谁在推、谁在修:动力层与形式层分开讲——哪一层在推这段时间(大运主星),哪一层只负责把结果修得体面(行运)。 3. 别去应 X 的象,去扮演 Y 的象:盘是客观的,活出它哪一个象是他的选择。哪怕是重的一年,也总有一个能扮演的象。这是希望的唯一来源,不许用一句空话替代。 -4. 最多三条短行动:破折号短句,每条 ≤ 20 Chinese characters,做完就能看到改变。 +4. 最多三条短行动:破折号短句,每条 ≤ 20 Chinese characters,做完就能看到改变。本命解读里这三条不写在开场,只写在正文最后一节「这周可以做的一件事」,出现一次;无出生分钟与申报时段没有正文骨架,行动就写在开场结尾。 术语当场用引号里的白话套住:土星的「磨」、木星的「享受」、土星的「守」。第一次出现就套,不另起名词解释段。 降级路线(形状照给,只换填法): - 申报时段(declared birth window):第 2 步只讲窗口内稳定的那一层——行星落座、公开日历;上升、宫位、大运、分盘不进这一步,也不写月份。第 3 步照给。 - 无出生分钟(no birth minute):第 2 步只讲公开日历这一天在推什么,不点个人大运,不写月份。第 3 步照给,能扮演的象取自公开日历,不取自个人盘。 -Good (口气与形状的定稿样例,盘上数据是示意): +Good (口气与形状的定稿样例,盘上数据是示意;本命解读里最后三行行动移到「这周可以做的一件事」那一节,开场到「别人松你不松」为止): 今年你的事业是「外松内紧」:表面上木星走到你的事业宫,机会、人缘都在,看着轻松;底下土星压着你的工作宫,做事的规矩和身体的负担在收紧。推动这一年的是土星,不是木星,木星只负责把结果修得体面。所以别去应木星的「享受」,也别去应土星的「磨」,去扮演土星的「守」:把规矩立起来,别人松你不松。 - 上半年别接口头承诺,只接写下来的。 - 6 月前身体的小毛病别拖。 @@ -33,6 +38,9 @@ Good (口气与形状的定稿样例,盘上数据是示意): Good (追问轮,用户说「你在说什么鬼」): 说直接点:这份工作还值得留,但你得换个位置,不是换公司。哪句没看懂,指给我。 +Good (是非题,用户问「我妈是不是不太在意我」,盘上数据是示意): 不是。4 宫主水星逆行落 10 宫,她把心力投在你的前途上——不是不在意,是不会用你想要的方式在意。 +Bad (同一问题): 你其实要的是被听见,她给的是安排;你是否需要的是一种被看见的方式……(替用户说动机,把是非题改写成需求题,第一句没有答「是」还是「不是」。) + Good: 你这段婚姻更像先把已经在一起的关系过稳,而不是立刻再开一段。眼下更值得把相处里反复出现的摩擦说清楚。 Bad: opening a natal answer with infrastructure, 根据当前计算结果, or a degrees table before answering what the person asked. @@ -43,19 +51,21 @@ Never open with 根据系统计算, 根据当前计算结果, 综合来看, 需 export const natalSpokenReportContract = `NATAL REPORT CONTRACT (spoken reply first, then skill Level 2 skeleton) The skill governs METHOD, TECHNIQUE INVOCATION, TRUTH BOUNDARIES, and the Level 2 skeleton — not the opening paragraphs. -For career / wealth / marriage / family (and any natal domain reading): open with the OPENER SHAPE above, heading-free, before the first H2 — 先给反差(表面 A,底下 B,并命名成一个格局),再说清谁在推、谁在修,然后「别去应 X 的象,去扮演 Y 的象」,收尾最多三条短行动(破折号短句,各 ≤ 20 Chinese characters). Spoken-layer total ≤ 400 Chinese characters. Then write the skill Level 2 template. 骨架不可省略,但必须以直接回应开场. The product UI renders the whole answer inline; there is no collapsed 完整分析 control any more. Write the full skeleton in the body and assume every line of it is read. Every H2 must start its own line — an H2 marker that continues the previous sentence renders as literal text, not a heading. +For career / wealth / marriage / family (and any natal domain reading), on the first natal answer of a session: open with the OPENER SHAPE above, heading-free, before the first H2 — 先给反差(表面 A,底下 B,都是盘上的结构,并命名成一个格局),再说清谁在推、谁在修,然后「别去应 X 的象,去扮演 Y 的象」. The opener carries no action list. Spoken-layer total ≤ 400 Chinese characters. Then write the skill Level 2 template under the three body headings below. 骨架不可省略,但必须以直接回应开场. The product UI renders the whole answer inline; there is no collapsed 完整分析 control any more. Write the full skeleton in the body and assume every line of it is read. Every H2 must start its own line — an H2 marker that continues the previous sentence renders as literal text, not a heading. -Required body order after that opener, using these exact H2 headings: ## 先回答你的问题, then ## 盘里支持这个判断的地方, then ## 时间怎么看, then ## 这周可以做的一件事. Do not write a thinking-process checklist; Activity is server-owned. Do not write 统一参数与原始结构 or 技法审计表 into the body — the evidence panel already renders those rows. -1. Answer the question first: the contrast, who is pushing / who is dressing the result, which role to play. -2. Ground the judgment in the evidence card's chart fields (houses, lords, yogas, strength). Raman six-step house judgment and the Yoga table stay in this section as evidence when the card carries them, not as a parameter dump. Cite, do not dump degrees tables. +Say each thing once (D8): the opener is the answer, so the body never restarts it under a 先回答 heading, never re-lists the opener's judgment in a table, and gives the actions once. First-turn total ≤ 900 Chinese characters. +Required body order after that opener, using these exact H2 headings: ## 盘里支持这个判断的地方, then ## 时间怎么看, then ## 这周可以做的一件事. Do not write a thinking-process checklist; Activity is server-owned. Do not write 统一参数与原始结构 or 技法审计表 into the body — the evidence panel already renders those rows. +1. The opener answers the question: the contrast (on the chart), who is pushing / who is dressing the result, which role to play. Not the person's motives. +2. Ground the judgment in the evidence card's chart fields (houses, lords, yogas, strength) — cite only the fields that support this judgment, and only what the opener did not already say. A table or prose, not both. Raman six-step house judgment and the Yoga table stay in this section as evidence when the card carries them, not as a parameter dump. Cite, do not dump degrees tables. 3. Timing: current Dasha and transits when delivered. Phrase as 能量活跃度/概率, never an absolute date guarantee. -4. One or two actions for this week. Direct, not footnote-soft. +4. One to three actions for this week, here and nowhere else. Direct, not footnote-soft. Address the reader as 你. Gloss a technical term once in plain Chinese (婚姻分盘(D9), 当前大运) before reuse. Raman yogas and house steps remain governed_support_only_candidate_ready: write strength and promotion/hindrance in skill language, but do not upgrade them into verified fate truth. Never: -- Skip a card fact the question needs in order to keep the answer short. +- Skip a card fact the question needs in order to keep the answer short — and never write a card fact the question does not need in order to look thorough. - Put the Technique Audit Table or 统一参数 heading back into the spoken body. +- State what the user or the person asked about wants, needs, or feels; state where the chart puts them. -One natal career/wealth/marriage/family question still uses the opener-plus-skeleton above.`; +The first natal career/wealth/marriage/family answer of a session uses the opener-plus-skeleton above; a follow-up turn follows the shape instruction carried in the user turn (a direct answer of about 200 characters, no headings) unless the user changes domain or asks for the full reading.`; diff --git a/frontend/src/mastra/skill-binding.ts b/frontend/src/mastra/skill-binding.ts index 08af7df6..05e6fc77 100644 --- a/frontend/src/mastra/skill-binding.ts +++ b/frontend/src/mastra/skill-binding.ts @@ -111,7 +111,7 @@ function boundMethod(): string { export const BOUND_METHOD_MARKER = ``; -const NATAL_REPORT_SKELETON = "For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (反差(表面 A,底下 B,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象 → 最多三条短行动,各 ≤ 20 characters; spoken layer ≤ 400 characters; raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). "; +const NATAL_REPORT_SKELETON = "For career, wealth, marriage, and family answers, on the first natal answer of a session, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (反差(表面 A,底下 B,都是盘上的结构,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象; no action list in the opener; spoken layer ≤ 400 characters) under the product's three body headings — 盘里支持这个判断的地方 (raw structure, six-step houses and the Yoga table as evidence, only what the opener did not say), 时间怎么看, 这周可以做的一件事 (the actions, once); the Technique Audit Table stays on the evidence panel, and a follow-up turn follows the shape instruction in the user turn instead. "; function methodBlock(reportSkeleton: string) { return `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. ${reportSkeleton}Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block. diff --git a/frontend/tests/consult-answer-the-question-20260927.test.ts b/frontend/tests/consult-answer-the-question-20260927.test.ts new file mode 100644 index 00000000..29661631 --- /dev/null +++ b/frontend/tests/consult-answer-the-question-20260927.test.ts @@ -0,0 +1,115 @@ +// TASK-consult-answer-the-question-20260927 T2 (BUG-1071): a follow-up turn +// (the session already holds a natal answer) carries the direct-answer shape +// instead of the first-turn skeleton, still calls the calculation tool once, +// and its short heading-free answer reaches the user in full — the +// step-scoped release (`ANSWER_RELEASE_CHARS` or a heading) holds the text +// only until the step ends, and a step that did not call a tool is released. +// +// Real `getJyotishAgent` + prompt-recording fake model; the calculation is +// the golden engine capture of a public AA chart (AGENTS §7.4). +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + followUpAnswerShapeInstruction, + natalAnswerShapeInstruction, + OPENER_THINKING_TITLE, + REPORT_HEADING, +} from "../src/lib/consultation-thinking-plan.ts"; +import { ANSWER_RELEASE_CHARS } from "../src/lib/stream-agent-response.ts"; +import { + firstPromptWithToolResult, + pieces, + runNatalAgent, + type Turn, +} from "./consult-natal-agent-test-support.ts"; + +type Json = Record; +const golden = JSON.parse(readFileSync( + new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url), + "utf8", +)) as { charts: Array<{ id: string; workflow: Json }> }; +const workflow = golden.charts.find((chart) => chart.id === "steve_jobs")!.workflow; + +const PRE_TOOL = "我先排一下盘,稍等。"; +// A fictional yes/no follow-up in the brief's fixture shape: verdict first, one +// chart fact, no heading, well under the release threshold. +const FOLLOW_UP_QUESTION = "我妈是不是不太在意我"; +const FOLLOW_UP_ANSWER = "不是。4 宫主落在 10 宫,她把心力投在你的前途上——不是不在意,是不会用你想要的方式在意。"; + +const calcCall = (text?: string): Turn => ({ + parts: [ + ...(text ? [{ text }] : []), + { tool: { name: "run-jyotish-consultation", input: { question: FOLLOW_UP_QUESTION, domains: ["parents"] } } }, + ], + finish: "tool-calls", +}); + +test("a follow-up turn still calculates once, and its short heading-free answer is released whole (BUG-1071)", async () => { + assert.ok(Array.from(FOLLOW_UP_ANSWER).length < ANSWER_RELEASE_CHARS, "the fixture is shorter than the release threshold"); + const run = await runNatalAgent( + [calcCall(PRE_TOOL), { parts: pieces(FOLLOW_UP_ANSWER), finish: "stop" }], + { workflow, question: FOLLOW_UP_QUESTION, instruction: followUpAnswerShapeInstruction(), followUpTurn: true }, + ); + + assert.deepEqual(run.terminal.map((event) => event.type), ["run.completed"]); + // ③ The calculation is request-scoped: one tool run, two model calls, the + // writer sees the result — exactly as on the first turn (D7). + assert.equal(run.workflowRuns, 1); + assert.equal(run.calls, 2); + assert.equal(firstPromptWithToolResult(run.prompts, "run-jyotish-consultation"), 1); + // ② The writer's prompt carries the follow-up shape, not the skeleton. + const writerPrompt = JSON.stringify(run.prompts[1]); + assert.match(writerPrompt, /这是追问轮/); + assert.match(writerPrompt, /不超过 200 字/); + assert.doesNotMatch(writerPrompt, /这三个二级标题:/); + assert.doesNotMatch(writerPrompt, /开场不写行动清单/); + // The answer has no heading and never crossed ANSWER_RELEASE_CHARS, yet it + // reaches the user whole: the step ended without a tool call. + assert.equal(run.answer, FOLLOW_UP_ANSWER); + assert.equal(run.completed, FOLLOW_UP_ANSWER); + assert.equal(run.charges, 1); + assert.equal(run.answer.includes(PRE_TOOL), false); + assert.doesNotMatch(run.answer, /^## /m); + // T4: the thinking bar names the opener step for what it is on a follow-up. + const sections = run.events.filter((event) => event.type === "thinking.section") as unknown as Array<{ title: string; steps: Array<{ label: string }> }>; + const opener = sections.find((section) => section.title === OPENER_THINKING_TITLE); + assert.ok(opener, "the opener section is announced"); + assert.equal(opener.steps[0]?.label, "直接回答这句话"); + assert.equal(JSON.stringify(sections).includes("先抓住你真正在问的事"), false); +}); + +test("the first turn of a session keeps the opener-plus-skeleton shape (control)", async () => { + const OPENER = "你和父母这条线,表面是各过各的,底下是 4 宫主和 9 宫主都不在家,这叫「隔着距离才亲」。"; + const ANSWER = [ + OPENER, + "", + `## ${REPORT_HEADING.support}`, + "4 宫空宫,宫主落 10 宫。", + "", + `## ${REPORT_HEADING.timing}`, + "这一段子运看父亲那条线。", + "", + `## ${REPORT_HEADING.action}`, + "- 替他们办一件具体的杂事。", + "", + ].join("\n"); + const run = await runNatalAgent( + [calcCall(), { parts: pieces(ANSWER), finish: "stop" }], + { workflow, question: "我和父母的亲疏关系如何", instruction: natalAnswerShapeInstruction() }, + ); + assert.deepEqual(run.terminal.map((event) => event.type), ["run.completed"]); + assert.equal(run.workflowRuns, 1); + const writerPrompt = JSON.stringify(run.prompts[1]); + // ① The first-turn shape: three body headings, no 先回答 heading, actions once. + for (const heading of Object.values(REPORT_HEADING)) assert.ok(writerPrompt.includes(`## ${heading}`)); + assert.doesNotMatch(writerPrompt, /## 先回答你的问题/); + assert.match(writerPrompt, /开场不写行动清单/); + assert.doesNotMatch(writerPrompt, /这是追问轮/); + assert.equal(run.answer, ANSWER); + assert.ok(run.answer.startsWith(OPENER), "the opener has no heading"); + const sections = run.events.filter((event) => event.type === "thinking.section") as unknown as Array<{ title: string; steps: Array<{ label: string }> }>; + const opener = sections.find((section) => section.title === OPENER_THINKING_TITLE); + assert.equal(opener?.steps[0]?.label, "用盘上的反差回答这个问题"); +}); diff --git a/frontend/tests/consult-answer-truncation-20260926.test.ts b/frontend/tests/consult-answer-truncation-20260926.test.ts index 203b77cc..f3a5603c 100644 --- a/frontend/tests/consult-answer-truncation-20260926.test.ts +++ b/frontend/tests/consult-answer-truncation-20260926.test.ts @@ -38,7 +38,10 @@ type Event = { type: string; code?: string; text?: string; message?: string; rec // Fictional answer text in the incident's shape: an opening, one heading, one // closed sentence, then a fragment the stream was cut after. -const CLOSED = `开场段落。\n- 见面比线上聊管用\n\n## ${REPORT_HEADING.question}\n能遇到,但「遇到」和「成」这半年不是一回事。\n\n`; +// 原值: 夹具标题用 REPORT_HEADING.question(先回答你的问题) +// 新值: 改用 REPORT_HEADING.support;这里只需要「任意一个二级标题」 +// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073 删除了「先回答你的问题」这一节 +const CLOSED = `开场段落。\n- 见面比线上聊管用\n\n## ${REPORT_HEADING.support}\n能遇到,但「遇到」和「成」这半年不是一回事。\n\n`; const DANGLING = "你这段盘"; const REST = "里金星落在七宫,所以关系会先从熟人圈里冒出来。\n"; diff --git a/frontend/tests/consult-condensed-checklist-20260927.test.ts b/frontend/tests/consult-condensed-checklist-20260927.test.ts index 09dc8bcf..a4495dc8 100644 --- a/frontend/tests/consult-condensed-checklist-20260927.test.ts +++ b/frontend/tests/consult-condensed-checklist-20260927.test.ts @@ -34,12 +34,12 @@ const QUESTIONS: Record<"career" | "marriage" | "wealth", string> = { wealth: "我的财富增长方式是什么?", }; +// 原值: 夹具里有 `## ${REPORT_HEADING.question}` 一节 +// 新值: 该节删除,夹具从口语开场直接到 `## ${REPORT_HEADING.support}` +// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073:开场就是回答,正文不再从「先回答你的问题」重来 const ANSWER = [ "表面是一个人在扛,底下是一群人在推,这叫「借力成事」。", "", - `## ${REPORT_HEADING.question}`, - "能成,但要分步。", - "", `## ${REPORT_HEADING.support}`, "十宫主在九分盘里站得住。", "", diff --git a/frontend/tests/consult-evidence-lookup-20260927.test.ts b/frontend/tests/consult-evidence-lookup-20260927.test.ts index 5b688a96..9c8bc037 100644 --- a/frontend/tests/consult-evidence-lookup-20260927.test.ts +++ b/frontend/tests/consult-evidence-lookup-20260927.test.ts @@ -54,12 +54,12 @@ const D60_LAGNA = String((((modules.varga_spectrum as Json).formal as Json).D60 const PRE_TOOL = "我先排一下盘,稍等。"; const LOOKUP_NARRATION = "我再看一眼 D60。"; const OPENER = "你和父母这条线,表面是各过各的,底下一直在较劲:你要自己说了算,他们要你稳。这不是谁对谁错,是同一件事的两种护法,所以叫「隔空守护」。"; +// 原值: 夹具里有 `## ${REPORT_HEADING.question}` 一节 +// 新值: 该节删除,夹具从口语开场直接到 `## ${REPORT_HEADING.support}` +// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073:开场就是回答,正文不再从「先回答你的问题」重来 const ANSWER = [ OPENER, "", - `## ${REPORT_HEADING.question}`, - "关系能缓,但先换说话方式,再谈大事。", - "", `## ${REPORT_HEADING.support}`, "四宫的主星落在十宫,家里的事常被你当成要办成的事来处理。", "", diff --git a/frontend/tests/consult-gender-card-20260927.test.ts b/frontend/tests/consult-gender-card-20260927.test.ts index 0e96f4a5..96c6392c 100644 --- a/frontend/tests/consult-gender-card-20260927.test.ts +++ b/frontend/tests/consult-gender-card-20260927.test.ts @@ -40,12 +40,12 @@ const golden = JSON.parse(readFileSync( )) as { charts: Array<{ id: string; workflow: Json }> }; const QUESTION = "今年婚恋怎么样?"; +// 原值: 夹具里有 `## ${REPORT_HEADING.question}` 一节 +// 新值: 该节删除,夹具从口语开场直接到 `## ${REPORT_HEADING.support}` +// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073:开场就是回答,正文不再从「先回答你的问题」重来 const ANSWER = [ "关系这件事,你不是没机会,是挑得狠。", "", - `## ${REPORT_HEADING.question}`, - "能谈成,但要分步。", - "", `## ${REPORT_HEADING.support}`, "七宫主在九分盘里站得住。", "", diff --git a/frontend/tests/consult-natal-agent-test-support.ts b/frontend/tests/consult-natal-agent-test-support.ts index c0268443..e26eb60c 100644 --- a/frontend/tests/consult-natal-agent-test-support.ts +++ b/frontend/tests/consult-natal-agent-test-support.ts @@ -134,6 +134,10 @@ export async function runNatalAgent( answerMs?: number; /** The bound subject's server chart; defaults to the public AA chart without a gender. */ serverChart?: Record; + /** The shape instruction the user turn carries; defaults to the first-turn natal shape. */ + instruction?: string; + /** The session already holds a natal answer (route: `hasPriorAssistantAnswer(history)`). */ + followUpTurn?: boolean; }, ) { seq += 1; @@ -149,6 +153,7 @@ export async function runNatalAgent( const agentContext = createConsultationAgentContext({ userId: "u", sessionId: "s", requestId: `natal-agent-${seq}`, consultationMode: "verified_chart", theme: options.theme ?? "parents", serverChart: (options.serverChart ?? publicServerChart) as never, abortSignal: clock.toolSignal, state, + followUpTurn: options.followUpTurn, runWorkflow: async () => { workflowRuns += 1; return structuredClone(options.workflow) as never; @@ -156,7 +161,7 @@ export async function runNatalAgent( }); const agent = getJyotishAgent({ id: `fake-${seq}`, model } as never, agentContext); const question = options.question ?? "我和父母关系如何"; - const baseMessages = [{ role: "user" as const, content: `${natalAnswerShapeInstruction()}\n问题:${question}` }]; + const baseMessages = [{ role: "user" as const, content: `${options.instruction ?? natalAnswerShapeInstruction()}\n问题:${question}` }]; const streamOptions = { runId: `run-${seq}`, maxSteps: AGENT_MAX_STEPS, abortSignal: clock.loopSignal }; const natalStreamOptions = { ...streamOptions, prepareStep: consultationNatalPrepareStep }; const continuations: unknown[] = []; diff --git a/frontend/tests/consult-single-pass-answer-20260927.test.ts b/frontend/tests/consult-single-pass-answer-20260927.test.ts index dbc972e0..47b5e115 100644 --- a/frontend/tests/consult-single-pass-answer-20260927.test.ts +++ b/frontend/tests/consult-single-pass-answer-20260927.test.ts @@ -60,12 +60,12 @@ const serverChart = { const PRE_TOOL = "我先排一下盘,稍等。"; const BETWEEN_TOOLS = "我再核对一次计算结果。"; const OPENER = "你这盘外面看着稳,底下其实一直在换跑道:表面求安定,底下要自己说了算。"; +// 原值: 夹具里有 `## ${REPORT_HEADING.question}` 一节 +// 新值: 该节删除,夹具从口语开场直接到 `## ${REPORT_HEADING.support}` +// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073:开场就是回答,正文不再从「先回答你的问题」重来 const ANSWER = [ OPENER, "", - `## ${REPORT_HEADING.question}`, - "能换,但先换做法,再换岗位。", - "", `## ${REPORT_HEADING.support}`, "月亮落在白羊座九宫,想法来得快。", "", @@ -429,7 +429,14 @@ test("the consult route has no separate compose stream and wires the single-pass const window = route.slice(route.indexOf("if (shouldRunDeclaredWindowWorkflow(consultationMode)) {"), route.indexOf("if (!prepared.serverChart)")); assert.match(window, /stepScopedAnswer: true,/); assert.match(window, /windowPacketMessage \? undefined : evidence/); - assert.match(route, /natalAnswerShapeInstruction\(\)/); + // 原值: assert.match(route, /natalAnswerShapeInstruction\(\)/) + // 新值: 用户轮形状经 natalUserTurnShape 按「会话已有回答」切换首轮 / 追问轮,思考栏与工具上下文带 followUpTurn + // 原因: TASK-consult-answer-the-question-20260927 D3/D6(BUG-1071) + assert.match(route, /natalUserTurnShape\(\{ entrypoint: consultEntrypoint, history \}\)/); + assert.match(route, /const followUpTurn = hasPriorAssistantAnswer\(history\);/); + assert.match(route, /followUp: followUpTurn,/); + assert.match(route, /followUpTurn,\n\s+serverChart: prepared\.serverChart,/); + assert.doesNotMatch(route, /natalAnswerShapeInstruction\(\)/); // One run clock: tools on the tool phase, the loop handed to the answer clock. assert.match(route, /const agentAbortSignal = runClock\.toolSignal;/); assert.match(route, /abortSignal: runClock\.loopSignal,/); diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index c774bbef..eb3a80ca 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -35,6 +35,7 @@ import { import { agentExecutionReceiptSchema } from "../src/lib/consultation-agent-events.ts"; import { consultationDomainIds, consultationDomainPlanValues } from "../src/lib/consultation-domain-registry.ts"; import { + OPENER_THINKING_TITLE, natalConsultationThinkingPlan, dailyConsultationThinkingPlan, DAILY_HEADING, @@ -2055,7 +2056,10 @@ test("a calculated run publishes thinking.section without tool ids", async () => title: string; heading: string; }>; - assert.ok(sections.some((section) => section.heading === REPORT_HEADING.question)); + // 原值: sections.some(heading === REPORT_HEADING.support) + // 新值: sections.some(title === OPENER_THINKING_TITLE);开场节没有自己的正文标题 + // 原因: TASK-consult-answer-the-question-20260927 D5/D8:开场节标题改「先回答你问的这件事」,heading 共用「盘里支持」 + assert.ok(sections.some((section) => section.title === OPENER_THINKING_TITLE)); assert.ok(sections.some((section) => section.heading === REPORT_HEADING.support)); assert.ok(JSON.stringify(sections).includes("事业") || JSON.stringify(sections).includes("财富")); assert.doesNotMatch(JSON.stringify(sections), /run-jyotish/); @@ -2067,12 +2071,14 @@ test("the loop's own text after the calculation is the answer, written once (BUG // 新值: 主循环在工具结果后写的正文就是回答,只写一次,不再有第二个流 // 原因: BUG-1053 实证被丢弃的那一步才看得到盘面证据,compose 看不到; // 产品 2026-09-27 决定删除 compose,保留主循环的正文。思考通道断言不变 + // 原值: 正文夹具标题 REPORT_HEADING.question;新值: REPORT_HEADING.support(下同,本文件共 5 处) + // 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073 删除「先回答你的问题」节;这些测试只需要任意一个二级标题 const state = toolOnlyRunState(); state.thinkingPlan = natalConsultationThinkingPlan({ domains: ["career", "wealth"] }); async function* loop() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "reasoning-delta", payload: { text: "The user asked about career." } }; - yield { type: "text-delta", payload: { text: `## ${REPORT_HEADING.question}\n外松内紧。\n` } }; + yield { type: "text-delta", payload: { text: `## ${REPORT_HEADING.support}\n外松内紧。\n` } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } let retries = 0; @@ -2092,7 +2098,7 @@ test("the loop's own text after the calculation is the answer, written once (BUG .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); - assert.equal(answer, `## ${REPORT_HEADING.question}\n外松内紧。\n`); + assert.equal(answer, `## ${REPORT_HEADING.support}\n外松内紧。\n`); assert.equal(events.some((event) => (event as { type?: string }).type === "think.plan"), true); assert.equal(events.filter((event) => (event as { type?: string }).type === "thinking.delta").length, 0); assert.doesNotMatch(JSON.stringify(events), /The user asked/); @@ -2188,7 +2194,7 @@ test("a length-cut answer step continues the same body with the calculation resu async function* pinched() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: evidence } }; yield { type: "step-finish", payload: { stepResult: { reason: "tool-calls" } } }; - yield { type: "text-delta", payload: { text: `## ${REPORT_HEADING.question}\n岁差` } }; + yield { type: "text-delta", payload: { text: `## ${REPORT_HEADING.support}\n岁差` } }; yield { type: "step-finish", payload: { stepResult: { reason: "length" } } }; yield { type: "finish", payload: { stepResult: { reason: "length" }, output: { usage: {}, steps: [{}, {}] } } }; } @@ -2198,7 +2204,7 @@ test("a length-cut answer step continues the same body with the calculation resu toolStatus: () => "ready", receipt: () => receipt(state), continueAfterLength: async (output, received) => { continues += 1; - assert.match(output, new RegExp(`## ${REPORT_HEADING.question}`)); + assert.match(output, new RegExp(`## ${REPORT_HEADING.support}`)); assert.deepEqual(received, evidence); async function* rest() { yield { type: "text-delta", payload: { text: " Lahiri。\n" } }; diff --git a/frontend/tests/consultation-run-timeline.test.ts b/frontend/tests/consultation-run-timeline.test.ts index 929ac8bb..09912704 100644 --- a/frontend/tests/consultation-run-timeline.test.ts +++ b/frontend/tests/consultation-run-timeline.test.ts @@ -31,18 +31,21 @@ test("career and wealth events append method, calculate 1/2, think, then the fir assert.equal(afterProgress.rows[1]?.status, "live"); assert.equal(afterProgress.rows[1]?.label, "正在计算本命盘(第 1/2 项)…"); + // 原值: 正文夹具 "## 先回答你的问题\n外松内紧。\n";思考行标签「先抓住你真正在问的事」;写作行 write-先回答你的问题 + // 新值: 正文夹具是口语开场 + "## 盘里支持这个判断的地方";思考行标签「先回答你问的这件事」;写作行 write-盘里支持这个判断的地方 + // 原因: TASK-consult-answer-the-question-20260927 D5/D8(BUG-1072/1073):步骤栏不再说「真正在问」,正文不再有「先回答你的问题」节 const state = reduceConsultationTimelineEvents([ { type: "thinking.section", ...question }, - { type: "answer.delta", text: "## 先回答你的问题\n外松内紧。\n" }, + { type: "answer.delta", text: "外松内紧。\n## 盘里支持这个判断的地方\n事业宫被土星压着。\n" }, ], afterProgress); assert.deepEqual(state.rows.map((row) => row.kind), ["method", "calculate", "think", "write"]); assert.equal(state.rows[1]?.status, "done"); - assert.equal(state.rows[2]?.label, "先抓住你真正在问的事"); + assert.equal(state.rows[2]?.label, "先回答你问的这件事"); assert.equal(state.rows[2]?.status, "done"); - assert.equal(state.rows[3]?.id, "write-先回答你的问题"); + assert.equal(state.rows[3]?.id, "write-盘里支持这个判断的地方"); assert.equal(state.rows[3]?.status, "live"); - assert.match(state.rows[3]?.label ?? "", /正在写先回答你的问题/); + assert.match(state.rows[3]?.label ?? "", /正在写盘里支持这个判断的地方/); }); test("thinking after a tool starts a new think row instead of reopening the first", () => { @@ -94,7 +97,8 @@ test("idle chat without a natal calculation has no calculate row", () => { test("settled natal messages hydrate a calculate row from domain sections", () => { const sections = natalConsultationThinkingPlan({ domains: ["career", "wealth"] }); const rows = consultationTimelineFromSettled({ - text: "## 先回答你的问题\n外松内紧。\n## 盘里支持这个判断的地方\n方向稳定。\n", + // 原值: 正文以 "## 先回答你的问题" 开头;新值: 口语开场无标题,再接「盘里支持」(D8) + text: "外松内紧。\n## 盘里支持这个判断的地方\n方向稳定。\n", thinkingSections: sections, thinkingText: "先对照 D10。", }); diff --git a/frontend/tests/consultation-smalltalk.test.ts b/frontend/tests/consultation-smalltalk.test.ts index e71b7cc0..43fdbaa4 100644 --- a/frontend/tests/consultation-smalltalk.test.ts +++ b/frontend/tests/consultation-smalltalk.test.ts @@ -228,7 +228,11 @@ test("route gates all special entries before a single selected-model classifier test("all three agent voices share the exemption, natal domain skeleton stays mandatory", () => { assert.match(productConversationVoice, /只是打招呼、道谢、告别时,回一句话,不要套开场形状/); assert.doesNotMatch(natalSpokenReportContract, /Short chit-chat/); - assert.match(natalSpokenReportContract, /One natal career\/wealth\/marriage\/family question still uses the opener-plus-skeleton/); + // 原值: /One natal career\/wealth\/marriage\/family question still uses the opener-plus-skeleton/ + // 新值: 首轮仍用开场 + 骨架;追问轮按用户轮里的指令直接回答(骨架对首轮仍是强制的) + // 原因: TASK-consult-answer-the-question-20260927 D3 / BUG-1071:追问轮不再重开整套骨架 + assert.match(natalSpokenReportContract, /The first natal career\/wealth\/marriage\/family answer of a session uses the opener-plus-skeleton above/); + assert.match(natalSpokenReportContract, /a follow-up turn follows the shape instruction carried in the user turn/); const index = source("../src/mastra/index.ts"); assert.ok((index.match(/\$\{productConversationVoice\}/g) ?? []).length >= 3); }); diff --git a/frontend/tests/consultation-thinking-plan.test.ts b/frontend/tests/consultation-thinking-plan.test.ts index f2432aa4..b64af2c8 100644 --- a/frontend/tests/consultation-thinking-plan.test.ts +++ b/frontend/tests/consultation-thinking-plan.test.ts @@ -9,13 +9,18 @@ import { consultationSpokenHeadingRule, dailyAnswerShapeInstruction, dailyConsultationThinkingPlan, + followUpAnswerShapeInstruction, + hasPriorAssistantAnswer, natalAnswerShapeInstruction, + natalUserTurnShape, DAILY_HEADING, natalConsultationThinkingPlan, + OPENER_THINKING_TITLE, REPORT_HEADING, splitAnswerByHeadings, thinkPlanSteps, visibleThinkingSteps, + WINDOW_OPEN_HEADING, windowConsultationThinkingPlan, } from "../src/lib/consultation-thinking-plan.ts"; @@ -34,12 +39,19 @@ test("multi-domain thinking plan uses Chinese product labels and hides tool ids" mustUseLayers: ["D1", "run-jyotish-consultation", "D10", "skill_read"], }); const encoded = JSON.stringify(sections); + // 原值: 四节 heading = [question, support, timing, action] + // 新值: 开场节的 heading 与「盘里支持」共用(开场没有自己的标题,第一个正文 H2 出现即算写完);正文三节 + // 原因: TASK-consult-answer-the-question-20260927 D5/D8(BUG-1072/1073):删「先回答你的问题」节,开场节标题改「先回答你问的这件事」 assert.deepEqual(sections.map((section) => section.heading), [ - REPORT_HEADING.question, + REPORT_HEADING.support, REPORT_HEADING.support, REPORT_HEADING.timing, REPORT_HEADING.action, ]); + assert.equal(sections[0]?.title, OPENER_THINKING_TITLE); + assert.equal(sections[0]?.title, "先回答你问的这件事"); + assert.equal(JSON.stringify(sections).includes("先抓住你真正在问的事"), false); + assert.equal(sections[0]?.steps[0]?.label, "用盘上的反差回答这个问题"); assert.ok(sections.some((section) => section.id === "support")); assert.ok(JSON.stringify(sections).includes("事业")); assert.equal(encoded.includes("run-jyotish"), false); @@ -49,7 +61,10 @@ test("multi-domain thinking plan uses Chinese product labels and hides tool ids" test("window thinking plan stays on stable-layer copy", () => { const [section] = windowConsultationThinkingPlan(); - assert.equal(section?.heading, REPORT_HEADING.question); + // 原值: REPORT_HEADING.question;新值: WINDOW_OPEN_HEADING(同一字面「先回答你的问题」) + // 原因: 本命正文标题表删掉了 question;申报窗口模式不在本单范围,标题字面不变 + assert.equal(section?.heading, WINDOW_OPEN_HEADING); + assert.equal(section?.heading, "先回答你的问题"); assert.equal(JSON.stringify(section).includes("run-jyotish"), false); }); @@ -66,8 +81,10 @@ test("visible steps collapse extras into a remaining count", () => { test("answer split follows the required heading order", () => { const headings = consultationReportHeadings(["career", "wealth"]); + // 原值: 四个标题,首节 question;新值: 三个标题,口语开场留在 preamble + // 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073 + assert.deepEqual(headings, [REPORT_HEADING.support, REPORT_HEADING.timing, REPORT_HEADING.action]); const text = [ - `## ${REPORT_HEADING.question}`, "外松内紧。", `## ${REPORT_HEADING.support}`, "事业宫被土星压着。", @@ -76,8 +93,8 @@ test("answer split follows the required heading order", () => { `## ${REPORT_HEADING.action}`, "这周只把合同条款写清楚。", ].join("\n"); - const { slices } = splitAnswerByHeadings(text, headings); - assert.match(slices[REPORT_HEADING.question] ?? "", /外松内紧/); + const { preamble, slices } = splitAnswerByHeadings(text, headings); + assert.match(preamble, /外松内紧/); assert.match(slices[REPORT_HEADING.support] ?? "", /土星/); assert.match(slices[REPORT_HEADING.timing] ?? "", /大运/); assert.match(slices[REPORT_HEADING.action] ?? "", /合同条款/); @@ -95,14 +112,25 @@ test("settled answers mark leftover thinking sections done", () => { test("progress marks the current heading active and completed ones done", () => { const sections = natalConsultationThinkingPlan({ domains: ["career"] }); - const progressed = applyThinkingSectionProgress(sections, `## ${REPORT_HEADING.question}\n外松内紧。\n`); + // 原值: 写出 `## 先回答你的问题` → 首节 done、第二节 active + // 新值: 口语开场还没有标题时首节 active;写出 `## 盘里支持` 后开场节与依据节都 done、时间节 active + // 原因: 开场没有自己的 H2,它与依据节共用标题(D8) + const opening = applyThinkingSectionProgress(sections, "外松内紧。\n"); + assert.equal(opening[0]?.steps[0]?.status, "active"); + assert.equal(opening[1]?.steps[0]?.status, "pending"); + const progressed = applyThinkingSectionProgress(sections, `外松内紧。\n## ${REPORT_HEADING.support}\n事业宫被土星压着。\n`); assert.equal(progressed[0]?.steps[0]?.status, "done"); - assert.equal(progressed[1]?.steps[0]?.status, "active"); + assert.equal(progressed[1]?.steps[0]?.status, "done"); + assert.equal(progressed[2]?.steps[0]?.status, "active"); }); test("continue prompt asks to resume after the last complete heading", () => { const prompt = consultationContinuePrompt("## 事业\n方向稳定。\n"); assert.match(prompt, /最后一个完整标题是:## 事业/); + // 续写的标题清单跟着正文三标题走,不再让续写补出「先回答你的问题」(D8)。 + assert.doesNotMatch(prompt, /## 先回答你的问题、/); + assert.match(prompt, /不要写「先回答你的问题」这个标题/); + assert.match(prompt, new RegExp(`## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}`)); assert.match(prompt, /不要重复已写出的段落/); assert.match(prompt, /不要写思考过程清单/); }); @@ -127,7 +155,17 @@ test("section prompt asks for one heading and forbids another calculation", () = const natal = natalAnswerShapeInstruction(); assert.match(natal, /只用结果里的盘面事实/); assert.match(natal, /开场不要标题/); + // 原值: 一次写完四个标题(含 ## 先回答你的问题) + // 新值: 三个标题;开场不写行动、行动只在最后一节出现一次、全文不超过 900 字、不写「先回答你的问题」 + // 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073 for (const heading of Object.values(REPORT_HEADING)) assert.ok(natal.includes(`## ${heading}`)); + assert.equal(Object.values(REPORT_HEADING).length, 3); + assert.match(natal, /这三个二级标题/); + assert.match(natal, /开场不写行动清单/); + assert.match(natal, /只在这里出现一次/); + assert.match(natal, /全文不超过 900 字/); + assert.match(natal, /不要写「先回答你的问题」这个标题/); + assert.match(natal, /反差只写盘上的结构/); assert.match(natal, /不要写「统一参数与原始结构」/); assert.match(natal, /不要写技法审计表/); assert.doesNotMatch(natal, /不要再调用排盘工具/, "the loop still has to call the tool before writing"); @@ -166,10 +204,50 @@ test("empty-retry section prompt asks to write the heading directly", () => { assert.match(daily, new RegExp(`${DAILY_HEADING.trend}、${DAILY_HEADING.actAvoid.replace(/[/]/g, "\\/")}、${DAILY_HEADING.action}`)); assert.match(daily, /探索性日提示,不是确定预测/); const base = [{ role: "user" as const, content: "问题" }]; - const withEvidence = consultationContinueMessages(base, `## ${REPORT_HEADING.question}\n岁差`, { moon: 3.2259 }); + // 原值: 已写正文夹具用 REPORT_HEADING.question;新值: REPORT_HEADING.support(只需任意一个标题) + // 原因: TASK-consult-answer-the-question-20260927 D8 删除该标题 + const withEvidence = consultationContinueMessages(base, `## ${REPORT_HEADING.support}\n岁差`, { moon: 3.2259 }); assert.equal(withEvidence.length, 4); assert.match(String(withEvidence[1]?.content), /本轮服务器计算结果[\s\S]*3\.2259/); assert.equal(withEvidence[2]?.role, "assistant"); - assert.equal(withEvidence[3]?.content, consultationContinuePrompt(`## ${REPORT_HEADING.question}\n岁差`)); + assert.equal(withEvidence[3]?.content, consultationContinuePrompt(`## ${REPORT_HEADING.support}\n岁差`)); assert.equal(consultationContinueMessages(base, "x").length, 3, "no evidence, no evidence message"); }); + +test("a follow-up turn answers directly and the thinking bar says so (TASK-consult-answer-the-question-20260927 T2/T4)", () => { + const followUp = followUpAnswerShapeInstruction(); + assert.match(followUp, /不超过 200 字/); + assert.match(followUp, /第一句就回答用户这句话/); + assert.match(followUp, /是非题先答「是」「不是」或「看情况:……」/); + assert.match(followUp, /不写二级标题/); + assert.match(followUp, /不重述上一轮已经写过的结构/); + assert.match(followUp, /不解释用户为什么问/); + assert.doesNotMatch(followUp, /这三个二级标题/, "the follow-up turn never asks for the first-turn skeleton"); + + // The server decides follow-up from the stored history, not from the question (D6). + assert.equal(hasPriorAssistantAnswer([]), false); + assert.equal(hasPriorAssistantAnswer([{ role: "user", text: "我和父母的亲疏关系如何" }]), false); + assert.equal(hasPriorAssistantAnswer([{ role: "assistant", text: " " }]), false); + assert.equal(hasPriorAssistantAnswer([ + { role: "user", text: "我和父母的亲疏关系如何" }, + { role: "assistant", text: "外顺内远。" }, + ]), true); + + // ① empty history → first-turn shape; ② prior answer → follow-up; ④ daily is untouched by history. + const first = natalUserTurnShape({ history: [] }); + assert.equal(first, natalAnswerShapeInstruction()); + const later = natalUserTurnShape({ history: [{ role: "user", text: "问" }, { role: "assistant", text: "答" }] }); + assert.equal(later, followUpAnswerShapeInstruction()); + assert.equal(natalUserTurnShape({ entrypoint: "daily_starlanguage", history: [] }), dailyAnswerShapeInstruction()); + assert.equal( + natalUserTurnShape({ entrypoint: "daily_starlanguage", history: [{ role: "assistant", text: "答" }] }), + dailyAnswerShapeInstruction(), + ); + + const plan = natalConsultationThinkingPlan({ domains: ["parents"], followUp: true }); + assert.equal(plan[0]?.title, OPENER_THINKING_TITLE); + assert.equal(plan[0]?.steps[0]?.label, "直接回答这句话"); + assert.deepEqual(thinkPlanSteps(plan).map((step) => step.id), ["question", "support", "timing", "action"]); + const settled = applyThinkingSectionProgress(plan, "不是。4 宫主落 10 宫,她把心力投在你的前途上。", { settled: true }); + assert.equal(settled.every((section) => section.steps.every((step) => step.status === "done")), true); +}); diff --git a/frontend/tests/consultation-voice-contract.test.ts b/frontend/tests/consultation-voice-contract.test.ts index 010c9d9e..a746e7ae 100644 --- a/frontend/tests/consultation-voice-contract.test.ts +++ b/frontend/tests/consultation-voice-contract.test.ts @@ -51,7 +51,22 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton" assert.doesNotMatch(mastra, /must_use_layers is the executed shortlist|Use every executed layer/); assert.match(mastra, /do not invent it from model knowledge/); assert.match(mastra, /search_period is the searched observation window/); - assert.match(voice, /## 先回答你的问题/); + // 原值: assert.match(voice, /## 先回答你的问题/) + // 新值: 正文只有三个标题,「先回答你的问题」不再出现在合同里;开场不带行动、行动只在最后一节、首轮 ≤ 900 字 + // 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073:开场就是回答,同一判断不再说三遍 + assert.doesNotMatch(voice, /## 先回答你的问题/); + assert.match(voice, /## 盘里支持这个判断的地方, then ## 时间怎么看, then ## 这周可以做的一件事/); + assert.match(voice, /The opener carries no action list/); + assert.match(voice, /Say each thing once \(D8\)/); + assert.match(voice, /First-turn total ≤ 900 Chinese characters/); + assert.match(voice, /cite only the fields that support this judgment, and only what the opener did not already say/); + assert.match(voice, /One to three actions for this week, here and nowhere else/); + assert.doesNotMatch(voice, /One natal career\/wealth\/marriage\/family question still uses the opener-plus-skeleton above/); + assert.match(voice, /a follow-up turn follows the shape instruction carried in the user turn/); + assert.match(mastra, /each thing said once, first-turn total ≤ 900 characters/); + assert.match(mastra, /The checklist is a list of things to check, not a list of paragraphs to write/); + assert.match(binding, /no action list in the opener/); + assert.match(binding, /the actions, once/); assert.match(mastra, /consultationSpokenHeadingRule\("natal"\)/); assert.match(workflow, /template: "skill_level_2"/); assert.match(workflow, /raman_six_step/); @@ -116,8 +131,12 @@ test("persona is the sharp-but-reliable colleague, and hope never becomes a plat assert.match(voice, /把对面的人当一个人认真对待/); assert.match(voice, /嘴有点毒但靠谱的同事/); assert.match(voice, /毒只对处境,不对人/); - // 含糊问题不猜、不反问一串:给默认解读再作答。 - assert.match(voice, /这句我按『……』理解了,不对你纠正我/); + // 原值: assert.match(voice, /这句我按『……』理解了,不对你纠正我/) + // 新值: 该规则删除;只有两种实质不同读法时才写「我按 A 答」,单义问题直接答、不改写 + // 原因: TASK-consult-answer-the-question-20260927 D4 / BUG-1070:这条规则让模型先改写问题再答(「是不是 X」→「你是否需要 Y」) + assert.doesNotMatch(voice, /这句我按『……』理解了/); + assert.match(voice, /只有当这句话有两种实质不同的读法时,才写一句「我按 A 答」/); + assert.match(voice, /单义的问题直接答,不改写它/); assert.match(voice, /HOPE DISCIPLINE/); assert.match(voice, /answer_policy\.can_answer_precise_timing 允许精确应期时/); assert.match(voice, /不是每个人都有转机,但每个人都有能扮演的象/); @@ -144,3 +163,27 @@ test("the pinned Good example carries the new shape; the old consultant-report l assert.ok(badLine, "旧 Good 例必须改挂在 Bad 上"); assert.match(badLine!, /像顾问报告:没有反差,没说谁在推谁在修,没有可扮演的象/); }); + +test("the voice answers the sentence asked: no motive guessing, yes/no first, contrast on the chart (BUG-1070)", () => { + const forbidden = ["你其实想问的是", "你真正在意的是", "你要的是", "你是否需要", "你问这个是因为", "你更想知道"]; + const discipline = voice.slice(voice.indexOf("不揣测(回答对象是用户问的那句话)"), voice.indexOf("Five principles")); + assert.ok(discipline.length > 0, "the no-guessing block sits in the shared voice before the principles"); + for (const phrase of forbidden) assert.ok(discipline.includes(`「${phrase}」`), phrase); + assert.match(discipline, /任何替用户或当事人陈述动机、需求、感受的句子/); + assert.match(discipline, /不得写「她问的是结果,你要的是被听」/); + assert.match(discipline, /是非题(是不是 \/ 会不会 \/ 能不能 \/ 有没有 \/ ……吗):第一句就是「是」「不是」或「看情况:……」/); + // The contrast is a chart structure, never the person's mind (D2). + assert.match(voice, /A 和 B 都必须是盘上的结构——哪颗星、哪个宫、哪段大运、哪张分盘/); + assert.match(voice, /反差写那个人在你盘上对应的宫位与代表星,不写那个人的心思/); + // Actions stay in the opener only where there is no body skeleton. + assert.match(voice, /本命解读里这三条不写在开场,只写在正文最后一节「这周可以做的一件事」/); + assert.match(voice, /无出生分钟与申报时段没有正文骨架,行动就写在开场结尾/); + // Yes/no Good and Bad pair, fictional chart. + assert.match(voice, /Good \(是非题,用户问「我妈是不是不太在意我」/); + assert.match(voice, /不是。4 宫主水星逆行落 10 宫/); + assert.match(voice, /Bad \(同一问题\): 你其实要的是被听见/); + assert.match(voice, /State what the user or the person asked about wants, needs, or feels; state where the chart puts them/); + // The three-mode shared voice still carries all of it (general and window expand the same literal). + const sharedVoice = voice.slice(0, voice.indexOf("export const natalSpokenReportContract")); + assert.ok(sharedVoice.includes("不揣测(回答对象是用户问的那句话)")); +});