From 22f5240fa7004884fd6ada8db9dc3f1bac139b31 Mon Sep 17 00:00:00 2001 From: JWeBot Contributors Date: Wed, 21 Oct 2026 18:17:12 +0800 Subject: [PATCH] Release beta 2.0.2.1021 --- CHANGELOG.md | 8 + README.md | 6 +- VERSION | 2 +- packages/jwebot-config/pyproject.toml | 2 +- .../src/jwebot_config/__init__.py | 2 +- packages/jwebot-extension-host/pyproject.toml | 6 +- .../src/jwebot_extension_host/__init__.py | 2 +- packages/jwebot-kernel/pyproject.toml | 8 +- .../src/jwebot_kernel/__init__.py | 2 +- .../src/jwebot_kernel/edge_runtime.py | 614 +++++++++++++++++- .../jwebot-official-extensions/pyproject.toml | 4 +- .../jwebot_official_extensions/__init__.py | 2 +- packages/jwebot-sdk/pyproject.toml | 2 +- .../jwebot-sdk/src/jwebot_sdk/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_jwebot_edge.py | 82 ++- tests/test_release_update.py | 2 +- 17 files changed, 720 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9897b8..a421efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## beta 2.0.2.1021 - 2026-10-21 18:11:35 + +- 上下文追问小版本更新完成:Taffy 现在会优先相信本地游戏状态,并能把短句追评接到上一条 21 点或主动发言。 +- 普通聊天入口新增本地游戏事实抢答:命中“某人在玩什么/现在咋样”等问题时,直接读取本地账本回复,不再排队生成可能互相矛盾的第二条闲聊最终回复。 +- 新增群级 `active_threads` 线程锚点,Taffy 本地命令结果、聊天最终回复和主动广播都会短时记录为当前话题,供“还真是”“胆子大”等短追评优先引用。 +- 上下文打分增强 `active_thread` 标记,并在系统提示中要求模型优先采用 `local_result`/`active_thread` 本地事实,只有多个候选冲突时才追问。 +- 单人和多人 21 点状态现在可被本地摘要复用,回答会包含当前牌局、手牌点数、下注、阶段和轮到谁操作等关键信息。 + ## beta 2.0.1.1021 - 2026-10-21 17:32:46 - 上下文管理小版本更新完成:Taffy 现在会按发送者、点名对象和游戏结果结构化选取上下文,低置信身份不再写入记忆或执行绑定命令。 diff --git a/README.md b/README.md index 69df09f..a329caa 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`beta 2.0.1.1021`,更新时间:2026-10-21 17:32:46。 +当前版本:`beta 2.0.2.1021`,更新时间:2026-10-21 18:11:35。 1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、 Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major @@ -271,10 +271,10 @@ visually confirmed GUI bridge. Current version: ```text -beta 2.0.1.1021 +beta 2.0.2.1021 ``` -Updated: 2026-10-21 17:32:46. +Updated: 2026-10-21 18:11:35. The 1.0 Beta baseline consolidates the stable local command set, person and context indexes, Steam presence/playtime queries, virtual coin games, diff --git a/VERSION b/VERSION index e3e0300..2f297d4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -beta 2.0.1.1021 +beta 2.0.2.1021 diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index b186925..3f7dddc 100644 --- a/packages/jwebot-config/pyproject.toml +++ b/packages/jwebot-config/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-config" -version = "2.0.1b1+1021" +version = "2.0.2b1+1021" description = "Profile-based configuration compiler for JWeBot" requires-python = ">=3.9" dependencies = [ diff --git a/packages/jwebot-config/src/jwebot_config/__init__.py b/packages/jwebot-config/src/jwebot_config/__init__.py index 2b36cf8..3f65a6b 100644 --- a/packages/jwebot-config/src/jwebot_config/__init__.py +++ b/packages/jwebot-config/src/jwebot_config/__init__.py @@ -36,4 +36,4 @@ __all__ = [ "write_default_profile", ] -__version__ = "2.0.1b1+1021" +__version__ = "2.0.2b1+1021" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index c74cae7..34cb4bb 100644 --- a/packages/jwebot-extension-host/pyproject.toml +++ b/packages/jwebot-extension-host/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-extension-host" -version = "2.0.1b1+1021" +version = "2.0.2b1+1021" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==2.0.1b1+1021", - "jwebot-kernel==2.0.1b1+1021", + "jwebot-sdk==2.0.2b1+1021", + "jwebot-kernel==2.0.2b1+1021", ] [project.scripts] diff --git a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py index 08b95b3..4f1ab66 100644 --- a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py +++ b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py @@ -16,4 +16,4 @@ __all__ = [ "process_request", ] -__version__ = "2.0.1b1+1021" +__version__ = "2.0.2b1+1021" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index c46281c..1fa9fd4 100644 --- a/packages/jwebot-kernel/pyproject.toml +++ b/packages/jwebot-kernel/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-kernel" -version = "2.0.1b1+1021" +version = "2.0.2b1+1021" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==2.0.1b1+1021", - "jwebot-sdk==2.0.1b1+1021", - "jwebot-official-extensions==2.0.1b1+1021", + "jwebot-config==2.0.2b1+1021", + "jwebot-sdk==2.0.2b1+1021", + "jwebot-official-extensions==2.0.2b1+1021", "tomli>=2; python_version < '3.11'", ] diff --git a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py index 6d121d5..0911b4f 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py @@ -107,4 +107,4 @@ __all__ = [ "verify_installed_release_signature", ] -__version__ = "2.0.1b1+1021" +__version__ = "2.0.2b1+1021" diff --git a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py index 8108e16..028cb79 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py @@ -77,6 +77,7 @@ from jwebot_official_extensions.player_games import ( append_game_coin_event, append_game_record, blackjack_achievement_lines, + card_label, hand_value, lottery_achievement_lines, next_game_index, @@ -108,6 +109,8 @@ MAX_CONTEXT_TURNS = 40 CONTEXT_CANDIDATE_LIMIT = 240 CONTEXT_RECENT_FALLBACK_TURNS = 10 CONTEXT_TRUSTED_SENDER_CONFIDENCE = 0.80 +ACTIVE_THREAD_TTL_SECONDS = 10 * 60 +ACTIVE_THREAD_SHORT_LIMIT = 24 MULTI_REPLY_DELAY_MIN_SECONDS = 0.5 MULTI_REPLY_DELAY_MAX_SECONDS = 1.0 OCR_EVENT_SOURCES = frozenset({"ocr", "rapidocr-local"}) @@ -494,6 +497,25 @@ CONTEXT_SELF_REFERENCE = re.compile( r"我那(?:把|局|个)|我这(?:把|局|个))", re.IGNORECASE, ) +ACTIVE_THREAD_REPLY_HINT = re.compile( + r"(?:还真|真是|确实|也是|那确实|这么|这也|也太|有点|太|胆子|敢|" + r"离谱|逆天|抽象|难评|牛|绷|笑|哈哈|可以|行吧|草|艹|绝了|夸张)", + re.IGNORECASE, +) +LOCAL_PLAY_STATUS_QUESTION = re.compile( + r"^\s*(?P[\w\u4e00-\u9fff.-]{1,32})\s*" + r"(?:(?:现在|当前|刚才|刚刚|还)?(?:在|正?在)?)?\s*" + r"(?P玩(?:什么|啥|哪(?:个|款)?(?:游戏)?)?|" + r"什么游戏|啥游戏|游戏状态|状态|咋样|怎么样|啥情况)" + r"(?:吗|呢|了|呀|啊|?|\?)?\s*$", + re.IGNORECASE, +) +LOCAL_PLAY_LIST_QUESTION = re.compile( + r"^\s*(?:谁|哪个|哪些人|有人)\s*(?:在|正?在)?\s*" + r"(?:玩|打|进行)\s*(?:什么|啥|游戏|21点|黑杰克|本地游戏)?" + r"(?:吗|呢|呀|啊|?|\?)?\s*$", + re.IGNORECASE, +) def apply_private_context(value: Mapping[str, Any]) -> None: @@ -812,6 +834,60 @@ def context_terms(value: object) -> set[str]: return profile_fact_terms(text) +def short_thread_reply(value: object) -> bool: + text = normalize_text(value) + if not text or local_command_kind(text): + return False + compact = re.sub(r"[\s,,。.!!??~~…]+", "", text) + return ( + 1 <= len(compact) <= ACTIVE_THREAD_SHORT_LIMIT + and bool(ACTIVE_THREAD_REPLY_HINT.search(text)) + ) + + +def blackjack_hand_label(cards: object) -> str: + if not isinstance(cards, list): + return "未知手牌" + normalized: list[int] = [] + for card in cards: + try: + normalized.append(int(card)) + except (TypeError, ValueError): + return "未知手牌" + if not normalized: + return "未发牌" + labels = " ".join(card_label(card) for card in normalized) + return f"{labels}({hand_value(normalized)}点)" + + +def visible_reply_text(value: object) -> str: + text = str(value or "") + parts = unpack_reply_parts(text) + return "\n".join(parts) if parts is not None else text + + +def thread_topic_label(route: str, reason: str) -> str: + if "blackjack" in reason: + return "21点" + if "trivia" in reason: + return "冷门问答" + if "dice" in reason: + return "骰子" + if "coin" in reason: + return "硬币" + if "lottery" in reason: + return "彩票" + if "draw" in reason: + return "抽奖" + if "steam" in reason: + return "Steam 状态" + if route == "broadcast": + return "Taffy 主动发言" + if route == "chat": + return "群聊" + return reason.removeprefix("local_").replace("_", " ") or route + + def automatic_facts_are_near_duplicates( left: dict[str, Any], right: dict[str, Any], @@ -1335,6 +1411,20 @@ class Ledger: updated_at TEXT NOT NULL, PRIMARY KEY (group_fingerprint, game_key) ); + CREATE TABLE IF NOT EXISTS active_threads ( + group_fingerprint TEXT NOT NULL, + thread_key TEXT NOT NULL, + topic TEXT NOT NULL, + actor_person_key TEXT NOT NULL DEFAULT '', + actor_display_name TEXT NOT NULL DEFAULT '', + source_event_id TEXT NOT NULL DEFAULT '', + source_kind TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + PRIMARY KEY (group_fingerprint, thread_key) + ); CREATE TABLE IF NOT EXISTS person_observations ( event_id TEXT PRIMARY KEY, group_fingerprint TEXT NOT NULL, @@ -1599,6 +1689,8 @@ class Ledger: ON outbox(state, next_attempt_at); CREATE INDEX IF NOT EXISTS direct_chat_outbox_due ON direct_chat_outbox(state, next_attempt_at); + CREATE INDEX IF NOT EXISTS active_threads_group_expiry + ON active_threads(group_fingerprint, expires_at); CREATE INDEX IF NOT EXISTS observations_scope_recent ON person_observations(group_fingerprint, person_key, observed_at); CREATE INDEX IF NOT EXISTS aliases_scope_match @@ -1842,8 +1934,7 @@ class Ledger: return cursor.rowcount == 1 def _record_taffy_context(self, event_id: str, response_text: str) -> None: - parts = unpack_reply_parts(response_text) - visible_text = "\n".join(parts) if parts is not None else response_text + visible_text = visible_reply_text(response_text) if not visible_text: return with self.lock: @@ -1869,6 +1960,46 @@ class Ledger: replace=True, ) + def _activate_thread_from_event(self, event_id: str, response_text: str) -> None: + summary = bounded_text(visible_reply_text(response_text), limit=700) + if not summary or summary == FAST_ACKNOWLEDGEMENT: + return + with self.lock: + row = self.connection.execute( + """ + SELECT group_fingerprint, sender, sender_confidence, route, + route_reason, status + FROM events + WHERE event_id = ? + """, + (event_id,), + ).fetchone() + if row is None: + return + reason = str(row["route_reason"]) + if str(row["route"]) == "local" and reason in LOCAL_CONTEXT_EXCLUDED_REASONS: + return + actor_person_key = "" + actor_display_name = "" + sender = str(row["sender"]) + sender_confidence = confidence_value(row["sender_confidence"], default=1.0) + group = str(row["group_fingerprint"]) + if trusted_sender_value(sender, sender_confidence): + person = self.resolve_person(group, sender) + actor_person_key = str(person["person_key"]) + actor_display_name = person_display_label(person["display_name"]) + elif sender and sender != "unknown": + actor_display_name = bounded_text(sender, limit=120) + self.save_active_thread( + group_fingerprint=group, + topic=thread_topic_label(str(row["route"]), reason), + summary=summary, + actor_person_key=actor_person_key, + actor_display_name=actor_display_name, + source_event_id=event_id, + source_kind=f"{row['route']}:{reason}", + ) + def set_event( self, event_id: str, @@ -1893,6 +2024,7 @@ class Ledger: ) if response_text is not None and status in TRACKED_TAFFY_RESPONSE_STATUSES: self._record_taffy_context(event_id, response_text) + self._activate_thread_from_event(event_id, response_text) def mentioned_person_names( self, @@ -1947,7 +2079,9 @@ class Ledger: current_question = bounded_text(question, limit=MAX_EVENT_TEXT) question_terms = context_terms(current_question) followup = bool(CONTEXT_FOLLOWUP_HINT.search(current_question)) + thread_followup = followup or short_thread_reply(current_question) self_reference = bool(CONTEXT_SELF_REFERENCE.search(current_question)) + active_thread = self.active_thread(group) if thread_followup else None current_sender_trusted = trusted_sender_value(sender, sender_confidence) current_display = ( str(self.resolve_person(group, sender)["display_name"]) @@ -1997,8 +2131,22 @@ class Ledger: """, (group, min(80, CONTEXT_CANDIDATE_LIMIT // 3)), ).fetchall() + history_rows = self.connection.execute( + """ + SELECT event_id, display_name, text, observed_at, + sender_confidence, body_confidence, source + FROM context_history + WHERE group_fingerprint = ? + AND event_id != ? + AND source = 'taffy_response' + ORDER BY observed_at DESC, event_id DESC + LIMIT ? + """, + (group, exclude_event_id, min(80, CONTEXT_CANDIDATE_LIMIT // 3)), + ).fetchall() candidates: list[dict[str, Any]] = [] fallback_recent: list[dict[str, Any]] = [] + event_row_ids = {str(row["event_id"]) for row in rows} for index, row in enumerate(rows): if ( row["route"] == "local" @@ -2046,6 +2194,12 @@ class Ledger: if overlap: score += min(40, overlap * 8) tags.append("term_overlap") + if ( + active_thread is not None + and str(active_thread.get("source_event_id")) == str(row["event_id"]) + ): + score += 140 + tags.append("active_thread") if str(row["route"]) == "local": score += 18 tags.append("local_result") @@ -2086,6 +2240,13 @@ class Ledger: if overlap: score += min(30, overlap * 8) tags.append("term_overlap") + if ( + active_thread is not None + and str(active_thread.get("source_event_id")) + == f"clock:{row['slot_key']}" + ): + score += 140 + tags.append("active_thread") if followup: score += 8 candidates.append( @@ -2105,6 +2266,96 @@ class Ledger: "tags": tuple(tags), } ) + if not self_reference: + for index, row in enumerate(history_rows): + history_event_id = str(row["event_id"]) + origin_event_id = ( + history_event_id.removeprefix("taffy:") + if history_event_id.startswith("taffy:") + else history_event_id + ) + if origin_event_id in event_row_ids: + continue + text = str(row["text"]) + overlap = len(question_terms & context_terms(text)) + score = max(0, 20 - index) + tags: list[str] = ["context_history", "taffy_response"] + if overlap: + score += min(30, overlap * 8) + tags.append("term_overlap") + if ( + active_thread is not None + and str(active_thread.get("source_event_id")) in { + history_event_id, + origin_event_id, + } + ): + score += 140 + tags.append("active_thread") + if followup: + score += 8 + if score < 30 and "active_thread" not in tags: + continue + candidates.append( + { + "source_event_id": history_event_id, + "speaker": ( + bounded_text(row["display_name"], limit=80) or "Taffy" + ), + "speaker_trusted": True, + "speaker_confidence": confidence_value( + row["sender_confidence"], + default=1.0, + ), + "user_text": "", + "taffy_text": text, + "route": "history", + "route_reason": str(row["source"]), + "status": "recorded", + "observed_at": str(row["observed_at"]), + "sort_key": str(row["observed_at"]), + "score": score, + "tags": tuple(tags), + } + ) + active_source_event = ( + str(active_thread.get("source_event_id", "")) + if active_thread is not None + else "" + ) + if ( + active_thread is not None + and active_source_event + and not self_reference + and all( + str(candidate["source_event_id"]) != active_source_event + for candidate in candidates + ) + ): + thread_tags = ["active_thread", "thread_summary"] + source_kind = bounded_text(active_thread.get("source_kind"), limit=120) + if source_kind: + thread_tags.append(source_kind.replace(":", "_")) + candidates.append( + { + "source_event_id": f"active:{active_source_event}", + "speaker": ( + bounded_text(active_thread.get("actor_display_name"), limit=80) + or "Taffy" + ), + "speaker_trusted": True, + "speaker_confidence": 1.0, + "user_text": "", + "taffy_text": str(active_thread.get("summary", "")), + "route": "thread", + "route_reason": str(active_thread.get("topic", "")), + "status": "active", + "observed_at": str(active_thread.get("updated_at", "")), + "sort_key": str(active_thread.get("updated_at", "")), + "score": 220, + "tags": tuple(thread_tags), + } + ) selected: list[dict[str, Any]] = [] seen: set[str] = set() for entry in sorted( @@ -3521,6 +3772,97 @@ class Ledger: ).fetchone() return str(row["value"]) if row else "" + def save_active_thread( + self, + *, + group_fingerprint: str, + topic: str, + summary: str, + actor_person_key: str = "", + actor_display_name: str = "", + source_event_id: str = "", + source_kind: str = "", + thread_key: str = "latest", + ttl_seconds: int = ACTIVE_THREAD_TTL_SECONDS, + ) -> None: + group = bounded_text(group_fingerprint, limit=256) + thread = bounded_text(thread_key, limit=80) or "latest" + text = bounded_text(summary, limit=700) + if not group or not text: + return + now = datetime.now(timezone.utc) + timestamp = now.isoformat(timespec="milliseconds") + expires_at = (now + timedelta(seconds=ttl_seconds)).isoformat( + timespec="milliseconds" + ) + with self.lock, self.connection: + self.connection.execute( + """ + INSERT INTO active_threads ( + group_fingerprint, thread_key, topic, actor_person_key, + actor_display_name, source_event_id, source_kind, summary, + created_at, updated_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(group_fingerprint, thread_key) DO UPDATE + SET topic = excluded.topic, + actor_person_key = excluded.actor_person_key, + actor_display_name = excluded.actor_display_name, + source_event_id = excluded.source_event_id, + source_kind = excluded.source_kind, + summary = excluded.summary, + updated_at = excluded.updated_at, + expires_at = excluded.expires_at + """, + ( + group, + thread, + bounded_text(topic, limit=120), + bounded_text(actor_person_key, limit=128), + bounded_text(actor_display_name, limit=120), + bounded_text(source_event_id, limit=256), + bounded_text(source_kind, limit=120), + text, + timestamp, + timestamp, + expires_at, + ), + ) + + def active_thread( + self, + group_fingerprint: str, + *, + thread_key: str = "latest", + now: datetime | None = None, + ) -> dict[str, Any] | None: + group = bounded_text(group_fingerprint, limit=256) + thread = bounded_text(thread_key, limit=80) or "latest" + if not group: + return None + current = now or datetime.now(timezone.utc) + timestamp = current.astimezone(timezone.utc).isoformat( + timespec="milliseconds" + ) + with self.lock, self.connection: + self.connection.execute( + """ + DELETE FROM active_threads + WHERE group_fingerprint = ? AND expires_at <= ? + """, + (group, timestamp), + ) + row = self.connection.execute( + """ + SELECT * + FROM active_threads + WHERE group_fingerprint = ? AND thread_key = ? + AND expires_at > ? + LIMIT 1 + """, + (group, thread, timestamp), + ).fetchone() + return dict(row) if row else None + def recent_clock_broadcast( self, group_fingerprint: str, @@ -3775,6 +4117,14 @@ class Ledger: source=f"clock_broadcast:{kind[:48]}", replace=True, ) + self.save_active_thread( + group_fingerprint=group_fingerprint, + topic=thread_topic_label("broadcast", kind), + summary=message, + actor_display_name="Taffy", + source_event_id=f"clock:{slot_key}", + source_kind=f"clock_broadcast:{kind[:48]}", + ) return True def fail_clock_broadcast( @@ -4248,6 +4598,230 @@ class Ledger: ), ) + def _local_status_subject( + self, + group: str, + subject: str, + *, + sender: str = "", + sender_confidence: float = 1.0, + ) -> dict[str, str] | None: + normalized = normalize_text(subject) + if not normalized: + return None + if normalized in {"我", "本人", "自己"}: + if not trusted_sender_value(sender, sender_confidence): + return None + person = self.resolve_person(group, sender) + return { + "person_key": str(person["person_key"]), + "display_name": person_display_label(person["display_name"]), + } + if normalized.casefold() in {"你", "taffy", "jwebot", BOT_MENTION.casefold()}: + return None + self.seed_person_aliases(group) + subject_key = person_alias_key(normalized) + with self.lock: + rows = self.connection.execute( + """ + SELECT alias_key, match_kind, canonical_name + FROM person_aliases + WHERE group_fingerprint = ? + ORDER BY length(alias_key) DESC, match_kind + """, + (group,), + ).fetchall() + for row in rows: + if not alias_is_mentioned( + subject_key, + str(row["alias_key"]), + str(row["match_kind"]), + ): + continue + canonical = str(row["canonical_name"]) + return { + "person_key": person_scope_key(group, canonical), + "display_name": person_display_label(canonical), + } + return None + + @staticmethod + def _active_single_blackjack_game(account: dict[str, Any]) -> dict[str, Any] | None: + game = account.get("game") + if not isinstance(game, dict): + return None + try: + updated_at = float(game.get("updated_at", 0)) + except (TypeError, ValueError): + return None + if time.time() - updated_at >= LOCAL_GAME_TIMEOUT_SECONDS: + return None + return game + + def _single_blackjack_status( + self, + group: str, + person_key: str, + display_name: str, + ) -> str | None: + with self.lock: + account = self._load_player_account(group, person_key) + game = self._active_single_blackjack_game(account) + if game is None: + return None + dealer = game.get("dealer", []) + dealer_text = ( + f"{card_label(int(dealer[0]))} + 暗牌" + if isinstance(dealer, list) and dealer + else "未知" + ) + try: + index = int(game.get("index", 0)) + bet = int(game.get("bet", 0)) + except (TypeError, ValueError): + index = 0 + bet = 0 + suffix = f" #{index}" if index > 0 else "" + return ( + f"{display_name} 在玩单人21点{suffix}:" + f"手牌 {blackjack_hand_label(game.get('player'))};" + f"庄家 {dealer_text};下注 {bet};" + "可 /21点 要牌 或 /21点 停牌" + ) + + def _multiplayer_blackjack_status( + self, + group: str, + person_key: str, + display_name: str, + ) -> str | None: + with self.lock: + table = self._open_group_blackjack(group) + if table is None: + return None + players = self._group_blackjack_players(group, int(table["table_number"])) + player = self._blackjack_entry(players, person_key) + if player is None: + return None + state = str(table["state"]) + table_number = int(table["table_number"]) + prompt = self._blackjack_entry(players, str(table["turn_person_key"])) + prompt_text = f";当前轮到 {prompt['display_name']}" if prompt else "" + if state == "joining": + names = "、".join(str(item["display_name"]) for item in players) + return ( + f"{display_name} 在多人21点第 {table_number} 桌,正在等人加入;" + f"当前玩家:{names}" + ) + if state == "betting": + bet = int(player["bet"]) + bet_text = f"已放 {bet}" if bet > 0 else "还没放筹码" + return ( + f"{display_name} 在多人21点第 {table_number} 桌,下注阶段;" + f"{display_name} {bet_text}{prompt_text}" + ) + if state == "playing": + return ( + f"{display_name} 在多人21点第 {table_number} 桌,出牌阶段;" + f"手牌 {blackjack_hand_label(player.get('hand'))};" + f"下注 {int(player['bet'])};状态 {player['state']}{prompt_text}" + ) + return None + + def _active_local_game_lines(self, group: str) -> list[str]: + directory = { + str(person["person_key"]): str(person["display_name"]) + for person in self.person_directory(group) + } + lines: list[str] = [] + with self.lock: + account_rows = self.connection.execute( + """ + SELECT person_key, state_json + FROM player_accounts + WHERE group_fingerprint = ? + ORDER BY updated_at DESC + LIMIT 80 + """, + (group,), + ).fetchall() + table = self._open_group_blackjack(group) + players = ( + self._group_blackjack_players(group, int(table["table_number"])) + if table is not None + else [] + ) + for row in account_rows: + try: + account = normalize_player_account( + json.loads(str(row["state_json"])) + ) + except (json.JSONDecodeError, TypeError, ValueError): + continue + game = self._active_single_blackjack_game(account) + if game is None: + continue + person_key = str(row["person_key"]) + display_name = directory.get(person_key, "有人") + index = int(game.get("index", 0) or 0) + index_text = f" #{index}" if index > 0 else "" + lines.append( + f"{display_name} 单人21点{index_text}," + f"手牌 {blackjack_hand_label(game.get('player'))}" + ) + if table is not None and players: + state = str(table["state"]) + state_text = { + "joining": "等人加入", + "betting": "下注阶段", + "playing": "出牌阶段", + }.get(state, state) + names = "、".join(str(player["display_name"]) for player in players) + lines.append( + f"多人21点第 {int(table['table_number'])} 桌{state_text}," + f"玩家:{names}" + ) + return lines + + def local_game_status_reply( + self, + group: str, + question: str, + *, + sender: str = "", + sender_confidence: float = 1.0, + ) -> str | None: + current = bounded_text(question, limit=MAX_EVENT_TEXT) + if LOCAL_PLAY_LIST_QUESTION.fullmatch(current): + lines = self._active_local_game_lines(group) + if not lines: + return None + return clean_reply("现在本地游戏里:" + ";".join(lines)) + match = LOCAL_PLAY_STATUS_QUESTION.fullmatch(current) + if match is None: + return None + person = self._local_status_subject( + group, + match.group("subject"), + sender=sender, + sender_confidence=sender_confidence, + ) + if person is None: + return None + person_key = str(person["person_key"]) + display_name = str(person["display_name"]) + lines = [ + line + for line in ( + self._single_blackjack_status(group, person_key, display_name), + self._multiplayer_blackjack_status(group, person_key, display_name), + ) + if line + ] + if not lines: + return None + return clean_reply(";".join(lines)) + def _store_player_action( self, group: str, @@ -7714,7 +8288,10 @@ class DeepSeekClient: "STRUCTURED_RECENT_GROUP_CONTEXT 是多人群聊记录,speaker 不同就不是" "同一个人;遇到“我刚才、我的、我上一把”这类自指问题,只引用" "CURRENT_GROUP_SENDER_LABEL 对应的上下文。点名别人时才引用被点名者。" - "上下文不足或指代不清时,短句追问“你说哪件?”" + "标记为 local_result 或 active_thread 的内容是本地账本或当前线程事实," + "必须优先采用,不要再回答“不知道”“去问他”或给出相反结论。" + "用户只发短句评价时,优先接 active_thread 继续聊。" + "只有多个候选明显冲突且无法判断时,才短句追问“你说哪件?”" ), } ] @@ -8760,6 +9337,37 @@ class EdgeApplication: if parts is not None: response["texts"] = parts return response + local_fact_reply = self.ledger.local_game_status_reply( + str(row["group_fingerprint"]), + str(row["question"]), + sender=str(row["sender"]), + sender_confidence=confidence_value( + row.get("sender_confidence"), + default=1.0, + ), + ) + if local_fact_reply: + self.ledger.set_event( + str(row["event_id"]), + status="chat_completed", + response_text=local_fact_reply, + ) + self.telemetry.elapsed( + "edge.local_fact.complete", + started, + event_id=event_id, + group_id=group_id, + route=route, + reason="local_game_status", + execution_path=ExecutionPath.LOCAL_COMMAND.value, + chars=len(local_fact_reply), + ) + return { + "status": "reply", + "route": route, + "execution_path": ExecutionPath.LOCAL_COMMAND.value, + "text": local_fact_reply, + } alias_reply = self.ledger.local_alias_identity_reply( str(row["group_fingerprint"]), str(row["question"]), diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index 55aa04e..ddbfeda 100644 --- a/packages/jwebot-official-extensions/pyproject.toml +++ b/packages/jwebot-official-extensions/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-official-extensions" -version = "2.0.1b1+1021" +version = "2.0.2b1+1021" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==2.0.1b1+1021", + "jwebot-sdk==2.0.2b1+1021", ] [tool.setuptools.packages.find] diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py index b5fbde4..21ec58c 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py @@ -90,4 +90,4 @@ __all__ = [ "render_feature_list", ] -__version__ = "2.0.1b1+1021" +__version__ = "2.0.2b1+1021" diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index 54f3595..5138e3e 100644 --- a/packages/jwebot-sdk/pyproject.toml +++ b/packages/jwebot-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-sdk" -version = "2.0.1b1+1021" +version = "2.0.2b1+1021" description = "Typed, capability-limited extension API for JWeBot plugins" requires-python = ">=3.9" dependencies = [] diff --git a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py index ce94236..f386c49 100644 --- a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py +++ b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py @@ -40,4 +40,4 @@ __all__ = [ "SingleGroupContext", ] -__version__ = "2.0.1b1+1021" +__version__ = "2.0.2b1+1021" diff --git a/pyproject.toml b/pyproject.toml index 42665e6..98b51d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "beta 2.0.1.1021" +release = "beta 2.0.2.1021" production_deployments_enabled_by_default = false [tool.jwebot.workspace] diff --git a/tests/test_jwebot_edge.py b/tests/test_jwebot_edge.py index 6ecc285..ab8fbc2 100644 --- a/tests/test_jwebot_edge.py +++ b/tests/test_jwebot_edge.py @@ -516,6 +516,82 @@ class LedgerAndApplicationTests(unittest.TestCase): ) ) + def test_local_game_status_question_answers_without_direct_chat(self): + person = self.application.ledger.resolve_person("group-a", "Bob") + account = MODULE.normalize_player_account({}) + account["game"] = { + "bet": 50, + "deck": [1, 2, 3], + "player": [5, 10], + "dealer": [8, 2], + "updated_at": time.time(), + "index": 1, + } + with self.application.ledger.lock, self.application.ledger.connection: + self.application.ledger._save_player_account( + "group-a", + str(person["person_key"]), + account, + MODULE.utc_now(), + ) + self.application.deepseek.complete = mock.Mock( + side_effect=AssertionError("direct chat should not run") + ) + + response = self.application.accept( + event("bob-local-status", "@JWeBot Bob 在玩什么吗", sender="Alice") + ) + + self.assertEqual(response["text"].count("喵"), 1) + self.assertIn("bob 在玩单人21点 #1", response["text"]) + self.assertIn("16点", response["text"]) + self.assertEqual(response["execution_path"], "local-command") + self.assertEqual( + self.application.ledger.row("bob-local-status")["status"], + "chat_completed", + ) + self.assertEqual( + self.application.ledger.status()["direct_chat_pending"], + 0, + ) + self.application.deepseek.complete.assert_not_called() + + def test_short_evaluation_uses_active_taffy_thread(self): + observed_context = [] + + def complete(**kwargs): + observed_context[:] = list(kwargs["context"]) + return "他那手确实挺敢喵" + + self.application.deepseek.complete = complete + self.application.deliver = mock.Mock() + self.application.ledger.reserve_clock_broadcast( + group_fingerprint="group-a", + slot_key="hourly:2026-10-21T18", + local_date="2026-10-21", + kind="ambient_chat", + message="21点那把16点还敢停牌喵", + ) + self.assertTrue( + self.application.ledger.complete_clock_broadcast( + "group-a", + "hourly:2026-10-21T18", + ) + ) + + self.application.accept( + event("short-active-thread", "@JWeBot 还真是 胆子大", sender="Alice") + ) + self.application.process_direct_chat_once() + + self.assertTrue( + any( + "active_thread" in item["tags"] + and "16点还敢停牌" in item["taffy_text"] + for item in observed_context + ) + ) + def test_route_and_ack_telemetry_is_privacy_safe(self): observed = [] with tempfile.TemporaryDirectory() as directory: @@ -1215,9 +1291,9 @@ class LedgerAndApplicationTests(unittest.TestCase): self.assertEqual(version["route"], "local") self.assertIn("JWeBot 版本", version["text"]) self.assertIn("kernel", version["text"]) - self.assertIn("最新版本:beta 2.0.1.1021", version["text"]) - self.assertIn("更新时间:2026-10-21 17:32:46", version["text"]) - self.assertIn("更新摘要:上下文管理小版本更新完成", version["text"]) + self.assertIn("最新版本:beta 2.0.2.1021", version["text"]) + self.assertIn("更新时间:2026-10-21 18:11:35", version["text"]) + self.assertIn("更新摘要:上下文追问小版本更新完成", version["text"]) self.assertEqual(debug["route"], "local") self.assertIn("调试埋点", debug["text"]) self.assertEqual(balance["route"], "local") diff --git a/tests/test_release_update.py b/tests/test_release_update.py index 493ba57..d7d7567 100644 --- a/tests/test_release_update.py +++ b/tests/test_release_update.py @@ -20,7 +20,7 @@ SPEC.loader.exec_module(MODULE) class ReleaseUpdateTests(unittest.TestCase): - release = "beta 2.0.1.1021" + release = "beta 2.0.2.1021" major_release = "beta 2.0.0.1021" def setUp(self):