@@ -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 现在会按发送者、点名对象和游戏结果结构化选取上下文,低置信身份不再写入记忆或执行绑定命令。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user