fix: 添加语境感知主动消息调度#81
Conversation
审阅者指南(Reviewer's Guide)通过从最近的用户消息内容中推导下一次触发时间,实现上下文感知的主动聊天(proactive-chat)调度,同时持久化更丰富的调度元数据,并在会话之间安全地合并这些数据。 上下文主动调度的时序图(Sequence Diagram)sequenceDiagram
actor User
participant EventsMixin
participant TaskScheduler
User->>EventsMixin: on_friend_message(event)
EventsMixin->>EventsMixin: _extract_event_text(event)
EventsMixin->>EventsMixin: session_temp_state[session_id].last_user_text = text
User->>TaskScheduler: _schedule_next_chat_and_save(session_id)
TaskScheduler->>TaskScheduler: _build_next_schedule_plan(session_id, session_config)
TaskScheduler->>TaskScheduler: _get_schedule_bounds(schedule_conf)
TaskScheduler->>TaskScheduler: _get_contextual_schedule_settings(schedule_conf)
TaskScheduler->>TaskScheduler: _collect_contextual_schedule_texts(session_id, history_count)
loop recent_texts
TaskScheduler->>TaskScheduler: _predict_contextual_interval_from_text(text, min_interval, max_interval)
alt [context match]
TaskScheduler->>TaskScheduler: plan.strategy = contextual
else [no match]
TaskScheduler->>TaskScheduler: keep random fallback
end
end
TaskScheduler->>TaskScheduler: _write_schedule_plan_to_session(session_payload, plan)
TaskScheduler-->>User: next_trigger_time set from contextual or random plan
文件级变更(File-Level Changes)
针对关联问题的评估(Assessment against linked issues)
可能关联的问题(Possibly linked issues)
提示与命令(Tips and commands)与 Sourcery 交互(Interacting with Sourcery)
自定义你的体验(Customizing Your Experience)访问你的 dashboard 以:
获取帮助(Getting Help)Original review guide in EnglishReviewer's GuideImplements contextual proactive-chat scheduling by deriving next trigger times from recent user message content, while persisting richer schedule metadata and safely merging it across sessions. Sequence diagram for contextual proactive schedulingsequenceDiagram
actor User
participant EventsMixin
participant TaskScheduler
User->>EventsMixin: on_friend_message(event)
EventsMixin->>EventsMixin: _extract_event_text(event)
EventsMixin->>EventsMixin: session_temp_state[session_id].last_user_text = text
User->>TaskScheduler: _schedule_next_chat_and_save(session_id)
TaskScheduler->>TaskScheduler: _build_next_schedule_plan(session_id, session_config)
TaskScheduler->>TaskScheduler: _get_schedule_bounds(schedule_conf)
TaskScheduler->>TaskScheduler: _get_contextual_schedule_settings(schedule_conf)
TaskScheduler->>TaskScheduler: _collect_contextual_schedule_texts(session_id, history_count)
loop recent_texts
TaskScheduler->>TaskScheduler: _predict_contextual_interval_from_text(text, min_interval, max_interval)
alt [context match]
TaskScheduler->>TaskScheduler: plan.strategy = contextual
else [no match]
TaskScheduler->>TaskScheduler: keep random fallback
end
end
TaskScheduler->>TaskScheduler: _write_schedule_plan_to_session(session_payload, plan)
TaskScheduler-->>User: next_trigger_time set from contextual or random plan
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Code Review
This pull request introduces contextual timing features that dynamically adjust the scheduling of active messages based on the semantic context of recent user messages (such as detecting keywords like '晚安' or explicit delays like '30分钟后'). The review feedback highlights three key issues: first, a missing last_user_time timestamp in on_friend_message causes the temporary session state to expire prematurely; second, simple substring matching for short English keywords (such as 'gn' or 'bed') can lead to false positives on words like 'design' or 'embed'; and third, the regex for parsing explicit delays incorrectly matches trailing digits in decimal values (e.g., parsing '1.5小时' as '5小时'). Suggestions with code blocks are provided to resolve each of these issues.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| last_user_text = self._extract_event_text(event) | ||
| self.session_temp_state.setdefault(normalized_session_id, {})[ | ||
| "last_user_text" | ||
| ] = last_user_text |
There was a problem hiding this comment.
In on_friend_message, the last_user_text is cached in self.session_temp_state but last_user_time is not set. During the periodic cleanup in _cleanup_expired_session_states, state.get("last_user_time", 0) will return 0 for friend sessions, causing current_time - last_user_time to always exceed the 300-second threshold. This leads to immediate deletion of the friend session's temporary state during the next cleanup cycle, breaking contextual scheduling for friend messages. Ensure last_user_time is also set when caching the text.
| last_user_text = self._extract_event_text(event) | |
| self.session_temp_state.setdefault(normalized_session_id, {})[ | |
| "last_user_text" | |
| ] = last_user_text | |
| last_user_text = self._extract_event_text(event) | |
| temp_state = self.session_temp_state.setdefault(normalized_session_id, {}) | |
| temp_state["last_user_text"] = last_user_text | |
| temp_state["last_user_time"] = current_time |
There was a problem hiding this comment.
已在 ea670ba 中修复:私聊缓存 last_user_text 时现在会同时写入 last_user_time,并放到 data_lock 内更新,避免下一轮 _cleanup_expired_session_states 把刚写入的临时语境立即清掉。
| for rule, markers, minute_range in rules: | ||
| if any(marker in normalized for marker in markers): | ||
| seconds = self._pick_schedule_jitter(*minute_range) | ||
| seconds = self._clamp_schedule_interval(seconds, min_interval, max_interval) | ||
| return { | ||
| "interval_seconds": seconds, | ||
| "strategy": "contextual", | ||
| "rule": rule, | ||
| "reason": f"context:{rule}", | ||
| } |
There was a problem hiding this comment.
Using simple substring matching (marker in normalized) for short English keywords can lead to severe false positives. For example, "gn" will match words like "design" or "signal", "bed" will match "embed" or "obedient", and "exam" will match "example" or "examine". This can cause the scheduler to incorrectly apply long delays (e.g., 7-10 hours for "sleep_night" or 1.5-3 hours for "meeting_or_class") when a user simply uses common words. To prevent this, English alphabetic markers should be matched using word boundaries (\b), while Chinese markers can continue to use substring matching.
| for rule, markers, minute_range in rules: | |
| if any(marker in normalized for marker in markers): | |
| seconds = self._pick_schedule_jitter(*minute_range) | |
| seconds = self._clamp_schedule_interval(seconds, min_interval, max_interval) | |
| return { | |
| "interval_seconds": seconds, | |
| "strategy": "contextual", | |
| "rule": rule, | |
| "reason": f"context:{rule}", | |
| } | |
| for rule, markers, minute_range in rules: | |
| matched = False | |
| for marker in markers: | |
| if re.match(r"^[a-zA-Z\s]+$", marker): | |
| if re.search(rf"\b{re.escape(marker)}\b", normalized): | |
| matched = True | |
| break | |
| elif marker in normalized: | |
| matched = True | |
| break | |
| if matched: | |
| seconds = self._pick_schedule_jitter(*minute_range) | |
| seconds = self._clamp_schedule_interval(seconds, min_interval, max_interval) | |
| return { | |
| "interval_seconds": seconds, | |
| "strategy": "contextual", | |
| "rule": rule, | |
| "reason": f"context:{rule}", | |
| } |
There was a problem hiding this comment.
已在 ea670ba 中修复:新增 _contains_schedule_marker,英文关键词现在使用 (?<![a-z0-9])...(?![a-z0-9]) 边界匹配,避免 gn 命中 design/signal、bed 命中 embed、exam 命中 example 这类误判;中文关键词仍保留子串匹配。
| minute_match = re.search(r"(\d{1,3})\s*(分钟|分|mins?|minutes?)\s*(后|later)?", text) | ||
| if minute_match: | ||
| value = int(minute_match.group(1)) | ||
| if 1 <= value <= 1440: | ||
| return value | ||
|
|
||
| hour_match = re.search(r"(\d{1,2})\s*(个)?\s*(小时|钟头|hours?|hrs?|h)\s*(后|later)?", text) | ||
| if hour_match: | ||
| value = int(hour_match.group(1)) | ||
| if 1 <= value <= 48: | ||
| return value * 60 |
There was a problem hiding this comment.
The regular expressions for extracting explicit delay minutes and hours are prone to incorrect matches when users specify decimal/fractional values (e.g., "1.5小时" or "2.5小时"). Because re.search scans the string and matches any substring satisfying the pattern, "1.5小时" will fail to match at "1" (since it's followed by ".5"), but will successfully match "5小时" at index 2. This causes "1.5小时" (90 minutes) to be incorrectly parsed as "5小时" (300 minutes). Additionally, without boundary constraints, a longer sequence of digits (e.g., "1500分钟") can match its suffix (e.g., "500分钟"). To fix both issues, use a negative lookbehind (?<![\d.]) to ensure the matched digits are not preceded by another digit or a decimal point.
| minute_match = re.search(r"(\d{1,3})\s*(分钟|分|mins?|minutes?)\s*(后|later)?", text) | |
| if minute_match: | |
| value = int(minute_match.group(1)) | |
| if 1 <= value <= 1440: | |
| return value | |
| hour_match = re.search(r"(\d{1,2})\s*(个)?\s*(小时|钟头|hours?|hrs?|h)\s*(后|later)?", text) | |
| if hour_match: | |
| value = int(hour_match.group(1)) | |
| if 1 <= value <= 48: | |
| return value * 60 | |
| minute_match = re.search(r"(?<![\d.])(\d{1,3})\s*(分钟|分|mins?|minutes?)\s*(后|later)?", text) | |
| if minute_match: | |
| value = int(minute_match.group(1)) | |
| if 1 <= value <= 1440: | |
| return value | |
| hour_match = re.search(r"(?<![\d.])(\d{1,2})\s*(个)?\s*(小时|钟头|hours?|hrs?|h)\s*(后|later)?", text) | |
| if hour_match: | |
| value = int(hour_match.group(1)) | |
| if 1 <= value <= 48: | |
| return value * 60 |
There was a problem hiding this comment.
已在 ea670ba 中修复:显式分钟/小时正则增加了数字和小数点边界,1.5小时后、2.5 hours later 不会再被截取成 5小时/5 hours;整数表达如 30分钟后、2小时后 仍可正常解析。
There was a problem hiding this comment.
Hey - 我发现了 2 个问题,并留下了一些高层次反馈:
_predict_contextual_interval_from_text中的上下文调度规则(关键词列表和分钟范围)目前都是硬编码的,这会让后续调优/本地化变得更困难;建议把这些规则移入某种配置结构中(例如放到schedule_settings或单独的映射里),这样可以在不改代码的情况下进行调整。_collect_contextual_schedule_text和事件处理器中对session_temp_state的访问目前没有同步;如果这个 dict 在任务之间共享,为了避免隐蔽的竞争条件,最好使用同一个data_lock或单独的锁来保护读写操作。
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The contextual scheduling rules (keyword lists and minute ranges in `_predict_contextual_interval_from_text`) are all hard-coded, which will make future tuning/localization harder; consider moving these into a config structure (e.g., in `schedule_settings` or a separate mapping) so they can be adjusted without code changes.
- Access to `session_temp_state` in `_collect_contextual_schedule_text` and in the event handlers is currently unsynchronized; if this dict is shared across tasks, it would be safer to guard mutations/reads with the same `data_lock` or a dedicated lock to avoid subtle race conditions.
## Individual Comments
### Comment 1
<location path="core/chat_flow.py" line_range="92-95" />
<code_context>
scheduled_job_payload = None
+ scheduled_plan = None
+
+ if is_private_session:
+ session_config = self._get_session_config(session_id)
+ if not session_config:
+ return
+ scheduled_plan = await self._build_next_schedule_plan(
+ session_id,
</code_context>
<issue_to_address>
**issue (bug_risk):** Early return for missing session_config now skips unanswered-count updates for private sessions.
This refactor changes behavior: for private sessions without a config, we now return before entering `data_lock`, so `unanswered_count` / `new_unanswered_count` are no longer updated. If that’s not intended, consider still doing the counter updates (e.g., by only moving the lookup outside the lock and delaying the early return, or handling the missing-config case inside the lock) so metrics stay consistent.
</issue_to_address>
### Comment 2
<location path="core/message_events.py" line_range="37-46" />
<code_context>
+ if value:
+ candidates.append(value)
+
+ try:
+ messages = event.get_messages()
+ except Exception:
+ messages = []
+ if messages:
+ extractor = getattr(self, "_extract_platform_message_text", None)
+ if callable(extractor):
+ try:
+ extracted = extractor(messages)
+ if extracted:
+ candidates.append(extracted)
+ except Exception:
+ pass
</code_context>
<issue_to_address>
**issue (bug_risk):** _extract_platform_message_text may receive a messages list where it expects a single content object.
Here `extractor` is called with `messages` from `event.get_messages()` (likely a list/sequence), while `_collect_contextual_schedule_texts` calls the same extractor with a single `content` object. If `_extract_platform_message_text` is intended for a single message payload, this mixed usage can introduce subtle bugs or force it to handle multiple input shapes. Consider either iterating over `messages` and calling the extractor per message, or standardizing the extractor’s contract (single vs list) and updating all call sites accordingly.
</issue_to_address>帮我变得更有用!请在每条评论上点击 👍 或 👎,我会根据你的反馈改进后续的评审。
Original comment in English
Hey - I've found 2 issues, and left some high level feedback:
- The contextual scheduling rules (keyword lists and minute ranges in
_predict_contextual_interval_from_text) are all hard-coded, which will make future tuning/localization harder; consider moving these into a config structure (e.g., inschedule_settingsor a separate mapping) so they can be adjusted without code changes. - Access to
session_temp_statein_collect_contextual_schedule_textand in the event handlers is currently unsynchronized; if this dict is shared across tasks, it would be safer to guard mutations/reads with the samedata_lockor a dedicated lock to avoid subtle race conditions.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The contextual scheduling rules (keyword lists and minute ranges in `_predict_contextual_interval_from_text`) are all hard-coded, which will make future tuning/localization harder; consider moving these into a config structure (e.g., in `schedule_settings` or a separate mapping) so they can be adjusted without code changes.
- Access to `session_temp_state` in `_collect_contextual_schedule_text` and in the event handlers is currently unsynchronized; if this dict is shared across tasks, it would be safer to guard mutations/reads with the same `data_lock` or a dedicated lock to avoid subtle race conditions.
## Individual Comments
### Comment 1
<location path="core/chat_flow.py" line_range="92-95" />
<code_context>
scheduled_job_payload = None
+ scheduled_plan = None
+
+ if is_private_session:
+ session_config = self._get_session_config(session_id)
+ if not session_config:
+ return
+ scheduled_plan = await self._build_next_schedule_plan(
+ session_id,
</code_context>
<issue_to_address>
**issue (bug_risk):** Early return for missing session_config now skips unanswered-count updates for private sessions.
This refactor changes behavior: for private sessions without a config, we now return before entering `data_lock`, so `unanswered_count` / `new_unanswered_count` are no longer updated. If that’s not intended, consider still doing the counter updates (e.g., by only moving the lookup outside the lock and delaying the early return, or handling the missing-config case inside the lock) so metrics stay consistent.
</issue_to_address>
### Comment 2
<location path="core/message_events.py" line_range="37-46" />
<code_context>
+ if value:
+ candidates.append(value)
+
+ try:
+ messages = event.get_messages()
+ except Exception:
+ messages = []
+ if messages:
+ extractor = getattr(self, "_extract_platform_message_text", None)
+ if callable(extractor):
+ try:
+ extracted = extractor(messages)
+ if extracted:
+ candidates.append(extracted)
+ except Exception:
+ pass
</code_context>
<issue_to_address>
**issue (bug_risk):** _extract_platform_message_text may receive a messages list where it expects a single content object.
Here `extractor` is called with `messages` from `event.get_messages()` (likely a list/sequence), while `_collect_contextual_schedule_texts` calls the same extractor with a single `content` object. If `_extract_platform_message_text` is intended for a single message payload, this mixed usage can introduce subtle bugs or force it to handle multiple input shapes. Consider either iterating over `messages` and calling the extractor per message, or standardizing the extractor’s contract (single vs list) and updating all call sites accordingly.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
已根据 review 推送
验证: 关于 Sourcery 提到“规则硬编码”的建议:这个方向我认同,但为了让本 PR 继续聚焦 #26 的最小可审阅修复,暂时没有把规则表完全配置化;如果维护者希望,我可以后续单独拆一个 PR 做规则配置化/本地化。 |
|
这个我个人建议还是用 LLM 来判断下一次进行主动消息的时间,纯靠词表的实现太拉跨了。类似的实现可以参考其他主动回复型的插件,套在主动消息上也可以。 |
|
补充说明一下最新一次调整的逻辑:
|
📝 描述 / Description
Fixes #26。
这个 PR 尝试让主动消息的下一次调度时间参考最近对话语境,而不是每次都只使用固定随机区间。改动由 AI 协助生成,我已经做过静态检查,但实现仍有待上游维护者结合真实运行场景继续审阅和完善。
🛠️ 改动点 / Modifications
这不是一个破坏性变更 / This is NOT a breaking change.
在
_conf_schema.json中新增enable_contextual_timing与contextual_timing_history_count配置项。在
core/task_scheduler.py中新增语境调度计划构建逻辑,识别显式延迟、明天/明早、晚安/睡觉、开会/上课、通勤、吃饭、洗澡、游戏、稍后等常见语义。所有语境推断出的时间仍受
min_interval_minutes与max_interval_minutes约束,避免突破用户配置的调度边界。在
core/chat_flow.py中让私聊主动消息发送完成后使用新的调度计划,并将调度策略、原因、规则、来源和区间元信息写入session_data。在
core/message_events.py中缓存最近用户文本,作为轻量语境调度输入。在
core/data_storage.py中合并并保留新增调度元信息,避免重载后状态丢失。📸 运行截图或测试结果 / Screenshots or Test Results
✅ 检查清单 / Checklist
requirements.txt文件中。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added torequirements.txt.❤️ CONTRIBUTING