Skip to content

feat(dmn): 热度图命令与调度可靠性修复 (0.9.0) - #9

Merged
JohnRichard4096 merged 8 commits into
mainfrom
feat/dmn-heatmap
Nov 6, 2026
Merged

JohnRichard4096 merged 8 commits into
mainfrom
feat/dmn-heatmap

Conversation

@AmriaLin

@AmriaLin AmriaLin commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

改动

1. /memory heatmap 热度图命令

  • 新增 rethinking/heatmap.py:Pillow 渲染 7×12 作息热度图(纯渲染,不依赖 nonebot,可脱离运行时出图核对)。
  • 格内数字是落盘时点的累计计数,颜色按半衰期折算到当前时刻;当前时槽用暖色点标记,规划窗口用金框高亮。
  • 图下方面板给出下次 DMN 运行时间(含「几小时后」相对时间)、窗口区间、时长、分层、penalty 与备注。
  • 缺中文字体时自动退回英文标签(可用 AMRITA_MEMORY_HEATMAP_FONT 指定字体),不会出豆腐块。
  • 命令别名 heat / 热图 / 热度图,隐藏且不进菜单,鉴权与 /memory dmn 一致(超级用户 → lp.admin)。
  • 出图异常时退回纯文本状态,不影响查看下次运行时间。

2. 调度可靠性(审下来会「静默不跑」的几处)

问题 后果 处理
misfire_grace_time=30 事件循环被同步 IO 卡住超过 30 秒,这次运行被 APScheduler 直接丢弃 置空(None)+ 显式 coalesce/max_instances=1
待执行任务只存在内存里 重启后计划消失,DMN 要等用户再开口才恢复 触发时刻随状态落盘(pending_run_ts),启动时 restore_schedule() 补排;停机错过的窗口 1 分钟内补跑
单轮抛异常后没有任何后续 一次异常就让 DMN 停摆到用户下次聊天 失败按 30 分钟重试,最多 3 次;用户发消息重置计数
周期内部异常被吞掉仍算「跑完」 失败被当成成功,惩罚被清零 _cycle_failed 标记,失败不重置 _fail_streak

刻意没有加定时自转:没有待执行任务时依旧等用户消息触发,避免无人使用时 DMN 无限跑烧 token。

验证

  • uv run ruff check / uv run ruff format --check 通过
  • pyright --pythonpath .venv/bin/python amrita_plugin_memory 0 error
  • 改到的模块 py_compile 通过
  • 出图冒烟:20 天模拟作息 → 周六 03:00-07:45 (285min, tier=mid, relax=0),PNG 46KB;无待执行任务路径、缺字体回退路径均可正常出图(英文标签)
  • 调度行为验证:用 AsyncIOScheduler 复现「run_date 已过 5 分钟」,旧配置会被丢弃,misfire_grace_time=None 下正常补跑

注:/memory heatmap 需要在真实 Bot 里手动跑一次确认图片与文案,PR 里未包含运行时截图。

Summary by Sourcery

Add a DMN activity heatmap and make scheduled memory consolidation resilient across delays, restarts, and transient failures.

New Features:

  • Add the hidden /memory heatmap command and aliases to render a 7×12 activity heatmap with current status and upcoming DMN schedule details.

Bug Fixes:

  • Prevent scheduled DMN runs from being silently dropped when the event loop is delayed, restore pending runs after restart, retry failed cycles, and preserve failure state for penalty handling.

Enhancements:

  • Persist pending schedule metadata and expose scheduler state for status reporting while continuing to avoid autonomous runs when no pending task exists.

Build:

  • Bump the package version to 0.9.0 and add Pillow as a runtime dependency.

Documentation:

  • Document the heatmap command and improved DMN scheduling reliability.

- rethinking/heatmap.py:Pillow 渲染 7×12 作息热度图,格内标累计计数、颜色按半衰期
  折算到当前时刻,并标出当前时槽与下次运行窗口;缺中文字体时自动退回英文标签
- /memory heatmap(别名 heat、热图、热度图):隐藏子命令,鉴权同 /memory dmn,
  出图失败退回文本状态,不影响查看
- 调度:misfire_grace_time 置空并显式 coalesce/max_instances,事件循环被卡住时
  这次运行不再被静默丢弃
- 调度:待执行任务的触发时刻随状态落盘,重启后补排;停机期间错过的窗口 1 分钟内补跑
- 调度:单轮失败按 30 分钟间隔重试,最多 3 次,用户发消息会重置计数
- 依赖新增 pillow>=10.1
@sourcery-ai

sourcery-ai Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

本 PR 新增可脱离 NoneBot 渲染的 /memory heatmap 热度图及其状态展示,同时通过持久化待执行时间、取消过短 misfire 宽限、启动恢复和受控重试,提升 DMN 调度在重启、事件循环阻塞及单轮异常场景下的可靠性,并升级至 0.9.0。

Sequence diagram for DMN schedule restoration and retry

sequenceDiagram
    participant User
    participant Runner
    participant StateStore
    participant Scheduler

    Runner->>StateStore: _load_state()
    StateStore-->>Runner: pending_run_ts
    Runner->>Runner: restore_schedule()
    Runner->>Scheduler: _schedule_once(delay_seconds)
    Scheduler-->>Runner: restored pending job
    Scheduler->>Runner: _run()
    Runner->>Runner: _run_cycle()
    alt cycle succeeds
        Runner->>StateStore: _save_state_locked()
    else cycle fails
        Runner->>Runner: _schedule_retry()
        Runner->>Scheduler: _schedule_once(30 minutes)
    end
    User->>Runner: cancel_and_reschedule()
    Runner->>Runner: reset _fail_streak
Loading

State diagram for reliable DMN execution

stateDiagram-v2
    [*] --> WaitingForUser
    WaitingForUser --> Scheduled: cancel_and_reschedule()
    Scheduled --> Running: _run()
    Running --> WaitingForUser: successful cycle
    Running --> RetryScheduled: cycle failure
    RetryScheduled --> Running: _schedule_retry()
    RetryScheduled --> WaitingForUser: 3 retries exhausted
    Scheduled --> Scheduled: restore_schedule()
    Running --> WaitingForUser: user message cancels cycle
Loading

Flow diagram for the memory heatmap command

flowchart TD
    A["/memory heatmap"] --> B{管理员鉴权}
    B -->|失败| C["未知选项"]
    B -->|通过| D{subconscious.enabled}
    D -->|否| E["返回调度未启用"]
    D -->|是| F["获取 Runner 快照"]
    F --> G["_heatmap_caption"]
    F --> H["render_heatmap_png"]
    H -->|成功| I["发送 PNG 图片"]
    H -->|异常| J["记录错误并发送纯文本状态"]
    I --> K["发送下次运行时间与窗口状态"]
Loading

File-Level Changes

Change Details Files
新增独立的 7×12 作息热度图渲染与隐藏命令入口。
  • 使用 Pillow 根据历史观测计数和半衰期权重绘制热度网格,并标记当前时槽及计划窗口。
  • 在图像面板和命令文本中展示下次 DMN 运行时间、相对等待时间、窗口详情、分层与 penalty。
  • 通过字体探测和环境变量覆盖支持中文渲染,缺少中文字体时回退英文;渲染失败时回退纯文本状态。
  • 新增 heat、热图、热度图别名,并复用 DMN 鉴权。
amrita_plugin_memory/rethinking/heatmap.py
amrita_plugin_memory/matchers.py
README.md
增强 DMN 调度的持久化恢复、延迟执行和失败重试能力。
  • 将 pending_run_ts 随运行状态落盘,并在启动时恢复待执行任务;停机错过窗口时延迟约一分钟补跑。
  • 移除 30 秒 misfire 限制,显式启用 coalesce 和单实例约束,避免事件循环阻塞导致任务静默丢失。
  • 跟踪周期内部失败和连续失败次数,失败按 30 分钟最多重试 3 次;成功或用户新消息可重置失败状态。
  • 保持无待执行任务时不进行定时自转,继续由用户消息触发首次规划。
  • 提供调度器状态快照和计划访问接口供热度图及状态展示使用。
amrita_plugin_memory/rethinking/runner.py
README.md
将发布版本升级并引入 Pillow 运行时依赖。
  • 版本从 0.8.1 更新至 0.9.0。
  • 在项目依赖和锁文件中加入 Pillow。
pyproject.toml
uv.lock

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 12 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="588" />
<code_context>
             _state.set_last_run_at(self._read_last_run_ts(payload))
             self._planner.load(payload)
+            try:
+                self._pending_run_ts = float(payload.get("pending_run_ts", 0.0) or 0.0)
+            except (TypeError, ValueError):
+                self._pending_run_ts = 0.0
</code_context>
<issue_to_address>
**Pending run targets the wrong user**

When `target_user_id` changes while a pending run timestamp is stored, `_load_state()` restores the timestamp without checking its target user, and `restore_schedule()` schedules it for the current runner. The pending window from the previous target therefore runs DMN against the newly configured user.

Persist the target user associated with the pending timestamp and restore the timestamp only when that target matches the current `target_user_id`.

Also at `amrita_plugin_memory/rethinking/runner.py:292-296`.
</issue_to_address>

### Comment 2
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="278" />
<code_context>
+            )
+            return
+        self._fail_streak += 1
+        self._schedule_once(_RETRY_DELAY_MINUTES * 60)
+        logger.warning(
+            f"[Subconscious] Retry #{self._fail_streak} "
</code_context>
<issue_to_address>
**Retries disappear after restart**

When the process restarts while a retry is pending or between consecutive failed runs, `_schedule_retry()` updates `_pending_run_ts` and `_fail_streak` in memory without saving them, and state loading does not restore the streak. `restore_schedule()` therefore loses the retry or uses a stale time, while the reset streak lets retries exceed the three-attempt limit; unsaved manual scheduling can also be restored from a stale timestamp.

Persist pending timestamps whenever retries or manual runs are scheduled, and include `_fail_streak` in saved and restored state.

Also at `amrita_plugin_memory/rethinking/runner.py:96-97`, `amrita_plugin_memory/rethinking/runner.py:334`, `amrita_plugin_memory/rethinking/runner.py:671`.
</issue_to_address>

### Comment 3
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="278" />
<code_context>
+            )
+            return
+        self._fail_streak += 1
+        self._schedule_once(_RETRY_DELAY_MINUTES * 60)
+        logger.warning(
+            f"[Subconscious] Retry #{self._fail_streak} "
</code_context>
<issue_to_address>
**Retries duplicate completed writes**

When a cycle commits a memory write before a later operation fails, `_schedule_retry` starts a fresh full cycle after an exception, without checking which tool effects the failed cycle already committed. If a cycle writes a memory and then fails later, `subconscious_write_memory` creates a new UUID on retry, so the same note is stored as a duplicate.

Make mutating tool calls idempotent across cycle retries, or persist progress so retries skip already committed actions.
</issue_to_address>

### Comment 4
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="417" />
<code_context>
             logger.opt(exception=e, colors=True, raw=True).error(
                 f"[Subconscious] No usable preset, skip cycle: {e}"
             )
+            self._cycle_failed = True
             return
         prompt_text = await self._load_prompt()
</code_context>
<issue_to_address>
**Failed runs clear accumulated backoff**

When the chat or agent raises after the cycle is considered valid, `_run_cycle` sets `_cycle_failed` but still calls `_post_process`, which resets the penalty based on cycle validity without checking the failure flag. The failed run therefore clears accumulated scheduling backoff.

Prevent `_post_process` from resetting the penalty when `_cycle_failed` is true.
</issue_to_address>

### Comment 5
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="206" />
<code_context>
+    )
+    _right(draw, WIDTH - PAD, 40, planner.tz_name, f_sub, MUTED)
+
+    observed = round(sum(state.coarse))
+    tier_text = plan.tier if plan is not None else "-"
+    subtitle = _t(
</code_context>
<issue_to_address>
**Activity counts shrink over time**

When the planner has applied time decay to recorded observations, `render_heatmap_png()` displays sums of `state.coarse` and values from `state.fine` as observation counts, but `IdlePlanner._decay()` reduces those values over time. The subtitle and cell labels therefore show decayed weights instead of cumulative observations, making historical counts shrink or disappear.

Render cumulative observation counts rather than the decayed `state.coarse` and `state.fine` weights.

Also at `amrita_plugin_memory/rethinking/heatmap.py:244-247`.
</issue_to_address>

### Comment 6
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="326" />
<code_context>
+            "pending_run_ts": self._pending_run_ts,
+            "is_running": self._is_running,
+            "penalty": _state.get_penalty_count(),
+            "plan": self._planner.last_plan(),
+            "tz_name": self._planner.tz_name,
+        }
</code_context>
<issue_to_address>
**Scheduled window details disappear**

When a run is scheduled through the cold-start fallback or a pending run is restored after restart, `scheduler_snapshot()` reads `IdlePlanner.last_plan()`, but `fallback_plan()` does not set that plan and planner state does not persist or restore it. The heatmap therefore omits the scheduled window details, including its duration and tier, despite a scheduled run.

Set the planner’s last plan for fallback schedules and persist and restore the plan used for pending runs.

Also at `amrita_plugin_memory/matchers.py:181`.
</issue_to_address>

### Comment 7
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="90" />
<code_context>
+
+
+def _t(zh: str, en: str) -> str:
+    """有中文字体时用中文,否则退回英文,避免整张图变成豆腐块。"""
+    return zh if _font_path() else en
+
</code_context>
<issue_to_address>
**Invalid fonts produce missing glyphs**

When `AMRITA_MEMORY_HEATMAP_FONT` points to an existing file that Pillow cannot load, `_font_path()` treats the file as usable, while `_font()` falls back to Pillow’s default font. `_t()` still selects Chinese labels, which that fallback font cannot render, so the heatmap shows missing-glyph boxes instead of English labels.

Select Chinese labels only when the configured font loads successfully; otherwise use English labels.

Also at `amrita_plugin_memory/rethinking/heatmap.py:66-68`, `amrita_plugin_memory/rethinking/heatmap.py:91`.
</issue_to_address>

### Comment 8
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="279" />
<code_context>
+        draw.text(
+            (PAD + 30 + w, panel_top + 40),
+            _humanize((local - now_local).total_seconds()),
+            font=f_sub,
+            fill=ACCENT,
+        )
</code_context>
<issue_to_address>
**DST countdown is off**

When the configured timezone observes DST and the next run crosses a DST transition, `local` and `now_local` use the same timezone, so their subtraction uses wall-clock time across a daylight-saving transition instead of elapsed time. The displayed relative countdown is therefore wrong by an hour when the scheduled run crosses a DST change.

Compute elapsed seconds from UTC timestamps before formatting the relative countdown.
</issue_to_address>

### Comment 9
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="220" />
<code_context>
+    if state.last_update:
+        dt_days = max(0.0, (now_local.timestamp() - state.last_update) / 86400)
+    decay = 2 ** (-dt_days / planner.tau_fine) if dt_days else 1.0
+    weights = [max(0.0, v) * decay for v in state.fine]
+    peak = max(weights) if weights else 0.0
+
</code_context>
<issue_to_address>
**Heatmap colors never cool**

When there are no new observations for a period, `render_heatmap_png` multiplies every cell by the same decay factor and then divides each weight by the maximum. That common factor cancels, so the colors remain unchanged as the entire activity history ages without new messages.

Map the decayed weights to color using a stable absolute scale or otherwise retain the common decay factor when computing color intensity.

Also at `amrita_plugin_memory/rethinking/heatmap.py:238`.
</issue_to_address>

### Comment 10
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="155" />
<code_context>
+    return f"in {minutes}m"
+
+
+def _plan_cells(plan: Plan, tz) -> set[tuple[int, int]]:
+    """把计划窗口铺到 (星期几, 槽位) 上,用于高亮。"""
+    cells: set[tuple[int, int]] = set()
</code_context>
<issue_to_address>
**Heatmap helpers lack complete types**

`_plan_cells` leaves `tz` untyped, while `_text_size`, `_center`, and `_right` leave `font` untyped, so callers can pass incompatible values without static type checking catching them.

Add type annotations for `tz` and each `font` parameter.

Also at `amrita_plugin_memory/rethinking/heatmap.py:111`, `amrita_plugin_memory/rethinking/heatmap.py:120`, `amrita_plugin_memory/rethinking/heatmap.py:132`.
</issue_to_address>

### Comment 11
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="94" />
<code_context>
+    return zh if _font_path() else en
+
+
+def _ramp(v: float) -> tuple[int, int, int]:
+    v = max(0.0, min(1.0, v))
+    for i in range(len(_RAMP) - 1):
</code_context>
<issue_to_address>
**Heatmap helper contracts undocumented**

`_ramp`, `_text_size`, `_center`, `_right`, `_humanize`, and `_font` have no docstrings describing their parameters or side effects, leaving these new functions’ contracts undocumented for maintainers.

Add concise Chinese docstrings that describe each function’s parameter semantics and side effects.

Also at `amrita_plugin_memory/rethinking/heatmap.py:76`, `amrita_plugin_memory/rethinking/heatmap.py:111`, `amrita_plugin_memory/rethinking/heatmap.py:115`, `amrita_plugin_memory/rethinking/heatmap.py:128`, `amrita_plugin_memory/rethinking/heatmap.py:139`.
</issue_to_address>

### Comment 12
<location path="amrita_plugin_memory/rethinking/heatmap.py" line_range="1-9" />
<code_context>
+"""热度图渲染:把 IdlePlanner 的 7×12 作息热度画成 PNG。
+
+纯渲染模块,只依赖 Pillow 与 schedule 的数据结构,不碰 nonebot,
+方便脱离运行时用临时脚本出图核对。格内数字是落盘时点的累计计数,
+颜色再按半衰期折算到当前时刻——数字看"来过多少条",颜色看"现在有多热"。
+
+字体按候选路径查找,找不到中文字体时自动退回英文标签(PIL 自带位图字体
+没有 CJK 字形,硬画会出一片豆腐块)。
+"""
+
+from __future__ import annotations
</code_context>
<issue_to_address>
**新增说明不是单行短句**

热度图模块说明、渲染函数说明和恢复调度说明都跨多行,违反仓库对注释与 docstring 的单行约束,维护者看到的说明不符合既定格式。

将这些说明改为符合约束的中文单行短句。

Also at `amrita_plugin_memory/rethinking/heatmap.py:179-182`, `amrita_plugin_memory/rethinking/runner.py:285-289`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 8 findings to address first, and restoring missed schedules and retrying failed runs can execute additional DMN cycles, which may write changes to memory that remain after the code is reverted. The extra runs are bounded, but their persisted results would need to be reviewed or repaired separately.

Blocking findings: amrita_plugin_memory/rethinking/runner.py:588, amrita_plugin_memory/rethinking/runner.py:278, amrita_plugin_memory/rethinking/runner.py:278, amrita_plugin_memory/rethinking/runner.py:417, amrita_plugin_memory/rethinking/heatmap.py:206, and 3 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

- 版式转置:12 行两小时槽纵向排列,7 列星期横向展开,更接近作息表读法
- 色阶由平方根改线性:低计数不再被抬成和热点同亮度
- 规划窗口改画半透明覆盖层,按真实分钟定位,跨午夜/跨天会拆到对应列,
  结束在槽中间时边界就落在槽中间
- 画布收成 760×814,去掉底部空白
画布与字号一起按 SCALE=2 放大后出图(760×814 → 1520×1628),
左下角图例与面板小字在 1x 下笔画粘连,超采样后边缘立得住。
同时把图例条加厚到 10 逻辑像素,小字统一提到 13 逻辑像素。
框的内缩量原来比格子少 1px,右边线落在两格之间的缝上、贴着相邻槽,
看着像没对齐。统一成和格子相同的 3px 内缩;上下边正好压在槽边界时
也向格内收一点,落在槽中间的(如 03:00)保持原位。
上一版按真实分钟画框,03:00 的顶边落在 02-04 槽中间,看着像框没对齐格子。
改成按槽取整:整块高亮从 02:00 到 10:00 框住四个槽,精确起止仍写在面板。
填色也改成逐格铺,不再把槽之间的缝一起染上。
红点原来贴着格子右下圆角,一半压在边线上,看着像漂到格子外面。
改成离边 9 个逻辑像素、半径 3.5,四边留白一致;图例补一句红点含义。
调度
- 待执行任务记录 pending_uid,target_user_id 变更后不再拿旧窗口去跑新用户
- 重试排期与失败计数落盘(fail_streak),重启后重试不会丢、也不会超过三次
- 手动触发清空 pending 后立即落盘,避免重启恢复出旧计划
- 周期失败时不再重置惩罚,失败不会被当成一轮有效整理

热度图
- 新增 raw_fine/raw_coarse 累计计数(不参与衰减),格内数字与观测总数不再随时间缩水
- 颜色分母改用累计峰值,整张图会随时间一起变淡,而不是永远拉满
- 字体候选改为实际试加载,指到损坏文件时退回英文标签而不是方框
- 相对时间用时间戳相减,跨夏令时不再差一小时
- 上次规划随状态落盘,重启后仍能看到待执行窗口的时长与分层;fallback 也记规划
- 补齐 helper 的类型注解与中文 docstring
@JohnRichard4096
JohnRichard4096 merged commit e82f03c into main Nov 6, 2026
3 checks passed
@JohnRichard4096
JohnRichard4096 deleted the feat/dmn-heatmap branch November 6, 2026 14:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants