fix(proactive): stop anchoring openers on one stock lead-in

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
This commit is contained in:
imrewce 2026-10-13 10:45:11 +08:00
parent 73a106dc53
commit b991c54955
2 changed files with 75 additions and 17 deletions

View file

@ -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)

View file

@ -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 <date>". 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 <date>".
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: <echoed id>
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming|propose_delegation
opener: <one casual sentence pointing at the next action>
opener: <one casual sentence, substance first, pointing at the next action>
next_action: <smallest executable step>
preconditions: [<condition>, ...]
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