From b991c54955abcfd0ff4ae92c1f392c0910274f7c Mon Sep 17 00:00:00 2001 From: imrewce Date: Tue, 13 Oct 2026 10:45:11 +0800 Subject: [PATCH] fix(proactive): stop anchoring openers on one stock lead-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both prompt languages told the model to open the way a friend "happens to mention" something, and the Chinese one even handed it "对了,上次那个……" as the example, so nearly every card came back with the same two characters in front - a script rather than a person. The deterministic fallback did the same for reminders, the one kind that always reaches today's agenda. - plan.yaml: name the stock lead-in as a second forbidden failure mode next to the notification tone, list the angles to rotate through (the date or the clash, what moved since last time, the deliverable, a direct question, the user's current focus), require that no two openers in a batch share their opening words, and restate it as a hard rule - plan.py: three opener variants per kind in FALLBACK_OPENERS, picked by a sha1 of the topic id, so the no-LLM path varies card to card while one topic keeps the same opener across rounds; every reminder variant still carries the due date, which a reminder may not hide --- reme/steps/evolve/proactive/plan.py | 58 +++++++++++++++++++++++---- reme/steps/evolve/proactive/plan.yaml | 34 +++++++++++----- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/reme/steps/evolve/proactive/plan.py b/reme/steps/evolve/proactive/plan.py index 38dea621..6b19c177 100644 --- a/reme/steps/evolve/proactive/plan.py +++ b/reme/steps/evolve/proactive/plan.py @@ -9,7 +9,8 @@ gets a deterministic fallback card so the agenda step never sees an empty input. Card contract: ``scenario_type`` (resume_task | answer_pending | explore_interest | prepare_upcoming | propose_delegation), ``opener`` (a casual, friend-like conversation opener that already names the minimal next action - -never a "based on your records" notification tone), ``next_action``, +never a "based on your records" notification tone, and never a stock lead-in +worn into a template), ``next_action``, ``preconditions`` and ``delivery`` (in_conversation | notification | agenda_item). ``linked_memory`` is derived from the topic's paths/evidence by code, never by the LLM. @@ -20,6 +21,7 @@ pins every reminder card it receives. """ import asyncio +import hashlib import json import time @@ -44,6 +46,33 @@ MAX_NEXT_ACTION_CHARS = 200 MAX_PRECONDITIONS = 5 MAX_PRECONDITION_CHARS = 80 +# Deterministic openers, several per kind, so a batch of fallback cards does not +# open every line the same way - one repeated lead-in reads as a script. ``{when}`` +# is the parenthesised due date (empty when unknown) and only reminder variants +# carry it, because a reminder may not hide when it is due. +FALLBACK_OPENERS = { + "reminder": ( + "「{title}」{when}就快到了,要不要现在把准备事项先过一遍?", + "时间快到了:「{title}」{when},先确认下会不会和别的安排撞上?", + "有件事排在前面了:「{title}」{when},现在动手准备最从容。", + ), + "new_idea": ( + "有件活儿我觉得可以替你办:「{title}」,要不要我先出个方案你看看?", + "「{title}」这件事其实可以直接交给我,要不要我先出一版草案给你审?", + "这活儿不必你自己来:「{title}」,我先跑个初稿你看看值不值?", + ), + "follow_up": ( + "上次聊到「{title}」,好像还没收尾——要不要趁现在花几分钟往前推一步?", + "「{title}」还挂着,要不要我先去查一下最新进展,再回来跟你说下一步?", + "那件「{title}」好像只差临门一脚了,现在顺手推一下?", + ), + "interest_extend": ( + "感觉你最近可能会想看看「{title}」,有空的话可以先从相关材料扫一眼。", + "「{title}」这块还能再往深挖一层,要不要我挑两处给你展开讲讲?", + "顺着「{title}」又翻到些延伸内容,值得的话我带你过一遍?", + ), +} + def linked_memory(candidate: dict) -> list[str]: """Workspace paths related to a topic, derived from paths + evidence.""" @@ -60,23 +89,22 @@ def linked_memory(candidate: dict) -> list[str]: def fallback_card(candidate: dict) -> dict: - """Deterministic card used when the LLM is absent or its output is unusable.""" + """Deterministic card used when the LLM is absent or its output is unusable. + + Its opener is picked per topic out of ``FALLBACK_OPENERS``, so the + no-LLM path honours the same "no repeated lead-in" rule the prompt states. + """ kind = str(candidate.get("kind") or DEFAULT_KIND) title = str(candidate.get("title") or "") scenario = DEFAULT_SCENARIO_BY_KIND.get(kind, DEFAULT_SCENARIO_BY_KIND[DEFAULT_KIND]) + opener = _fallback_opener(kind, candidate, title) if kind == "reminder": - due = str(candidate.get("due_at") or "").strip() - when = f"({due})" if due else "" - opener = f"对了,「{title}」{when}就快到了,要不要现在把准备事项先过一遍?" next_action = "确认时间、所需材料与是否和其它安排冲突,需要改期就现在说" elif kind == "new_idea": - opener = f"有件活儿我觉得可以替你办:「{title}」,要不要我先出个方案你看看?" next_action = "确认值不值得委托;值得的话由 agent 先产出一版草案供你审阅" elif kind == "follow_up": - opener = f"上次聊到「{title}」,好像还没收尾——要不要趁现在花几分钟往前推一步?" next_action = "先回顾相关记录,确认卡点,然后给出下一步动作" else: - opener = f"感觉你最近可能会想看看「{title}」,有空的话可以先从相关材料扫一眼。" next_action = "快速浏览相关材料,判断值不值得深入" memory = linked_memory(candidate) if memory: @@ -90,6 +118,20 @@ def fallback_card(candidate: dict) -> dict: } +def _fallback_opener(kind: str, candidate: dict, title: str) -> str: + """Pick one opener variant for this kind, seeded by the topic identity. + + The seed is the topic id, so one topic keeps one opener across rounds + instead of flip-flopping, while different topics in the same round land on + different variants and the agenda does not read as one repeated sentence. + """ + variants = FALLBACK_OPENERS.get(kind) or FALLBACK_OPENERS[DEFAULT_KIND] + seed = str(candidate.get("id") or title) + picked = variants[int(hashlib.sha1(seed.encode("utf-8")).hexdigest(), 16) % len(variants)] + due = str(candidate.get("due_at") or "").strip() + return picked.format(title=title, when=f"({due})" if due else "") + + def _clean_card(raw: dict, candidate: dict) -> dict: """Validate one LLM card; per-field fallback keeps the card always usable.""" base = fallback_card(candidate) diff --git a/reme/steps/evolve/proactive/plan.yaml b/reme/steps/evolve/proactive/plan.yaml index bb634fbf..bad07b6e 100644 --- a/reme/steps/evolve/proactive/plan.yaml +++ b/reme/steps/evolve/proactive/plan.yaml @@ -23,11 +23,19 @@ plan_system_prompt: | resolve when the topic mentions one); - `new_idea` -> propose_delegation, naming the deliverable the agent would produce - an offer, never a report of work already done. - - `opener`: one casual spoken sentence, like a friend happening to mention - it ("by the way..."). STRICTLY forbidden: notification tone such as - "based on your previous records", "the system reminds you", "as you - mentioned on ". The opener must already point at the minimal - executable next action, so it invites doing, not just knowing. + - `opener`: one casual spoken sentence a friend would actually say. Two + failure modes are STRICTLY forbidden: + 1. Notification tone: "based on your previous records", "the system + reminds you", "as you mentioned on ". + 2. A stock lead-in worn into a template. Never open with "by the way..." + or any other fixed filler ("just a reminder...", "quick note..."): + the same first words every day read as a script, not as a person. + Start from the substance instead and change the angle card by card - the + date or the clash, what moved since last time, the deliverable the agent + would produce, a direct question, or whatever the user is working on right + now. Inside one batch no two openers may share their opening words. + The opener must already point at the minimal executable next action, so it + invites doing, not just knowing. A reminder opener still sounds like a person, but it may not hide the date: the user has to hear when it is due. - `next_action`: the smallest concrete step the user can start right now. @@ -42,6 +50,7 @@ plan_system_prompt: | - Exactly one card per input topic, no extras. - Ground everything in the given reason/evidence; never invent facts. - Proposals stay proposals: never word a card as if the work were done. + - Openers vary: one lead-in formula per batch is a defect, not a style. - Quote YAML strings containing punctuation such as `:`. - Write `opener` and `next_action` in the language the user's own material is written in, so the assistant never switches tongues mid-conversation. @@ -53,7 +62,7 @@ plan_system_prompt: | cards: - topic_id: scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming|propose_delegation - opener: + opener: next_action: preconditions: [, ...] delivery: in_conversation|notification|agenda_item @@ -82,8 +91,14 @@ plan_system_prompt_zh: | 点明要先协调哪一件事; - `new_idea` -> propose_delegation,说清 agent 会交付什么——是邀约, 绝不能写成已经做完的汇报。 - - `opener`:一句口语化的开场,像朋友随口提起("对了,上次那个……")。 - 严禁通知腔:"根据您之前的记录"、"系统提醒您"、"您在某天提到过"等。 + - `opener`:一句朋友真会说出口的口语化开场。以下两种写法都严禁: + 1. 通知腔:"根据您之前的记录"、"系统提醒您"、"您在某天提到过"等; + 2. 把某个口头禅当模板反复套用。不要一律以"对了……"开头,也不要换成 + "顺便说一下"、"提醒您一下"之类别的固定引子——每天都是同一句开头, + 就成了脚本,不像人。 + 直接从内容本身切入,并且逐卡换角度:先说时间点或撞期、先说上次之后有 + 什么变化、先说 agent 会交付什么、直接抛一个问题,或者从用户眼下正在做 + 的事接过去。同一批卡片里,任意两张开场的开头用词都不得相同。 开场必须顺势给出最小可执行的下一步,让人愿意动手,而不只是知道。 reminder 的开场同样要像人话,但不能藏时间:用户必须听到"什么时候到期"。 - `next_action`:用户现在就能开始的最小具体动作。 @@ -98,6 +113,7 @@ plan_system_prompt_zh: | - 每个输入主题恰好一张卡,不得多给。 - 所有内容必须基于给定的 reason/evidence,禁止编造事实。 - 提议就是提议:任何卡片都不得写成工作已经完成。 + - 开场白必须各不相同:整批共用一个引子是缺陷,不是风格。 - 包含 `:` 等标点的 YAML 字符串请加引号。 - `opener` 与 `next_action` 使用用户素材本身所用的语言书写,避免助手在对话 中途切换语言。 @@ -109,7 +125,7 @@ plan_system_prompt_zh: | cards: - topic_id: <回显的 id> scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming|propose_delegation - opener: <一句口语化开场,顺势给出下一步动作> + opener: <一句口语化开场,直接从内容切入,顺势给出下一步动作> next_action: <最小可执行步骤> preconditions: [<条件>, ...] delivery: in_conversation|notification|agenda_item