Release beta 2.0.2.1021
check / local-gate (push) Failing after 1m56s

This commit is contained in:
JWeBot Contributors
2026-10-21 18:17:12 +08:00
parent 645a282b22
commit 22f5240fa7
17 changed files with 720 additions and 28 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # 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 ## beta 2.0.1.1021 - 2026-10-21 17:32:46
- 上下文管理小版本更新完成:Taffy 现在会按发送者、点名对象和游戏结果结构化选取上下文,低置信身份不再写入记忆或执行绑定命令。 - 上下文管理小版本更新完成:Taffy 现在会按发送者、点名对象和游戏结果结构化选取上下文,低置信身份不再写入记忆或执行绑定命令。
+3 -3
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 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 收口了当前稳定功能:本地命令插件、人物与上下文索引、 1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major
@@ -271,10 +271,10 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```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 The 1.0 Beta baseline consolidates the stable local command set, person and
context indexes, Steam presence/playtime queries, virtual coin games, context indexes, Steam presence/playtime queries, virtual coin games,
+1 -1
View File
@@ -1 +1 @@
beta 2.0.1.1021 beta 2.0.2.1021
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "2.0.1b1+1021" version = "2.0.2b1+1021"
description = "Profile-based configuration compiler for JWeBot" description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile", "write_default_profile",
] ]
__version__ = "2.0.1b1+1021" __version__ = "2.0.2b1+1021"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-extension-host" name = "jwebot-extension-host"
version = "2.0.1b1+1021" version = "2.0.2b1+1021"
description = "Single-release, single-invocation process host for JWeBot extensions" description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==2.0.1b1+1021", "jwebot-sdk==2.0.2b1+1021",
"jwebot-kernel==2.0.1b1+1021", "jwebot-kernel==2.0.2b1+1021",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "2.0.1b1+1021" __version__ = "2.0.2b1+1021"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "2.0.1b1+1021" version = "2.0.2b1+1021"
description = "Stable routing and extension authority for JWeBot" description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-config==2.0.1b1+1021", "jwebot-config==2.0.2b1+1021",
"jwebot-sdk==2.0.1b1+1021", "jwebot-sdk==2.0.2b1+1021",
"jwebot-official-extensions==2.0.1b1+1021", "jwebot-official-extensions==2.0.2b1+1021",
"tomli>=2; python_version < '3.11'", "tomli>=2; python_version < '3.11'",
] ]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature", "verify_installed_release_signature",
] ]
__version__ = "2.0.1b1+1021" __version__ = "2.0.2b1+1021"
@@ -77,6 +77,7 @@ from jwebot_official_extensions.player_games import (
append_game_coin_event, append_game_coin_event,
append_game_record, append_game_record,
blackjack_achievement_lines, blackjack_achievement_lines,
card_label,
hand_value, hand_value,
lottery_achievement_lines, lottery_achievement_lines,
next_game_index, next_game_index,
@@ -108,6 +109,8 @@ MAX_CONTEXT_TURNS = 40
CONTEXT_CANDIDATE_LIMIT = 240 CONTEXT_CANDIDATE_LIMIT = 240
CONTEXT_RECENT_FALLBACK_TURNS = 10 CONTEXT_RECENT_FALLBACK_TURNS = 10
CONTEXT_TRUSTED_SENDER_CONFIDENCE = 0.80 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_MIN_SECONDS = 0.5
MULTI_REPLY_DELAY_MAX_SECONDS = 1.0 MULTI_REPLY_DELAY_MAX_SECONDS = 1.0
OCR_EVENT_SOURCES = frozenset({"ocr", "rapidocr-local"}) OCR_EVENT_SOURCES = frozenset({"ocr", "rapidocr-local"})
@@ -494,6 +497,25 @@ CONTEXT_SELF_REFERENCE = re.compile(
r"我那(?:把|局|个)|我这(?:把|局|个))", r"我那(?:把|局|个)|我这(?:把|局|个))",
re.IGNORECASE, re.IGNORECASE,
) )
ACTIVE_THREAD_REPLY_HINT = re.compile(
r"(?:还真|真是|确实|也是|那确实|这么|这也|也太|有点|太|胆子|敢|"
r"离谱|逆天|抽象|难评|牛|绷|笑|哈哈|可以|行吧|草|艹|绝了|夸张)",
re.IGNORECASE,
)
LOCAL_PLAY_STATUS_QUESTION = re.compile(
r"^\s*(?P<subject>[\w\u4e00-\u9fff.-]{1,32})\s*"
r"(?:(?:现在|当前|刚才|刚刚|还)?(?:在|正?在)?)?\s*"
r"(?P<ask>玩(?:什么|啥|哪(?:个|款)?(?:游戏)?)?|"
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: 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) 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( def automatic_facts_are_near_duplicates(
left: dict[str, Any], left: dict[str, Any],
right: dict[str, Any], right: dict[str, Any],
@@ -1335,6 +1411,20 @@ class Ledger:
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, game_key) 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 ( CREATE TABLE IF NOT EXISTS person_observations (
event_id TEXT PRIMARY KEY, event_id TEXT PRIMARY KEY,
group_fingerprint TEXT NOT NULL, group_fingerprint TEXT NOT NULL,
@@ -1599,6 +1689,8 @@ class Ledger:
ON outbox(state, next_attempt_at); ON outbox(state, next_attempt_at);
CREATE INDEX IF NOT EXISTS direct_chat_outbox_due CREATE INDEX IF NOT EXISTS direct_chat_outbox_due
ON direct_chat_outbox(state, next_attempt_at); 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 CREATE INDEX IF NOT EXISTS observations_scope_recent
ON person_observations(group_fingerprint, person_key, observed_at); ON person_observations(group_fingerprint, person_key, observed_at);
CREATE INDEX IF NOT EXISTS aliases_scope_match CREATE INDEX IF NOT EXISTS aliases_scope_match
@@ -1842,8 +1934,7 @@ class Ledger:
return cursor.rowcount == 1 return cursor.rowcount == 1
def _record_taffy_context(self, event_id: str, response_text: str) -> None: def _record_taffy_context(self, event_id: str, response_text: str) -> None:
parts = unpack_reply_parts(response_text) visible_text = visible_reply_text(response_text)
visible_text = "\n".join(parts) if parts is not None else response_text
if not visible_text: if not visible_text:
return return
with self.lock: with self.lock:
@@ -1869,6 +1960,46 @@ class Ledger:
replace=True, 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( def set_event(
self, self,
event_id: str, event_id: str,
@@ -1893,6 +2024,7 @@ class Ledger:
) )
if response_text is not None and status in TRACKED_TAFFY_RESPONSE_STATUSES: if response_text is not None and status in TRACKED_TAFFY_RESPONSE_STATUSES:
self._record_taffy_context(event_id, response_text) self._record_taffy_context(event_id, response_text)
self._activate_thread_from_event(event_id, response_text)
def mentioned_person_names( def mentioned_person_names(
self, self,
@@ -1947,7 +2079,9 @@ class Ledger:
current_question = bounded_text(question, limit=MAX_EVENT_TEXT) current_question = bounded_text(question, limit=MAX_EVENT_TEXT)
question_terms = context_terms(current_question) question_terms = context_terms(current_question)
followup = bool(CONTEXT_FOLLOWUP_HINT.search(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)) 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_sender_trusted = trusted_sender_value(sender, sender_confidence)
current_display = ( current_display = (
str(self.resolve_person(group, sender)["display_name"]) str(self.resolve_person(group, sender)["display_name"])
@@ -1997,8 +2131,22 @@ class Ledger:
""", """,
(group, min(80, CONTEXT_CANDIDATE_LIMIT // 3)), (group, min(80, CONTEXT_CANDIDATE_LIMIT // 3)),
).fetchall() ).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]] = [] candidates: list[dict[str, Any]] = []
fallback_recent: 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): for index, row in enumerate(rows):
if ( if (
row["route"] == "local" row["route"] == "local"
@@ -2046,6 +2194,12 @@ class Ledger:
if overlap: if overlap:
score += min(40, overlap * 8) score += min(40, overlap * 8)
tags.append("term_overlap") 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": if str(row["route"]) == "local":
score += 18 score += 18
tags.append("local_result") tags.append("local_result")
@@ -2086,6 +2240,13 @@ class Ledger:
if overlap: if overlap:
score += min(30, overlap * 8) score += min(30, overlap * 8)
tags.append("term_overlap") 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: if followup:
score += 8 score += 8
candidates.append( candidates.append(
@@ -2105,6 +2266,96 @@ class Ledger:
"tags": tuple(tags), "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]] = [] selected: list[dict[str, Any]] = []
seen: set[str] = set() seen: set[str] = set()
for entry in sorted( for entry in sorted(
@@ -3521,6 +3772,97 @@ class Ledger:
).fetchone() ).fetchone()
return str(row["value"]) if row else "" 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( def recent_clock_broadcast(
self, self,
group_fingerprint: str, group_fingerprint: str,
@@ -3775,6 +4117,14 @@ class Ledger:
source=f"clock_broadcast:{kind[:48]}", source=f"clock_broadcast:{kind[:48]}",
replace=True, 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 return True
def fail_clock_broadcast( 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( def _store_player_action(
self, self,
group: str, group: str,
@@ -7714,7 +8288,10 @@ class DeepSeekClient:
"STRUCTURED_RECENT_GROUP_CONTEXT 是多人群聊记录,speaker 不同就不是" "STRUCTURED_RECENT_GROUP_CONTEXT 是多人群聊记录,speaker 不同就不是"
"同一个人;遇到“我刚才、我的、我上一把”这类自指问题,只引用" "同一个人;遇到“我刚才、我的、我上一把”这类自指问题,只引用"
"CURRENT_GROUP_SENDER_LABEL 对应的上下文。点名别人时才引用被点名者。" "CURRENT_GROUP_SENDER_LABEL 对应的上下文。点名别人时才引用被点名者。"
"上下文不足或指代不清时,短句追问“你说哪件?”" "标记为 local_result 或 active_thread 的内容是本地账本或当前线程事实,"
"必须优先采用,不要再回答“不知道”“去问他”或给出相反结论。"
"用户只发短句评价时,优先接 active_thread 继续聊。"
"只有多个候选明显冲突且无法判断时,才短句追问“你说哪件?”"
), ),
} }
] ]
@@ -8760,6 +9337,37 @@ class EdgeApplication:
if parts is not None: if parts is not None:
response["texts"] = parts response["texts"] = parts
return response 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( alias_reply = self.ledger.local_alias_identity_reply(
str(row["group_fingerprint"]), str(row["group_fingerprint"]),
str(row["question"]), str(row["question"]),
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "2.0.1b1+1021" version = "2.0.2b1+1021"
description = "Reviewed local command adapters and broker implementation for JWeBot" description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==2.0.1b1+1021", "jwebot-sdk==2.0.2b1+1021",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list", "render_feature_list",
] ]
__version__ = "2.0.1b1+1021" __version__ = "2.0.2b1+1021"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "2.0.1b1+1021" version = "2.0.2b1+1021"
description = "Typed, capability-limited extension API for JWeBot plugins" description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [] dependencies = []
@@ -40,4 +40,4 @@ __all__ = [
"SingleGroupContext", "SingleGroupContext",
] ]
__version__ = "2.0.1b1+1021" __version__ = "2.0.2b1+1021"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "beta 2.0.1.1021" release = "beta 2.0.2.1021"
production_deployments_enabled_by_default = false production_deployments_enabled_by_default = false
[tool.jwebot.workspace] [tool.jwebot.workspace]
+79 -3
View File
@@ -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): def test_route_and_ack_telemetry_is_privacy_safe(self):
observed = [] observed = []
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -1215,9 +1291,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(version["route"], "local") self.assertEqual(version["route"], "local")
self.assertIn("JWeBot 版本", version["text"]) self.assertIn("JWeBot 版本", version["text"])
self.assertIn("kernel", version["text"]) self.assertIn("kernel", version["text"])
self.assertIn("最新版本:beta 2.0.1.1021", version["text"]) self.assertIn("最新版本:beta 2.0.2.1021", version["text"])
self.assertIn("更新时间:2026-10-21 17:32:46", version["text"]) self.assertIn("更新时间:2026-10-21 18:11:35", version["text"])
self.assertIn("更新摘要:上下文管理小版本更新完成", version["text"]) self.assertIn("更新摘要:上下文追问小版本更新完成", version["text"])
self.assertEqual(debug["route"], "local") self.assertEqual(debug["route"], "local")
self.assertIn("调试埋点", debug["text"]) self.assertIn("调试埋点", debug["text"])
self.assertEqual(balance["route"], "local") self.assertEqual(balance["route"], "local")
+1 -1
View File
@@ -20,7 +20,7 @@ SPEC.loader.exec_module(MODULE)
class ReleaseUpdateTests(unittest.TestCase): class ReleaseUpdateTests(unittest.TestCase):
release = "beta 2.0.1.1021" release = "beta 2.0.2.1021"
major_release = "beta 2.0.0.1021" major_release = "beta 2.0.0.1021"
def setUp(self): def setUp(self):