diff --git a/prod.example/deploy-state.py b/prod.example/deploy-state.py index 9888bbae..406f6240 100644 --- a/prod.example/deploy-state.py +++ b/prod.example/deploy-state.py @@ -23,7 +23,9 @@ SERVICES = ("llbot", "quickquip", "web-admin") -def atomic_write(path: Path, data: bytes, mode: int, new_owner: tuple[int, int] | None = None) -> None: +def atomic_write( + path: Path, data: bytes, mode: int, new_owner: tuple[int, int] | None = None +) -> None: missing_parents = [] parent = path.parent while new_owner is not None and not parent.exists(): @@ -122,7 +124,8 @@ def apply_shared(root: Path, incoming: Path, backup: Path) -> None: connections.append({}) connections[1]["url"] = "ws://quickquip:8080/onebot/v11/ws/" connections[1]["token"] = token or "" - atomic_write(path, (json.dumps(data, ensure_ascii=False, indent=4) + "\n").encode(), record["mode"]) + payload = (json.dumps(data, ensure_ascii=False, indent=4) + "\n").encode() + atomic_write(path, payload, record["mode"]) def restore_shared(root: Path, backup: Path) -> None: @@ -136,15 +139,24 @@ def restore_shared(root: Path, backup: Path) -> None: def capture_baseline(root: Path, baseline: Path) -> None: """Capture server files and running image IDs without moving live bind sources.""" - command = ["docker", "compose", "--env-file", str(root / ".env"), "-f", str(root / "prod/docker-compose.yml")] + command = [ + "docker", "compose", "--env-file", str(root / ".env"), + "-f", str(root / "prod/docker-compose.yml"), + ] # No interpolation also preserves env_file references on Compose 2.27+. raw = subprocess.check_output(command + ["config", "--no-interpolate", "--format", "json"]) config = json.loads(raw) if set(config["services"]) != set(SERVICES): - raise ValueError("migration requires exactly llbot, quickquip and web-admin; review custom services first") + raise ValueError( + "migration requires exactly llbot, quickquip and web-admin; " + "review custom services first" + ) # Keep private runtime files out of the snapshot; copy only mounted app assets. baseline.mkdir(mode=0o700) - for name in ("src", "config", "llm_about", "frontend/dist", "bot.py", "web_api.py", "pyproject.toml", "requirements.txt", ".dockerignore"): + for name in ( + "src", "config", "llm_about", "frontend/dist", "bot.py", "web_api.py", + "pyproject.toml", "requirements.txt", ".dockerignore", + ): source = checked_path(root, name) if source.is_dir(): validate_tree(source) @@ -155,7 +167,9 @@ def capture_baseline(root: Path, baseline: Path) -> None: container = spec.get("container_name") if not container: raise ValueError(f"migration needs container_name for {service}") - image = subprocess.check_output(["docker", "inspect", "--format", "{{.Image}}", container], text=True).strip() + image = subprocess.check_output( + ["docker", "inspect", "--format", "{{.Image}}", container], text=True + ).strip() tag = f"quickquip-{service}:{baseline.name}" subprocess.run(["docker", "tag", image, tag], check=True) spec["image"] = tag @@ -173,14 +187,22 @@ def capture_baseline(root: Path, baseline: Path) -> None: if not source.is_relative_to(root): raise ValueError(f"external bind mount requires manual migration: {source}") relative = source.relative_to(root) - if relative.parts[0] == "data" or str(relative) in ("prod/llbot-qq", "prod/llbot-data", ".env"): + if relative.parts[0] == "data" or str(relative) in ( + "prod/llbot-qq", "prod/llbot-data", ".env", + ): volume["source"] = str(source) elif (baseline / relative).exists(): volume["source"] = str(baseline / relative) else: raise ValueError(f"unsupported bind mount: {relative}") atomic_write(baseline / "prod/docker-compose.yml", json.dumps(config).encode(), 0o600) - subprocess.run(["docker", "compose", "--env-file", str(root / ".env"), "-f", str(baseline / "prod/docker-compose.yml"), "config", "--quiet"], check=True) + subprocess.run( + [ + "docker", "compose", "--env-file", str(root / ".env"), + "-f", str(baseline / "prod/docker-compose.yml"), "config", "--quiet", + ], + check=True, + ) def main() -> None: @@ -201,5 +223,8 @@ def main() -> None: try: main() except PermissionError as exc: - print(f"deployment filesystem permission denied: {exc.filename or 'shared files'}", file=sys.stderr) + print( + f"deployment filesystem permission denied: {exc.filename or 'shared files'}", + file=sys.stderr, + ) raise SystemExit(3) from None diff --git a/pyproject.toml b/pyproject.toml index 828faa17..5fe939d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ exclude = [".venv"] [tool.ruff.lint] select = ["E", "F"] -ignore = ["E501"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/scripts/backfill_record_identities.py b/scripts/backfill_record_identities.py index ee7a7636..4eb82525 100644 --- a/scripts/backfill_record_identities.py +++ b/scripts/backfill_record_identities.py @@ -20,7 +20,11 @@ from quickquip.common.record_content import legacy, references, render, validate # noqa: E402 from quickquip.common.record_storage import migrate, save_parts # noqa: E402 -DATABASES = {"memories": LLM_DB_PATH, "quotes": QUOTES_DB_PATH, "offline_messages": OFFLINE_MESSAGES_DB_PATH} +DATABASES = { + "memories": LLM_DB_PATH, + "quotes": QUOTES_DB_PATH, + "offline_messages": OFFLINE_MESSAGES_DB_PATH, +} class ApplyResult(Enum): @@ -40,7 +44,10 @@ def _iter_rows(reader, table, group, record_id, batch_size): if record_id is not None: conditions.append("id=?") params.append(record_id) - rows = reader.execute(f"SELECT * FROM {table} WHERE {' AND '.join(conditions)} ORDER BY id LIMIT ?", (*params, batch_size)).fetchall() + rows = reader.execute( + f"SELECT * FROM {table} WHERE {' AND '.join(conditions)} ORDER BY id LIMIT ?", + (*params, batch_size), + ).fetchall() if not rows: return yield from rows @@ -48,7 +55,12 @@ def _iter_rows(reader, table, group, record_id, batch_size): def _prepare_writer(reader, path, table, report): - backup = path.with_name(path.name + ".identities-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + ".bak") + backup = path.with_name( + path.name + + ".identities-" + + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + + ".bak" + ) with closing(sqlite3.connect(backup)) as target: reader.backup(target) if report: @@ -71,20 +83,50 @@ def _preview_row(row, body): def _apply_row(writer, table, row, encoded, body): with writer: writer.execute("BEGIN IMMEDIATE") - current = writer.execute(f"SELECT content, content_parts_json FROM {table} WHERE id=?", (row["id"],)).fetchone() + current = writer.execute( + f"SELECT content, content_parts_json FROM {table} WHERE id=?", (row["id"],) + ).fetchone() if current is None or current[0] != row["content"] or current[1] != encoded: return ApplyResult.CONCURRENT_SKIPPED if encoded is None: save_parts(writer, table, row["id"], row["group_id"], body) return ApplyResult.WRITTEN - existing = {r[0] for r in writer.execute(f"SELECT qq FROM {table}_member_refs WHERE record_id=?", (row["id"],))} + existing = { + r[0] + for r in writer.execute( + f"SELECT qq FROM {table}_member_refs WHERE record_id=?", (row["id"],) + ) + } missing = references(body) - existing - writer.executemany(f"INSERT INTO {table}_member_refs(group_id, record_id, qq) VALUES (?, ?, ?)", [(row["group_id"], row["id"], qq) for qq in missing]) + writer.executemany( + f"INSERT INTO {table}_member_refs(group_id, record_id, qq) VALUES (?, ?, ?)", + [(row["group_id"], row["id"], qq) for qq in missing], + ) return ApplyResult.INDEX_REPAIRED if missing else ApplyResult.UNCHANGED -def backfill(path, table, *, apply=False, group=None, record_id=None, batch_size=200, before_write=None, preview_limit=0, report=None): - counts = dict(scanned=0, convertible=0, unparsed=0, existing=0, concurrent_skipped=0, failed=0, written=0, index_repaired=0) +def backfill( + path, + table, + *, + apply=False, + group=None, + record_id=None, + batch_size=200, + before_write=None, + preview_limit=0, + report=None, +): + counts = dict( + scanned=0, + convertible=0, + unparsed=0, + existing=0, + concurrent_skipped=0, + failed=0, + written=0, + index_repaired=0, + ) path = Path(path).resolve() if table not in DATABASES: raise ValueError("unsupported table") @@ -100,9 +142,23 @@ def backfill(path, table, *, apply=False, group=None, record_id=None, batch_size for row in _iter_rows(reader, table, group, record_id, batch_size): counts["scanned"] += 1 try: - encoded = row["content_parts_json"] if "content_parts_json" in row.keys() else None - body = validate(json.loads(encoded), max_length=1_000_000) if encoded is not None else legacy(row["content"]) - category = "existing" if encoded is not None else "convertible" if any(p["type"] != "text" for p in body["parts"]) else "unparsed" + encoded = ( + row["content_parts_json"] + if "content_parts_json" in row.keys() + else None + ) + body = ( + validate(json.loads(encoded), max_length=1_000_000) + if encoded is not None + else legacy(row["content"]) + ) + category = ( + "existing" + if encoded is not None + else "convertible" + if any(p["type"] != "text" for p in body["parts"]) + else "unparsed" + ) counts[category] += 1 if encoded is None and counts["scanned"] <= preview_limit and report: report(_preview_row(row, body)) @@ -123,7 +179,10 @@ def backfill(path, table, *, apply=False, group=None, record_id=None, batch_size def _emit(event): - print(json.dumps(event, ensure_ascii=False), file=sys.stderr if "error" in event else sys.stdout) + print( + json.dumps(event, ensure_ascii=False), + file=sys.stderr if "error" in event else sys.stdout, + ) def main(argv=None): @@ -134,7 +193,12 @@ def main(argv=None): parser.add_argument("--record-id", type=int) parser.add_argument("--batch-size", type=int, default=200) parser.add_argument("--apply", action="store_true") - parser.add_argument("--preview-limit", type=int, default=10, help="Maximum record examples per database; 0 prints counts only") + parser.add_argument( + "--preview-limit", + type=int, + default=10, + help="Maximum record examples per database; 0 prints counts only", + ) args = parser.parse_args(argv) if args.path and args.database == "all": parser.error("--path requires a single --database") @@ -145,7 +209,16 @@ def main(argv=None): if args.database not in {table, "all"}: continue try: - counts = backfill(args.path or default, table, apply=args.apply, group=args.group, record_id=args.record_id, batch_size=args.batch_size, preview_limit=args.preview_limit, report=_emit) + counts = backfill( + args.path or default, + table, + apply=args.apply, + group=args.group, + record_id=args.record_id, + batch_size=args.batch_size, + preview_limit=args.preview_limit, + report=_emit, + ) failed |= bool(counts["failed"]) _emit({"database": table, **counts}) except Exception as exc: diff --git a/scripts/ci/mcp_dep_audit.py b/scripts/ci/mcp_dep_audit.py index 906b26e8..ccd48519 100644 --- a/scripts/ci/mcp_dep_audit.py +++ b/scripts/ci/mcp_dep_audit.py @@ -46,7 +46,11 @@ def _install_and_list(pip: str, spec: str) -> set[str] | None: capture_output=True, text=True, ) - return {line.split("==")[0].lower() for line in result.stdout.strip().splitlines() if "==" in line} + return { + line.split("==")[0].lower() + for line in result.stdout.strip().splitlines() + if "==" in line + } def main() -> int: diff --git a/src/quickquip/adapters/nonebot/_forward.py b/src/quickquip/adapters/nonebot/_forward.py index b9b8c105..dfafab1f 100644 --- a/src/quickquip/adapters/nonebot/_forward.py +++ b/src/quickquip/adapters/nonebot/_forward.py @@ -49,7 +49,13 @@ def _extract_forward_payload(message) -> tuple[str, list[object]]: return "", [] -def _format_forward_sender(sender_name: str, user_id: str, *, bot_keys: set[str], identities: IdentityIndex) -> str: +def _format_forward_sender( + sender_name: str, + user_id: str, + *, + bot_keys: set[str], + identities: IdentityIndex, +) -> str: normalized_user_id = user_id.strip() if normalized_user_id and normalized_user_id in bot_keys: return f"机器人(QQ {normalized_user_id})" @@ -167,7 +173,11 @@ async def _render_forward_content( try: result = await bot.call_api("get_forward_msg", message_id=nested_id) except Exception: - logger.warning("Failed to fetch nested forward message id=%s", nested_id, exc_info=True) + logger.warning( + "Failed to fetch nested forward message id=%s", + nested_id, + exc_info=True, + ) nested_text = "" else: nested_nodes = [] @@ -263,5 +273,8 @@ async def extract_forward_content( visited_forward_ids={forward_id} if forward_id else set(), ) if len(rendered_text) > MAX_FORWARD_TEXT_CHARS: - rendered_text = rendered_text[:MAX_FORWARD_TEXT_CHARS].rstrip() + "…(合并转发内容过长,已截断)" + rendered_text = ( + rendered_text[:MAX_FORWARD_TEXT_CHARS].rstrip() + + "…(合并转发内容过长,已截断)" + ) return rendered_text, image_urls diff --git a/src/quickquip/adapters/nonebot/_llm_reply.py b/src/quickquip/adapters/nonebot/_llm_reply.py index feba80ae..5ba37906 100644 --- a/src/quickquip/adapters/nonebot/_llm_reply.py +++ b/src/quickquip/adapters/nonebot/_llm_reply.py @@ -135,7 +135,11 @@ async def __call__(self, delivery_id: str, payload: dict[str, Any]) -> DeliveryR return DeliveryReceipt(status=DeliveryStatus.UNKNOWN, error_code="missing_message_id") -def text_only_message(text: str, Message: type[OneBotMessage], MessageSegment: type[OneBotMessageSegment]) -> OneBotMessage: +def text_only_message( + text: str, + Message: type[OneBotMessage], + MessageSegment: type[OneBotMessageSegment], +) -> OneBotMessage: """纯文本 Message(§6.2):分段正文不经 CQ 解析器。 正文中的 ``@QQ 号`` 数字艾特在此出口切分为真实 at 段(分段交付与 @@ -144,7 +148,9 @@ def text_only_message(text: str, Message: type[OneBotMessage], MessageSegment: t return Message(split_outbound_at_mentions(text, Message, MessageSegment)) -def make_matcher_sink(matcher, Message, MessageSegment, *, scope_key: str, interval_ms: int) -> OneBotDeliverySink: +def make_matcher_sink( + matcher, Message, MessageSegment, *, scope_key: str, interval_ms: int +) -> OneBotDeliverySink: return OneBotDeliverySink( lambda text: matcher.send(text_only_message(text, Message, MessageSegment)), scope_key=scope_key, @@ -152,7 +158,9 @@ def make_matcher_sink(matcher, Message, MessageSegment, *, scope_key: str, inter ) -def make_group_bot_sink(bot, Message, MessageSegment, *, group_id: int | str, interval_ms: int) -> OneBotDeliverySink: +def make_group_bot_sink( + bot, Message, MessageSegment, *, group_id: int | str, interval_ms: int +) -> OneBotDeliverySink: async def _send(text: str): return await bot.send_group_msg( group_id=int(group_id), @@ -162,7 +170,9 @@ async def _send(text: str): return OneBotDeliverySink(_send, scope_key=str(group_id), interval_ms=interval_ms) -def make_private_bot_sink(bot, Message, MessageSegment, *, user_id: int | str, interval_ms: int) -> OneBotDeliverySink: +def make_private_bot_sink( + bot, Message, MessageSegment, *, user_id: int | str, interval_ms: int +) -> OneBotDeliverySink: async def _send(text: str): return await bot.send_private_msg( user_id=int(user_id), diff --git a/src/quickquip/adapters/nonebot/awakening_plugin.py b/src/quickquip/adapters/nonebot/awakening_plugin.py index 8bed1402..4a992fe3 100644 --- a/src/quickquip/adapters/nonebot/awakening_plugin.py +++ b/src/quickquip/adapters/nonebot/awakening_plugin.py @@ -197,7 +197,10 @@ async def _(event): lines.append(f"唤醒延长: {settings.extend_duration}s") lines.append(f"兴趣话题: {settings.interest_topics or '(未配置)'}") lines.append(f"兜底概率: {settings.fallback_probability}") - lines.append(f"无聊沉寂: {settings.boredom_silence_seconds}s / 概率 {settings.boredom_probability}") + lines.append( + f"无聊沉寂: {settings.boredom_silence_seconds}s" + f" / 概率 {settings.boredom_probability}" + ) lines.append(f"无聊检查间隔: {settings.boredom_check_interval}s") lines.append(f"相关性阈值: {settings.relevance_threshold} (>=1 关闭)") lines.append(f"答疑阈值: {settings.qa_threshold} (>=1 关闭)") @@ -211,7 +214,11 @@ async def _(event): if not _is_admin(event): await cmd.finish("仅管理员可执行此操作") if len(tokens) < 2: - await cmd.finish("用法: /awakening on <规则名>\n可选: awakening_extend, awakening_interest, awakening_fallback, awakening_boredom, awakening_relevance, awakening_qa") + await cmd.finish( + "用法: /awakening on <规则名>\n" + "可选: awakening_extend, awakening_interest, awakening_fallback, " + "awakening_boredom, awakening_relevance, awakening_qa" + ) rule_name = tokens[1] if rule_name not in AWAKENING_RULE_NAMES: await cmd.finish(f"未知规则: {rule_name}") diff --git a/src/quickquip/adapters/nonebot/command_parts/_chat_utils.py b/src/quickquip/adapters/nonebot/command_parts/_chat_utils.py index 9afd8365..c9302d66 100644 --- a/src/quickquip/adapters/nonebot/command_parts/_chat_utils.py +++ b/src/quickquip/adapters/nonebot/command_parts/_chat_utils.py @@ -4,7 +4,10 @@ def _is_private_chat(event) -> bool: - return getattr(event, "message_type", "") == "private" or getattr(event, "group_id", None) is None + return ( + getattr(event, "message_type", "") == "private" + or getattr(event, "group_id", None) is None + ) def _chat_type(event) -> str: diff --git a/src/quickquip/adapters/nonebot/command_parts/_formatting.py b/src/quickquip/adapters/nonebot/command_parts/_formatting.py index bc828f99..51f4b0b2 100644 --- a/src/quickquip/adapters/nonebot/command_parts/_formatting.py +++ b/src/quickquip/adapters/nonebot/command_parts/_formatting.py @@ -10,7 +10,11 @@ def _format_tts_models(audio_generation) -> str: for model_id, resolved in audio_generation.models.items(): label = resolved.model_config.label or model_id default_mark = "(默认)" if model_id == audio_generation.default_model else "" - voice_hint = f" / 默认音色 {resolved.model_config.voice_id}" if resolved.model_config.voice_id else "" + voice_hint = ( + f" / 默认音色 {resolved.model_config.voice_id}" + if resolved.model_config.voice_id + else "" + ) lines.append( f"- {model_id}:{label} / provider {resolved.provider.id}{voice_hint}{default_mark}" ) diff --git a/src/quickquip/adapters/nonebot/command_parts/_parsing.py b/src/quickquip/adapters/nonebot/command_parts/_parsing.py index 7510b75b..ee9cad3f 100644 --- a/src/quickquip/adapters/nonebot/command_parts/_parsing.py +++ b/src/quickquip/adapters/nonebot/command_parts/_parsing.py @@ -42,7 +42,12 @@ def _parse_profile_mode(message_text: str) -> ProfileModeConfig: return DEFAULT_PROFILE_MODE -_PRESET_RE = re.compile(r'--preset\s+(?:"((?:[^"\\]|\\.)*)"|\'((?:[^\'\\]|\\.)*)\'|(\S.*))', re.DOTALL) +_PRESET_RE = re.compile( + r'--preset\s+(?:"((?:[^"\\]|\\.)*)"|' + r'\'((?:[^\'\\]|\\.)*)\'' + r'|(\S.*))', + re.DOTALL, +) _RESUME_RE = re.compile(r'--resume(?:\s+(\d+))?') _DICE_RE = re.compile(r"^(\d*)[dD](\d+)$") _DRAW_SIZE_RE = re.compile(r'--size\s+(\d+x\d+)', re.IGNORECASE) diff --git a/src/quickquip/adapters/nonebot/command_parts/common.py b/src/quickquip/adapters/nonebot/command_parts/common.py index f39e2a6f..a8abf953 100644 --- a/src/quickquip/adapters/nonebot/command_parts/common.py +++ b/src/quickquip/adapters/nonebot/command_parts/common.py @@ -10,11 +10,15 @@ from __future__ import annotations # ── chat utils ─────────────────────────────────────────────────────────────── -from quickquip.adapters.nonebot.command_parts._chat_utils import _allow_scope_management as _allow_scope_management # noqa: F401 +from quickquip.adapters.nonebot.command_parts._chat_utils import ( + _allow_scope_management as _allow_scope_management, # noqa: F401 +) from quickquip.adapters.nonebot.command_parts._chat_utils import _chat_id as _chat_id # noqa: F401 from quickquip.adapters.nonebot.command_parts._chat_utils import _chat_label as _chat_label # noqa: F401 from quickquip.adapters.nonebot.command_parts._chat_utils import _chat_type as _chat_type # noqa: F401 -from quickquip.adapters.nonebot.command_parts._chat_utils import _is_private_chat as _is_private_chat # noqa: F401 +from quickquip.adapters.nonebot.command_parts._chat_utils import ( + _is_private_chat as _is_private_chat, # noqa: F401 +) # ── event utils (is_admin / strip_command_name 直接从 common.event_utils re-export, # 修 v1.8.9 PR-6 跨层遗留:不再经 app.message_pipeline 间接获取) ────────────── @@ -31,9 +35,15 @@ from quickquip.adapters.nonebot.command_parts._fortune import _NUMBER_EMOJIS as _NUMBER_EMOJIS # noqa: F401 # ── content ────────────────────────────────────────────────────────────────── -from quickquip.adapters.nonebot.command_parts._content import _extract_image_urls as _extract_image_urls # noqa: F401 -from quickquip.adapters.nonebot.command_parts._content import _resolve_forward_content as _resolve_forward_content # noqa: F401 -from quickquip.adapters.nonebot.command_parts._content import _resolve_message_content as _resolve_message_content # noqa: F401 +from quickquip.adapters.nonebot.command_parts._content import ( + _extract_image_urls as _extract_image_urls, # noqa: F401 +) +from quickquip.adapters.nonebot.command_parts._content import ( + _resolve_forward_content as _resolve_forward_content, # noqa: F401 +) +from quickquip.adapters.nonebot.command_parts._content import ( + _resolve_message_content as _resolve_message_content, # noqa: F401 +) # ── parsing ────────────────────────────────────────────────────────────────── from quickquip.adapters.nonebot.command_parts._parsing import _DICE_RE as _DICE_RE # noqa: F401 @@ -41,21 +51,41 @@ from quickquip.adapters.nonebot.command_parts._parsing import _DRAW_SIZE_RE as _DRAW_SIZE_RE # noqa: F401 from quickquip.adapters.nonebot.command_parts._parsing import _parse_music_args as _parse_music_args # noqa: F401 from quickquip.adapters.nonebot.command_parts._parsing import _parse_preset as _parse_preset # noqa: F401 -from quickquip.adapters.nonebot.command_parts._parsing import _parse_profile_mode as _parse_profile_mode # noqa: F401 +from quickquip.adapters.nonebot.command_parts._parsing import ( + _parse_profile_mode as _parse_profile_mode, # noqa: F401 +) from quickquip.adapters.nonebot.command_parts._parsing import _parse_resume as _parse_resume # noqa: F401 -from quickquip.adapters.nonebot.command_parts._parsing import _parse_tieba_command_args as _parse_tieba_command_args # noqa: F401 +from quickquip.adapters.nonebot.command_parts._parsing import ( + _parse_tieba_command_args as _parse_tieba_command_args, # noqa: F401 +) from quickquip.adapters.nonebot.command_parts._parsing import _parse_tts_args as _parse_tts_args # noqa: F401 from quickquip.adapters.nonebot.command_parts._parsing import _PRESET_RE as _PRESET_RE # noqa: F401 from quickquip.adapters.nonebot.command_parts._parsing import _RESUME_RE as _RESUME_RE # noqa: F401 from quickquip.adapters.nonebot.command_parts._parsing import _safe_shlex_split as _safe_shlex_split # noqa: F401 -from quickquip.adapters.nonebot.command_parts._parsing import _select_profile_samples as _select_profile_samples # noqa: F401 -from quickquip.adapters.nonebot.command_parts._parsing import _strip_leading_command_token as _strip_leading_command_token # noqa: F401 +from quickquip.adapters.nonebot.command_parts._parsing import ( + _select_profile_samples as _select_profile_samples, # noqa: F401 +) +from quickquip.adapters.nonebot.command_parts._parsing import ( + _strip_leading_command_token as _strip_leading_command_token, # noqa: F401 +) from quickquip.adapters.nonebot.command_parts._parsing import MusicCommandArgs as MusicCommandArgs # noqa: F401 # ── formatting ─────────────────────────────────────────────────────────────── -from quickquip.adapters.nonebot.command_parts._formatting import _chunk_text as _chunk_text # noqa: F401 -from quickquip.adapters.nonebot.command_parts._formatting import _format_generated_lyrics as _format_generated_lyrics # noqa: F401 -from quickquip.adapters.nonebot.command_parts._formatting import _format_music_models as _format_music_models # noqa: F401 -from quickquip.adapters.nonebot.command_parts._formatting import _format_tts_models as _format_tts_models # noqa: F401 -from quickquip.adapters.nonebot.command_parts._formatting import _format_voice_groups as _format_voice_groups # noqa: F401 -from quickquip.adapters.nonebot.command_parts._formatting import _send_lyrics_forward as _send_lyrics_forward # noqa: F401 +from quickquip.adapters.nonebot.command_parts._formatting import ( + _chunk_text as _chunk_text, # noqa: F401 +) +from quickquip.adapters.nonebot.command_parts._formatting import ( + _format_generated_lyrics as _format_generated_lyrics, # noqa: F401 +) +from quickquip.adapters.nonebot.command_parts._formatting import ( + _format_music_models as _format_music_models, # noqa: F401 +) +from quickquip.adapters.nonebot.command_parts._formatting import ( + _format_tts_models as _format_tts_models, # noqa: F401 +) +from quickquip.adapters.nonebot.command_parts._formatting import ( + _format_voice_groups as _format_voice_groups, # noqa: F401 +) +from quickquip.adapters.nonebot.command_parts._formatting import ( + _send_lyrics_forward as _send_lyrics_forward, # noqa: F401 +) diff --git a/src/quickquip/adapters/nonebot/command_parts/games.py b/src/quickquip/adapters/nonebot/command_parts/games.py index 2e869ae3..31699a23 100644 --- a/src/quickquip/adapters/nonebot/command_parts/games.py +++ b/src/quickquip/adapters/nonebot/command_parts/games.py @@ -41,9 +41,14 @@ async def _(event): active_name = game_registry.get_active_game_name(group_id) if active_name: await game_cmd.finish(f"本群已有进行中的游戏:{active_name},请先 /game stop 结束") - opening = game_registry.start_game(group_id, str(event.user_id), game, start_arg=start_arg) + opening = game_registry.start_game( + group_id, str(event.user_id), game, start_arg=start_arg + ) if opening is None: - await game_cmd.finish(f"本群已有进行中的游戏:{game_registry.get_active_game_name(group_id)},请先 /game stop 结束") + await game_cmd.finish( + f"本群已有进行中的游戏:{game_registry.get_active_game_name(group_id)}," + f"请先 /game stop 结束" + ) await game_cmd.finish(opening) if sub == "stop": diff --git a/src/quickquip/adapters/nonebot/command_parts/history.py b/src/quickquip/adapters/nonebot/command_parts/history.py index 409986b9..41aa70e7 100644 --- a/src/quickquip/adapters/nonebot/command_parts/history.py +++ b/src/quickquip/adapters/nonebot/command_parts/history.py @@ -6,9 +6,21 @@ from datetime import datetime from time import time -from quickquip.adapters.nonebot.command_parts.common import _is_private_chat, _parse_profile_mode, _select_profile_samples, _strip_command_name +from quickquip.adapters.nonebot.command_parts.common import ( + _is_private_chat, + _parse_profile_mode, + _select_profile_samples, + _strip_command_name, +) from quickquip.adapters.nonebot.long_messages import send_long_group_message -from quickquip.app.message_pipeline import _ensure_llm_bindings, chat_archive, get_llm_service, get_sender_identity_sources, group_quote_store, stats_tracker +from quickquip.app.message_pipeline import ( + _ensure_llm_bindings, + chat_archive, + get_llm_service, + get_sender_identity_sources, + group_quote_store, + stats_tracker, +) from quickquip.chat.group_quotes import resolve_quote_display_name from quickquip.llm.profile import generate_profile from quickquip.llm.provider import LLMProviderError @@ -31,7 +43,9 @@ def _snapshot_from_sources(sources) -> IdentitySnapshot: def _quote_display_name(group_id, quoted_user_id: str, snapshot_name: str, sources=None) -> str: snapshot = _snapshot_from_sources(sources or get_sender_identity_sources(str(group_id))) - resolved, changed = resolve_quote_display_name(quoted_user_id, snapshot_name, identity_snapshot=snapshot) + resolved, changed = resolve_quote_display_name( + quoted_user_id, snapshot_name, identity_snapshot=snapshot + ) if snapshot.ambiguous(snapshot.candidates(resolved)): resolved = f"{resolved}(QQ {quoted_user_id})" if changed: @@ -45,7 +59,9 @@ def _format_quote_rows(rows, group_id, header: str, sources=None) -> str: for row in rows: content = render(decode(row["content"], row.get("content_parts_json")), snapshot) preview = content[:40] + ("…" if len(content) > 40 else "") - name = _quote_display_name(group_id, row.get("quoted_user_id", ""), row["quoted_sender_name"], snapshot) + name = _quote_display_name( + group_id, row.get("quoted_user_id", ""), row["quoted_sender_name"], snapshot + ) lines.append(f"#{row['group_seq']} 「{preview}」—— {name}") return "\n".join(lines) @@ -88,7 +104,9 @@ async def _(bot, event): if m: target_user_id = m.group(1) if not target_user_id: - await profile_cmd.finish(MessageSegment.text("用法:/profile [short|middle|long|full] @某人")) + await profile_cmd.finish( + MessageSegment.text("用法:/profile [short|middle|long|full] @某人") + ) group_id = event.group_id profile_mode = _parse_profile_mode(str(event.get_message())) @@ -136,10 +154,16 @@ async def _(bot, event): ), ) except Exception: - logger.exception("profile data collection failed for group=%s user=%s", group_id, target_user_id) + logger.exception( + "profile data collection failed for group=%s user=%s", + group_id, + target_user_id, + ) await profile_cmd.finish(MessageSegment.text("收集用户数据时出错,请稍后重试")) - memories = [m.get("content_display", m["content"]) for m in memories_raw if m.get("content")] + memories = [ + m.get("content_display", m["content"]) for m in memories_raw if m.get("content") + ] samples = _select_profile_samples( all_msgs, str(target_user_id), @@ -188,9 +212,15 @@ async def _(event): snapshot = identities.snapshot(group_id) hits = await asyncio.to_thread(_find_hits, messages, keyword, snapshot) if not hits: - await find_cmd.finish(MessageSegment.text(f"没有找到包含「{keyword}」的消息(最近 30 天)")) + await find_cmd.finish( + MessageSegment.text(f"没有找到包含「{keyword}」的消息(最近 30 天)") + ) shown = hits[-5:] - header = f"找到 {len(hits)} 条,显示最新 5 条:" if len(hits) > 5 else f"找到 {len(hits)} 条:" + header = ( + f"找到 {len(hits)} 条,显示最新 5 条:" + if len(hits) > 5 + else f"找到 {len(hits)} 条:" + ) lines = [header] for m in shown: ts = datetime.fromtimestamp(m["ts"]).strftime("%m-%d %H:%M") @@ -216,11 +246,20 @@ async def _(event, bot=None): if args.lower() == "random" or (not args and not reply): q = group_quote_store.random(group_id, identity_snapshot=snapshot) if q is None: - await quote_cmd.finish(MessageSegment.text("语录库还是空的,引用一条消息发 /quote 来收藏吧")) + await quote_cmd.finish( + MessageSegment.text("语录库还是空的,引用一条消息发 /quote 来收藏吧") + ) ts = datetime.fromtimestamp(q["saved_at"]).strftime("%m-%d") seq_str = f"#{q.get('group_seq', '?')} " if q.get('group_seq') else "" - display = _quote_display_name(group_id, q.get("quoted_user_id", ""), q["quoted_sender_name"], sources) - await quote_cmd.finish(MessageSegment.text(f"{seq_str}「{q.get('content_display', q['content'])}」\n—— {display} ({ts})")) + display = _quote_display_name( + group_id, q.get("quoted_user_id", ""), q["quoted_sender_name"], sources + ) + await quote_cmd.finish( + MessageSegment.text( + f"{seq_str}「{q.get('content_display', q['content'])}」" + f"\n—— {display} ({ts})" + ) + ) # /quote N or /quote #N → get by group_seq seq_match = re.match(r"^#?(\d+)$", args) @@ -230,17 +269,36 @@ async def _(event, bot=None): if q is None: await quote_cmd.finish(MessageSegment.text(f"本群没有编号为 #{seq} 的语录")) ts = datetime.fromtimestamp(q["saved_at"]).strftime("%m-%d") - display = _quote_display_name(group_id, q.get("quoted_user_id", ""), q["quoted_sender_name"], sources) - await quote_cmd.finish(MessageSegment.text(f"#{seq} 「{q.get('content_display', q['content'])}」\n—— {display} ({ts})")) + display = _quote_display_name( + group_id, q.get("quoted_user_id", ""), q["quoted_sender_name"], sources + ) + await quote_cmd.finish( + MessageSegment.text( + f"#{seq} 「{q.get('content_display', q['content'])}」" + f"\n—— {display} ({ts})" + ) + ) # /quote search or /quote s search_match = re.match(r"^(?:search|s)\s+(.+)$", args, re.IGNORECASE) if search_match: keyword = search_match.group(1).strip() - rows, total = await asyncio.to_thread(group_quote_store.search, group_id, keyword, limit=10, identity_snapshot=snapshot) + rows, total = await asyncio.to_thread( + group_quote_store.search, + group_id, + keyword, + limit=10, + identity_snapshot=snapshot, + ) if not rows: await quote_cmd.finish(MessageSegment.text(f"未找到包含「{keyword}」的语录")) - await quote_cmd.finish(MessageSegment.text(_format_quote_rows(rows, group_id, f"🔍 「{keyword}」(共 {total} 条):", sources))) + await quote_cmd.finish( + MessageSegment.text( + _format_quote_rows( + rows, group_id, f"🔍 「{keyword}」(共 {total} 条):", sources + ) + ) + ) # /quote by <名字|QQ> or /quote b <名字|QQ> → by sender by_match = re.match(r"^(?:by|b)\s+(.+)$", args, re.IGNORECASE) @@ -257,7 +315,13 @@ async def _(event, bot=None): ) if not rows: await quote_cmd.finish(MessageSegment.text(f"未找到「{query}」发言的语录")) - await quote_cmd.finish(MessageSegment.text(_format_quote_rows(rows, group_id, f"👤 「{query}」的语录(共 {total} 条):", sources))) + await quote_cmd.finish( + MessageSegment.text( + _format_quote_rows( + rows, group_id, f"👤 「{query}」的语录(共 {total} 条):", sources + ) + ) + ) if not reply: await quote_cmd.finish( @@ -269,13 +333,26 @@ async def _(event, bot=None): "引用消息 + /quote — 收藏语录") ) quoted_user = str(getattr(reply, "user_id", "") or "") - body, prepared_snapshot = await prepare_body(reply_source(reply), group_id, bot, extra_ids=[quoted_user]) - if not any(p["type"] == "text" and p["text"].strip() or p["type"] in {"member", "all"} for p in body["parts"]): + body, prepared_snapshot = await prepare_body( + reply_source(reply), group_id, bot, extra_ids=[quoted_user] + ) + if not any( + p["type"] == "text" and p["text"].strip() or p["type"] in {"member", "all"} + for p in body["parts"] + ): await quote_cmd.finish(MessageSegment.text("引用的消息没有文字内容,无法收藏")) content = render(body) sender = getattr(reply, "sender", None) - sender_name = (sender.get("card") or sender.get("nickname")) if isinstance(sender, dict) else (getattr(sender, "card", "") or getattr(sender, "nickname", "")) - sender_name = sender_name or getattr(reply, "nickname", "") or prepared_snapshot.names.get(quoted_user, "") + sender_name = ( + (sender.get("card") or sender.get("nickname")) + if isinstance(sender, dict) + else (getattr(sender, "card", "") or getattr(sender, "nickname", "")) + ) + sender_name = ( + sender_name + or getattr(reply, "nickname", "") + or prepared_snapshot.names.get(quoted_user, "") + ) if len(content) > 500: await quote_cmd.finish(MessageSegment.text("内容过长(限 500 字),无法收藏")) try: diff --git a/src/quickquip/adapters/nonebot/command_parts/llm.py b/src/quickquip/adapters/nonebot/command_parts/llm.py index 24514233..42c83a8e 100644 --- a/src/quickquip/adapters/nonebot/command_parts/llm.py +++ b/src/quickquip/adapters/nonebot/command_parts/llm.py @@ -2,7 +2,15 @@ from typing import NamedTuple -from quickquip.adapters.nonebot.command_parts.common import _allow_scope_management, _chat_id, _chat_label, _chat_type, _parse_preset, _parse_resume, _strip_command_name +from quickquip.adapters.nonebot.command_parts.common import ( + _allow_scope_management, + _chat_id, + _chat_label, + _chat_type, + _parse_preset, + _parse_resume, + _strip_command_name, +) from quickquip.app.message_pipeline import _ensure_llm_bindings, get_llm_service, rate_limiter from quickquip.llm.epoch import DEFAULT_EPOCH_MAX_ROWS from quickquip.llm.settings import DeliveryDomain @@ -75,7 +83,8 @@ def _domain_default(scope_domain: DeliveryDomain) -> str: # 概览与单域显式互斥,不依赖 finish 的终止副作用兜底控制流。 if domain is None or domain is DeliveryDomain.ALL: await llm_cmd.finish( - f"{scope_label}分段交付:中间轮 {views.current_intermediate}(默认 {views.default_intermediate})" + f"{scope_label}分段交付:" + f"中间轮 {views.current_intermediate}(默认 {views.default_intermediate})" f" / 最终轮 {views.current_final}(默认 {views.default_final})" ) else: @@ -85,7 +94,8 @@ def _domain_default(scope_domain: DeliveryDomain) -> str: else views.current_final ) await llm_cmd.finish( - f"{scope_label}{_DELIVERY_DOMAIN_LABELS[domain]}:{current}(全局默认 {_domain_default(domain)})" + f"{scope_label}{_DELIVERY_DOMAIN_LABELS[domain]}:{current}" + f"(全局默认 {_domain_default(domain)})" ) @@ -163,14 +173,20 @@ async def _(event): scope_key = svc.build_chat_scope_key(chat_id, "private") svc._session_presets[scope_key] = preset_override preset = preset_override or result.get("preset", "") - msg = f"已恢复存档 #{result['archive_number']}({result['message_count']} 条消息)" + msg = ( + f"已恢复存档 #{result['archive_number']}" + f"({result['message_count']} 条消息)" + ) if preset: preview = preset[:80] + ("..." if len(preset) > 80 else "") msg += f"\n附加设定:{preview}" await llm_cmd.finish(msg) preset = _parse_preset(args) svc.start_private_session(chat_id, preset=preset) - msg = f"{scope_label}会话已开启。也可以直接使用 /start_sesssion,上下文由会话纪元自动管理。" + msg = ( + f"{scope_label}会话已开启。" + f"也可以直接使用 /start_sesssion,上下文由会话纪元自动管理。" + ) if preset: preview = preset[:80] + ("..." if len(preset) > 80 else "") msg += f"\n附加设定:{preview}" @@ -186,10 +202,16 @@ async def _(event): deleted = result["deleted"] archive_number = result.get("archive_number") if archive_number is not None: - await llm_cmd.finish(f"{scope_label}会话已结束,已存档为 #{archive_number}({deleted} 条消息)。") + await llm_cmd.finish( + f"{scope_label}会话已结束,已存档为 #{archive_number}" + f"({deleted} 条消息)。" + ) else: suffix = "(未存档)" if no_save else "" - await llm_cmd.finish(f"{scope_label}会话已结束,并清空了 {deleted} 条短期上下文。{suffix}") + await llm_cmd.finish( + f"{scope_label}会话已结束," + f"并清空了 {deleted} 条短期上下文。{suffix}" + ) else: svc.set_chat_enabled(chat_id, False, chat_type=chat_type) await llm_cmd.finish(f"{scope_label} LLM 已关闭") @@ -202,7 +224,9 @@ async def _(event): if config.load_error: await llm_cmd.finish(f"LLM 配置重载失败:{config.load_error}") await llm_cmd.send("LLM 配置已重载,正在探活当前 provider/model…") - await llm_cmd.finish(await svc.format_current_provider_probe(chat_id, chat_type=chat_type)) + await llm_cmd.finish( + await svc.format_current_provider_probe(chat_id, chat_type=chat_type) + ) if args == "clear_context": deleted = svc.clear_context(chat_id, chat_type=chat_type) @@ -216,7 +240,10 @@ async def _(event): if not target_msg_id and len(tokens) >= 2: target_msg_id = tokens[1].strip() if not target_msg_id: - await llm_cmd.finish("用法:引用一条消息并发送 /llm delete_msg,或 /llm delete_msg <消息ID>") + await llm_cmd.finish( + "用法:引用一条消息并发送 /llm delete_msg," + "或 /llm delete_msg <消息ID>" + ) scope_key = svc.build_chat_scope_key(chat_id, chat_type) deleted = svc.delete_message_from_context(scope_key, target_msg_id) if deleted: @@ -317,14 +344,20 @@ async def _(event): if n < 1: await llm_cmd.finish("上下文上限须为正整数") if n > DEFAULT_EPOCH_MAX_ROWS: - await llm_cmd.finish(f"上下文上限最大 {DEFAULT_EPOCH_MAX_ROWS} 条(纪元行数兜底上限)") + await llm_cmd.finish( + f"上下文上限最大 {DEFAULT_EPOCH_MAX_ROWS} 条(纪元行数兜底上限)" + ) svc.set_chat_history_limit(chat_id, n, chat_type=chat_type) await llm_cmd.finish(f"{scope_label}上下文上限已设为 {n} 条(行数兜底,超出截断)") await llm_cmd.finish( - "LLM 命令用法:/llm status|current|on|off|providers|probe|models [provider]|use [model]|" - "personas|persona use |trigger prefix |trigger prefix_mode on|off|trigger at on|off|" - "memory status|memory on|memory off|auto_memory on|off|reset|status|delivery intermediate|final|all |delivery status|" + "LLM 命令用法:/llm status|current|on|off|providers|" + "probe|models [provider]|use [model]|" + "personas|persona use |trigger prefix |" + "trigger prefix_mode on|off|trigger at on|off|" + "memory status|memory on|memory off|" + "auto_memory on|off|reset|status|" + "delivery intermediate|final|all |delivery status|" "context_limit |context_limit reset|clear_context|reload|mcp status" ) diff --git a/src/quickquip/adapters/nonebot/command_parts/media.py b/src/quickquip/adapters/nonebot/command_parts/media.py index 080d0530..d084b1ec 100644 --- a/src/quickquip/adapters/nonebot/command_parts/media.py +++ b/src/quickquip/adapters/nonebot/command_parts/media.py @@ -3,7 +3,20 @@ from io import BytesIO from quickquip.adapters.nonebot.command_parts._chat_utils import _scope_key -from quickquip.adapters.nonebot.command_parts.common import _DRAW_QUALITY_RE, _DRAW_SIZE_RE, _extract_image_urls, _format_music_models, _format_tts_models, _format_voice_groups, _parse_music_args, _parse_tts_args, _resolve_message_content, _safe_shlex_split, _send_lyrics_forward, _strip_command_name +from quickquip.adapters.nonebot.command_parts.common import ( + _DRAW_QUALITY_RE, + _DRAW_SIZE_RE, + _extract_image_urls, + _format_music_models, + _format_tts_models, + _format_voice_groups, + _parse_music_args, + _parse_tts_args, + _resolve_message_content, + _safe_shlex_split, + _send_lyrics_forward, + _strip_command_name, +) from quickquip.app.message_pipeline import rate_limiter from quickquip.common.sensitive_filter import ( DEFAULT_OUTPUT_FALLBACK, @@ -128,7 +141,11 @@ async def _(bot, event): if raw_args.startswith("voices"): pieces = _safe_shlex_split(raw_args) - maybe_model = pieces[1] if len(pieces) > 1 and pieces[1] in audio_generation.models else None + maybe_model = ( + pieces[1] + if len(pieces) > 1 and pieces[1] in audio_generation.models + else None + ) keyword = "" if maybe_model is not None: keyword = " ".join(pieces[2:]).strip() diff --git a/src/quickquip/adapters/nonebot/command_parts/memory.py b/src/quickquip/adapters/nonebot/command_parts/memory.py index b659724b..e944fa1a 100644 --- a/src/quickquip/adapters/nonebot/command_parts/memory.py +++ b/src/quickquip/adapters/nonebot/command_parts/memory.py @@ -4,8 +4,20 @@ from quickquip.app.identities import identities from quickquip.common.record_content import QQ, render -from quickquip.adapters.nonebot.command_parts.common import _allow_scope_management, _chat_id, _chat_label, _chat_type, _is_private_chat, _strip_command_name -from quickquip.app.message_pipeline import _ensure_llm_bindings, get_llm_service, get_sender_name, offline_message_store +from quickquip.adapters.nonebot.command_parts.common import ( + _allow_scope_management, + _chat_id, + _chat_label, + _chat_type, + _is_private_chat, + _strip_command_name, +) +from quickquip.app.message_pipeline import ( + _ensure_llm_bindings, + get_llm_service, + get_sender_name, + offline_message_store, +) def register_memory_commands(on_command, Message, MessageSegment) -> None: @@ -15,7 +27,12 @@ def register_memory_commands(on_command, Message, MessageSegment) -> None: async def _(event, bot=None): if not _allow_scope_management(event): await remember_cmd.finish(MessageSegment.text("仅管理员可执行此操作")) - body, _ = await prepare_body(event.get_message(), _chat_id(event) if not _is_private_chat(event) else "", bot, "remember") + body, _ = await prepare_body( + event.get_message(), + _chat_id(event) if not _is_private_chat(event) else "", + bot, + "remember", + ) content = render(body).strip() if not content: await remember_cmd.finish(MessageSegment.text("用法:/remember <要保存的记忆>")) @@ -24,10 +41,14 @@ async def _(event, bot=None): chat_type = _chat_type(event) chat_id = _chat_id(event) try: - memory_id = svc.remember_memory(chat_id, content, chat_type=chat_type, content_parts=body) + memory_id = svc.remember_memory( + chat_id, content, chat_type=chat_type, content_parts=body + ) except ValueError as exc: await remember_cmd.finish(MessageSegment.text(str(exc))) - await remember_cmd.finish(MessageSegment.text(f"已写入{_chat_label(event)}记忆 #{memory_id}")) + await remember_cmd.finish( + MessageSegment.text(f"已写入{_chat_label(event)}记忆 #{memory_id}") + ) memories_cmd = on_command("memories", priority=10, block=True) @@ -36,7 +57,9 @@ async def _(event): _ensure_llm_bindings() svc = get_llm_service() keyword = _strip_command_name(str(event.get_message()).strip(), "memories") - reply = svc.format_memories(_chat_id(event), keyword=keyword or None, chat_type=_chat_type(event)) + reply = svc.format_memories( + _chat_id(event), keyword=keyword or None, chat_type=_chat_type(event) + ) await memories_cmd.finish(MessageSegment.text(reply)) forget_cmd = on_command("forget", priority=10, block=True) @@ -54,7 +77,9 @@ async def _(event): deleted = svc.forget_memories(_chat_id(event), keyword, chat_type=_chat_type(event)) except ValueError as exc: await forget_cmd.finish(MessageSegment.text(str(exc))) - await forget_cmd.finish(MessageSegment.text(f"已删除{_chat_label(event)}中的 {deleted} 条记忆")) + await forget_cmd.finish( + MessageSegment.text(f"已删除{_chat_label(event)}中的 {deleted} 条记忆") + ) forget_all_cmd = on_command("forget_all", priority=10, block=True) @@ -65,7 +90,9 @@ async def _(event): _ensure_llm_bindings() svc = get_llm_service() deleted = svc.clear_memories(_chat_id(event), chat_type=_chat_type(event)) - await forget_all_cmd.finish(MessageSegment.text(f"已清空{_chat_label(event)}全部长期记忆(共 {deleted} 条)")) + await forget_all_cmd.finish( + MessageSegment.text(f"已清空{_chat_label(event)}全部长期记忆(共 {deleted} 条)") + ) tell_cmd = on_command("tell", priority=10, block=True) @@ -129,4 +156,9 @@ async def _(event): to_user_id = offline_message_store.retract_latest(event.group_id, event.user_id) if to_user_id is None: await untell_cmd.finish(MessageSegment.text("没有可撤回的留言")) - await untell_cmd.finish(MessageSegment.text(f"已撤回最新留言(收件人:{identities.snapshot(event.group_id).name(to_user_id)})")) + await untell_cmd.finish( + MessageSegment.text( + f"已撤回最新留言(收件人:" + f"{identities.snapshot(event.group_id).name(to_user_id)})" + ) + ) diff --git a/src/quickquip/adapters/nonebot/command_parts/niuniu.py b/src/quickquip/adapters/nonebot/command_parts/niuniu.py index 7378c8d5..10f3c68c 100644 --- a/src/quickquip/adapters/nonebot/command_parts/niuniu.py +++ b/src/quickquip/adapters/nonebot/command_parts/niuniu.py @@ -5,7 +5,14 @@ from datetime import datetime, timedelta, timezone from time import time -from quickquip.adapters.nonebot.command_parts.common import _evaluate_luck, _fence_luck_tips, _glue_luck_tips, _is_admin, _is_private_chat, _strip_command_name +from quickquip.adapters.nonebot.command_parts.common import ( + _evaluate_luck, + _fence_luck_tips, + _glue_luck_tips, + _is_admin, + _is_private_chat, + _strip_command_name, +) from quickquip.app.message_pipeline import game_economy, niuniu_store from quickquip.common.rate_limit import SlidingWindowRateLimiter from quickquip.games.niuniu import fence_cd, fenced_cd, fencing, get_comment, glue_cd, gluing @@ -106,7 +113,8 @@ async def _(event): balance = game_economy.get_balance(uid, str(event.group_id)) if balance["gold"] < niuniu_store.config.unsubscribe_gold: await nn_unsubscribe.finish( - f"你的金币不足 {niuniu_store.config.unsubscribe_gold},无法注销牛牛!(当前 {balance['gold']} 金币)" + f"你的金币不足 {niuniu_store.config.unsubscribe_gold},无法注销牛牛!" + f"(当前 {balance['gold']} 金币)" ) game_economy.deduct_gold(uid, str(event.group_id), niuniu_store.config.unsubscribe_gold) niuniu_store.unsubscribe(uid) @@ -128,7 +136,10 @@ async def _(event): else: depth_rank = niuniu_store.get_rank_position(uid, "depth") abs_rank = niuniu_store.get_rank_position(uid, "absolute") - rank_str = f"总榜第 {natural_rank} 名 | 深度榜第 {depth_rank} 名 | 绝对值榜第 {abs_rank} 名" + rank_str = ( + f"总榜第 {natural_rank} 名 | " + f"深度榜第 {depth_rank} 名 | 绝对值榜第 {abs_rank} 名" + ) last_glue = niuniu_store.latest_record_time(uid, "gluing") glue_luck = niuniu_store.get_glue_luck(uid) fence_luck = niuniu_store.get_fence_luck(uid) @@ -349,7 +360,10 @@ async def _(event): act = action_labels.get(r["action"], r["action"]) diff = r["diff"] sign = "+" if diff > 0 else "" - lines.append(f"{act} | {r['origin_length']} → {r['new_length']} ({sign}{diff}) | {_fmt_time(r['created_at'])}") + lines.append( + f"{act} | {r['origin_length']} → {r['new_length']} " + f"({sign}{diff}) | {_fmt_time(r['created_at'])}" + ) await nn_records.finish("\n".join(lines)) nn_glue_luck = on_command("打胶运势", priority=10, block=True) diff --git a/src/quickquip/adapters/nonebot/command_parts/rules.py b/src/quickquip/adapters/nonebot/command_parts/rules.py index 3836fac1..1d388c6c 100644 --- a/src/quickquip/adapters/nonebot/command_parts/rules.py +++ b/src/quickquip/adapters/nonebot/command_parts/rules.py @@ -1,7 +1,16 @@ from __future__ import annotations -from quickquip.adapters.nonebot.command_parts.common import _allow_scope_management, _is_private_chat -from quickquip.app.message_pipeline import RULE_SWITCH_PATH, _ensure_llm_bindings, get_llm_service, reload_chat_rules_pipeline, rule_switch +from quickquip.adapters.nonebot.command_parts.common import ( + _allow_scope_management, + _is_private_chat, +) +from quickquip.app.message_pipeline import ( + RULE_SWITCH_PATH, + _ensure_llm_bindings, + get_llm_service, + reload_chat_rules_pipeline, + rule_switch, +) from quickquip.common.event_utils import is_admin as _is_admin diff --git a/src/quickquip/adapters/nonebot/command_parts/scheduler.py b/src/quickquip/adapters/nonebot/command_parts/scheduler.py index 6c0c83f3..17829dce 100644 --- a/src/quickquip/adapters/nonebot/command_parts/scheduler.py +++ b/src/quickquip/adapters/nonebot/command_parts/scheduler.py @@ -96,7 +96,10 @@ async def _(event): kind, recurring, rest = _parse_add_flags(rest) parts = rest.split(maxsplit=5) if len(parts) < 6: - await schedule_cmd.finish("用法:/schedule add [llm] [once] <消息>,例如 /schedule add 0 9 * * * 早安") + await schedule_cmd.finish( + "用法:/schedule add [llm] [once] <消息>," + "例如 /schedule add 0 9 * * * 早安" + ) cron = " ".join(parts[:5]) message = parts[5] try: diff --git a/src/quickquip/adapters/nonebot/command_parts/session.py b/src/quickquip/adapters/nonebot/command_parts/session.py index de1bea60..dc7d8fcf 100644 --- a/src/quickquip/adapters/nonebot/command_parts/session.py +++ b/src/quickquip/adapters/nonebot/command_parts/session.py @@ -1,6 +1,11 @@ from __future__ import annotations -from quickquip.adapters.nonebot.command_parts.common import _is_private_chat, _parse_preset, _parse_resume, _strip_command_name +from quickquip.adapters.nonebot.command_parts.common import ( + _is_private_chat, + _parse_preset, + _parse_resume, + _strip_command_name, +) from quickquip.app.message_pipeline import _ensure_llm_bindings, get_llm_service, stats_tracker @@ -54,10 +59,14 @@ async def _end_private_session(event, matcher, cmd_name: str) -> None: deleted = result["deleted"] archive_number = result.get("archive_number") if archive_number is not None: - await matcher.finish(f"当前私聊会话已结束,已存档为 #{archive_number}({deleted} 条消息)。") + await matcher.finish( + f"当前私聊会话已结束,已存档为 #{archive_number}({deleted} 条消息)。" + ) else: suffix = "(未存档)" if no_save else "" - await matcher.finish(f"当前私聊会话已结束,并清空了 {deleted} 条短期上下文。{suffix}") + await matcher.finish( + f"当前私聊会话已结束,并清空了 {deleted} 条短期上下文。{suffix}" + ) @start_session_cmd.handle() async def _(event): diff --git a/src/quickquip/adapters/nonebot/command_parts/tieba.py b/src/quickquip/adapters/nonebot/command_parts/tieba.py index cfb4f4d4..7b8b02b9 100644 --- a/src/quickquip/adapters/nonebot/command_parts/tieba.py +++ b/src/quickquip/adapters/nonebot/command_parts/tieba.py @@ -2,8 +2,19 @@ from collections.abc import Callable -from quickquip.adapters.nonebot.command_parts.common import _is_admin, _is_private_chat, _parse_tieba_command_args, _strip_command_name -from quickquip.app.message_pipeline import STATS_PATH, rate_limiter, rule_switch, stats_tracker, tieba_service +from quickquip.adapters.nonebot.command_parts.common import ( + _is_admin, + _is_private_chat, + _parse_tieba_command_args, + _strip_command_name, +) +from quickquip.app.message_pipeline import ( + STATS_PATH, + rate_limiter, + rule_switch, + stats_tracker, + tieba_service, +) from quickquip.tieba.config import TIEBA_RULE_NAME from quickquip.tieba.errors import TiebaLoginRequiredError, TiebaServiceError from quickquip.tieba.formatting import build_thread_preview, format_sources, format_status @@ -43,16 +54,24 @@ async def _(event): await tieba_cmd.finish(f"贴吧搬运失败:{exc}") if thread is None: if tieba_service.is_login_required(forum_keyword): - await tieba_cmd.finish("贴吧登录态需要人工续签,请让管理员先运行 python -m quickquip.tieba.login") + await tieba_cmd.finish( + "贴吧登录态需要人工续签," + "请让管理员先运行 python -m quickquip.tieba.login" + ) if forum_keyword: - await tieba_cmd.finish(f"{forum_keyword}吧消息池为空,请稍后再试或让管理员执行 /tieba refresh {forum_keyword}") + await tieba_cmd.finish( + f"{forum_keyword}吧消息池为空," + f"请稍后再试或让管理员执行 /tieba refresh {forum_keyword}" + ) await tieba_cmd.finish("当前贴吧池为空,请稍后再试或让管理员执行 /tieba refresh") tieba_service.mark_sent(thread) stats_tracker.record_trigger(event.group_id, TIEBA_RULE_NAME) if text_only: await tieba_cmd.finish(build_thread_preview(thread)) message = Message([MessageSegment.text(build_thread_preview(thread))]) - image_url = thread.cover_image_url or (thread.image_urls[0] if thread.image_urls else "") + image_url = thread.cover_image_url or ( + thread.image_urls[0] if thread.image_urls else "" + ) if image_url: message.append(MessageSegment.image(image_url)) await tieba_cmd.finish(message) @@ -125,7 +144,9 @@ async def _(event): try: thread = await tieba_service.peek_random_thread(forum_keyword) except TiebaLoginRequiredError: - await tieba_peek_cmd.finish("贴吧登录态需要人工续签,请运行 python -m quickquip.tieba.login") + await tieba_peek_cmd.finish( + "贴吧登录态需要人工续签,请运行 python -m quickquip.tieba.login" + ) except TiebaServiceError as exc: await tieba_peek_cmd.finish(f"现爬失败:{exc}") if thread is None: diff --git a/src/quickquip/adapters/nonebot/command_parts/utility.py b/src/quickquip/adapters/nonebot/command_parts/utility.py index bca0dd05..0c55b101 100644 --- a/src/quickquip/adapters/nonebot/command_parts/utility.py +++ b/src/quickquip/adapters/nonebot/command_parts/utility.py @@ -2,7 +2,13 @@ import random -from quickquip.adapters.nonebot.command_parts.common import _DICE_RE, _NUMBER_EMOJIS, _daily_fortune, _safe_shlex_split, _strip_command_name +from quickquip.adapters.nonebot.command_parts.common import ( + _DICE_RE, + _NUMBER_EMOJIS, + _daily_fortune, + _safe_shlex_split, + _strip_command_name, +) def register_utility_commands(on_command, Message, MessageSegment) -> None: diff --git a/src/quickquip/adapters/nonebot/daily_briefing_plugin.py b/src/quickquip/adapters/nonebot/daily_briefing_plugin.py index 8d8c6590..3f7bbcab 100644 --- a/src/quickquip/adapters/nonebot/daily_briefing_plugin.py +++ b/src/quickquip/adapters/nonebot/daily_briefing_plugin.py @@ -276,7 +276,8 @@ async def _(event): await briefing_cmd.finish("仅管理员可执行此操作") if not cfg.enabled: await briefing_cmd.finish( - "每日播报全局未开启,请先在 config/llm.toml 的 [daily_briefing] 中设置 enabled = true。" + "每日播报全局未开启," + "请先在 config/llm.toml 的 [daily_briefing] 中设置 enabled = true。" ) daily_briefing_enabled_groups.add(group_id) rule_switch.enable(group_id, _RULE_NAME) @@ -317,7 +318,9 @@ async def _(event): await send_daily_briefing_now( group_id, period, - before_generate=lambda selected_period: briefing_cmd.send(f"正在生成{_PERIOD_LABELS[selected_period]},请稍候……"), + before_generate=lambda selected_period: briefing_cmd.send( + f"正在生成{_PERIOD_LABELS[selected_period]},请稍候……" + ), ) except RuntimeError as exc: message = str(exc) diff --git a/src/quickquip/adapters/nonebot/daily_summary_plugin.py b/src/quickquip/adapters/nonebot/daily_summary_plugin.py index bb688792..8eb31571 100644 --- a/src/quickquip/adapters/nonebot/daily_summary_plugin.py +++ b/src/quickquip/adapters/nonebot/daily_summary_plugin.py @@ -103,7 +103,9 @@ class DailySummaryGenerationFailedError(RuntimeError): """每日总结生成失败或被跳过(LLM 失败、persona 缺失等)。""" -async def send_daily_summary_now(group_id: int | str, bot=None, before_generate=None) -> dict[str, object]: +async def send_daily_summary_now( + group_id: int | str, bot=None, before_generate=None +) -> dict[str, object]: group_key = str(group_id) if not daily_enabled_groups.contains(group_key): raise DailySummaryNotEnabledError("daily summary is not enabled for this group") @@ -287,7 +289,12 @@ async def _(event): # ── /summary weekly|monthly ... 子命令分发 ────────────────── # 周期报告子命令独立解析,不与日报 on/off/status/now 冲突。 - if args.split(None, 1)[:1] and args.split(None, 1)[0] in {"weekly", "monthly", "周报", "月报"}: + if args.split(None, 1)[:1] and args.split(None, 1)[0] in { + "weekly", + "monthly", + "周报", + "月报", + }: handled = await _handle_period_subcommand(args, group_id, summary_cmd, event) if handled: return @@ -326,7 +333,10 @@ async def _(event): if not _is_admin(event): await summary_cmd.finish("仅管理员可执行此操作") try: - await send_daily_summary_now(group_id, before_generate=lambda: summary_cmd.send("正在生成总结,请稍候……")) + await send_daily_summary_now( + group_id, + before_generate=lambda: summary_cmd.send("正在生成总结,请稍候……"), + ) except DailySummaryNotEnabledError: await summary_cmd.finish("本群未开启每日总结,请先使用 /summary on 开启。") except DailySummaryCooldownError: @@ -369,7 +379,8 @@ def setup(on_command) -> None: def _period_enabled_groups(period_type: str): - """按 period_type 返回对应的 enabled groups 实例(duck-typed,具备 add/remove/contains/all_groups)。""" + """按 period_type 返回对应的 enabled groups 实例(duck-typed,具备 + add/remove/contains/all_groups)。""" if period_type == PERIOD_WEEKLY: return weekly_enabled_groups if period_type == PERIOD_MONTHLY: @@ -464,11 +475,19 @@ async def _wrapped(): pub_id = f"{period_type}_report_publish" scheduler.add_job( _make_wrapped(gen_id, lambda pt=period_type: _job_generate_period_reports(pt)), - "cron", id=gen_id, name=gen_id, replace_existing=True, **parse_cron(cfg.generate_cron, fallback_hour="6"), + "cron", + id=gen_id, + name=gen_id, + replace_existing=True, + **parse_cron(cfg.generate_cron, fallback_hour="6"), ) scheduler.add_job( _make_wrapped(pub_id, lambda pt=period_type: _job_publish_period_reports(pt)), - "cron", id=pub_id, name=pub_id, replace_existing=True, **parse_cron(cfg.publish_cron, fallback_hour="6"), + "cron", + id=pub_id, + name=pub_id, + replace_existing=True, + **parse_cron(cfg.publish_cron, fallback_hour="6"), ) logger.info( "period_report[%s]: jobs registered (generate=%s, publish=%s)", @@ -561,10 +580,17 @@ async def _handle_period_subcommand( if not _is_admin(event): await summary_cmd.finish("仅管理员可执行此操作") enabled.add(group_id) - cfg = svc.config.weekly_report if period_type == PERIOD_WEEKLY else svc.config.monthly_report + cfg = ( + svc.config.weekly_report + if period_type == PERIOD_WEEKLY + else svc.config.monthly_report + ) gen_time = cron_to_hhmm(cfg.generate_cron) pub_time = cron_to_hhmm(cfg.publish_cron) - await summary_cmd.finish(f"本群{kind_word}已开启。将于每周期 {gen_time} 生成,每天 {pub_time} 发布(未发布的报告会自动补发)。") + await summary_cmd.finish( + f"本群{kind_word}已开启。将于每周期 {gen_time} 生成," + f"每天 {pub_time} 发布(未发布的报告会自动补发)。" + ) return True if sub in {"off", "关闭", "禁用"}: diff --git a/src/quickquip/adapters/nonebot/group_messages.py b/src/quickquip/adapters/nonebot/group_messages.py index d7443fc8..80b36fb6 100644 --- a/src/quickquip/adapters/nonebot/group_messages.py +++ b/src/quickquip/adapters/nonebot/group_messages.py @@ -38,8 +38,19 @@ logger = logging.getLogger(__name__) -def _remember_recent_message(group_id, user_id, sender_name: str, canonical_name: str, rendered_text: str, message_id: str = "", image_urls: list[str] | None = None) -> None: - recent_messages.add_message(group_id, user_id, sender_name, canonical_name, rendered_text, message_id=message_id, image_urls=image_urls) +def _remember_recent_message( + group_id, + user_id, + sender_name: str, + canonical_name: str, + rendered_text: str, + message_id: str = "", + image_urls: list[str] | None = None, +) -> None: + recent_messages.add_message( + group_id, user_id, sender_name, canonical_name, rendered_text, + message_id=message_id, image_urls=image_urls, + ) def collect_at_qq_ids(message) -> list[str]: @@ -135,7 +146,10 @@ def register_message_matcher(on_message, Message, MessageSegment): @matcher.handle() async def _(bot, event): - if getattr(event, "group_id", None) is None or getattr(event, "message_type", "") == "private": + if ( + getattr(event, "group_id", None) is None + or getattr(event, "message_type", "") == "private" + ): return if _is_self_message(event): _archive_self_message(event) @@ -246,8 +260,14 @@ async def _(bot, event): mention_names=mention_names, ) if llm_input is not None and rule_switch.is_enabled(group_id, "llm_chat"): - _remember_recent_message(group_id, user_id, sender_name, canonical_name, rendered_text, message_id, image_urls=rendered_message.image_urls) - if not roll_reply("llm_chat", group_id=group_id) or not rate_limiter.allow("llm_chat", user_id): + _remember_recent_message( + group_id, user_id, sender_name, canonical_name, rendered_text, message_id, + image_urls=rendered_message.image_urls, + ) + if ( + not roll_reply("llm_chat", group_id=group_id) + or not rate_limiter.allow("llm_chat", user_id) + ): return from quickquip.llm.agent_records import TriggerKind @@ -290,7 +310,8 @@ async def _(bot, event): user_id=user_id, incoming_message_id=message_id, incoming_preview=rendered_text, - reply_preview=result["reply"] or (delivery_sink.sent_texts[-1][:120] if delivery_sink.sent_texts else ""), + reply_preview=result["reply"] + or (delivery_sink.sent_texts[-1][:120] if delivery_sink.sent_texts else ""), llm_used=bool(result.get("llm_used")), provider_id=str(result.get("provider_id", "")), model=str(result.get("model", "")), @@ -299,8 +320,12 @@ async def _(bot, event): # 逐 Turn 模式正文已由 sink 交付(reply 为空),此处只处理 # 最终单发/错误提示路径,避免二次发送(§10)。 if str(result.get("reply") or "").strip() or (result.get("images") or []): - resp = await matcher.send(build_llm_reply_message(result, Message, MessageSegment)) - sent_msg_id = str(resp.get("message_id", "")) if isinstance(resp, dict) else "" + resp = await matcher.send( + build_llm_reply_message(result, Message, MessageSegment) + ) + sent_msg_id = ( + str(resp.get("message_id", "")) if isinstance(resp, dict) else "" + ) record_final_receipt(svc, result, sent_msg_id) return @@ -319,11 +344,21 @@ async def _(bot, event): llm_settings, svc, rule_enabled=lambda rule_name: rule_switch.is_enabled(group_id, rule_name), - rate_available=lambda rule_name: rate_limiter.can_allow(rule_name, user_id, group_id=group_id), + rate_available=lambda rule_name: rate_limiter.can_allow( + rule_name, user_id, group_id=group_id + ), ) if awakening_result and rule_switch.is_enabled(group_id, awakening_result.rule_name): - _remember_recent_message(group_id, user_id, sender_name, canonical_name, rendered_text, message_id, image_urls=rendered_message.image_urls) - if not roll_reply(awakening_result.rule_name, group_id=group_id) or not rate_limiter.allow(awakening_result.rule_name, user_id, group_id=group_id): + _remember_recent_message( + group_id, user_id, sender_name, canonical_name, rendered_text, message_id, + image_urls=rendered_message.image_urls, + ) + if ( + not roll_reply(awakening_result.rule_name, group_id=group_id) + or not rate_limiter.allow( + awakening_result.rule_name, user_id, group_id=group_id + ) + ): return # trigger_context was captured before the current message was stored, # so the passive prompt's user text stays the only copy of it. @@ -347,7 +382,9 @@ async def _(bot, event): prompt=build_awakening_prompt(awakening_result, passive_image_urls), image_urls=passive_image_urls, include_recent_images=allows_recent_images(awakening_result.rule_name), - raw_user_text=build_passive_trigger_raw_user_text(awakening_result, passive_image_urls), + raw_user_text=build_passive_trigger_raw_user_text( + awakening_result, passive_image_urls + ), message_id=message_id or None, mentioned_qq_ids=list(rendered_message.mentioned_qq_ids), ) @@ -363,15 +400,20 @@ async def _(bot, event): user_id=user_id, incoming_message_id=message_id, incoming_preview=rendered_text, - reply_preview=result["reply"] or (passive_sink.sent_texts[-1][:120] if passive_sink.sent_texts else ""), + reply_preview=result["reply"] + or (passive_sink.sent_texts[-1][:120] if passive_sink.sent_texts else ""), llm_used=bool(result.get("llm_used")), provider_id=str(result.get("provider_id", "")), model=str(result.get("model", "")), source="group_message.awakening", ): if str(result.get("reply") or "").strip() or (result.get("images") or []): - resp = await matcher.send(build_llm_reply_message(result, Message, MessageSegment)) - sent_msg_id = str(resp.get("message_id", "")) if isinstance(resp, dict) else "" + resp = await matcher.send( + build_llm_reply_message(result, Message, MessageSegment) + ) + sent_msg_id = ( + str(resp.get("message_id", "")) if isinstance(resp, dict) else "" + ) record_final_receipt(svc, result, sent_msg_id) return @@ -384,7 +426,10 @@ async def _(bot, event): repeat_fingerprint=repeat_fingerprint, ) if not result: - _remember_recent_message(group_id, user_id, sender_name, canonical_name, rendered_text, message_id, image_urls=rendered_message.image_urls) + _remember_recent_message( + group_id, user_id, sender_name, canonical_name, rendered_text, message_id, + image_urls=rendered_message.image_urls, + ) return reply_message = _build_rule_reply_message( result, @@ -393,15 +438,24 @@ async def _(bot, event): MessageSegment, ) if reply_message is None: - _remember_recent_message(group_id, user_id, sender_name, canonical_name, rendered_text, message_id, image_urls=rendered_message.image_urls) + _remember_recent_message( + group_id, user_id, sender_name, canonical_name, rendered_text, message_id, + image_urls=rendered_message.image_urls, + ) return if not rate_limiter.allow(result["rate_limit_key"], user_id, group_id=group_id): - _remember_recent_message(group_id, user_id, sender_name, canonical_name, rendered_text, message_id, image_urls=rendered_message.image_urls) + _remember_recent_message( + group_id, user_id, sender_name, canonical_name, rendered_text, message_id, + image_urls=rendered_message.image_urls, + ) return stats_tracker.record_trigger(group_id, result.get("rule_name", "unknown")) - _remember_recent_message(group_id, user_id, sender_name, canonical_name, rendered_text, message_id, image_urls=rendered_message.image_urls) + _remember_recent_message( + group_id, user_id, sender_name, canonical_name, rendered_text, message_id, + image_urls=rendered_message.image_urls, + ) with bot_action_trace( trigger_kind=str(result.get("trigger_kind", "rule")), reason_code=str(result.get("reason_code", result.get("rule_name", "unknown"))), diff --git a/src/quickquip/adapters/nonebot/private_messages.py b/src/quickquip/adapters/nonebot/private_messages.py index a3e7bbc7..aa55b80d 100644 --- a/src/quickquip/adapters/nonebot/private_messages.py +++ b/src/quickquip/adapters/nonebot/private_messages.py @@ -23,8 +23,17 @@ from quickquip.app.message_pipeline import is_self_message as _is_self_message -def _remember_recent_message(scope_key, user_id, sender_name: str, canonical_name: str, rendered_text: str, message_id: str = "") -> None: - recent_messages.add_message(scope_key, user_id, sender_name, canonical_name, rendered_text, message_id=message_id) +def _remember_recent_message( + scope_key, + user_id, + sender_name: str, + canonical_name: str, + rendered_text: str, + message_id: str = "", +) -> None: + recent_messages.add_message( + scope_key, user_id, sender_name, canonical_name, rendered_text, message_id=message_id + ) def register_private_message_matcher(on_message): @@ -34,7 +43,10 @@ def register_private_message_matcher(on_message): async def _(bot, event): from nonebot.adapters.onebot.v11 import Message, MessageSegment - if getattr(event, "group_id", None) is not None or getattr(event, "message_type", "") == "group": + if ( + getattr(event, "group_id", None) is not None + or getattr(event, "message_type", "") == "group" + ): return if _is_self_message(event): return @@ -90,9 +102,14 @@ async def _(bot, event): if llm_input is None: return - _remember_recent_message(scope_key, user_id, sender_name, canonical_name, rendered_text, message_id) + _remember_recent_message( + scope_key, user_id, sender_name, canonical_name, rendered_text, message_id + ) # 私聊掷骰状态按用户隔离,避免 suppress/pity 跨私聊用户串扰 - if not roll_reply("llm_chat", group_id=f"private:{user_id}") or not rate_limiter.allow("llm_chat", user_id): + if ( + not roll_reply("llm_chat", group_id=f"private:{user_id}") + or not rate_limiter.allow("llm_chat", user_id) + ): return from quickquip.llm.agent_records import TriggerKind @@ -131,7 +148,8 @@ async def _(bot, event): user_id=user_id, incoming_message_id=message_id, incoming_preview=rendered_text, - reply_preview=result["reply"] or (delivery_sink.sent_texts[-1][:120] if delivery_sink.sent_texts else ""), + reply_preview=result["reply"] + or (delivery_sink.sent_texts[-1][:120] if delivery_sink.sent_texts else ""), llm_used=bool(result.get("llm_used")), provider_id=str(result.get("provider_id", "")), model=str(result.get("model", "")), diff --git a/src/quickquip/adapters/nonebot/record_content.py b/src/quickquip/adapters/nonebot/record_content.py index 557d9655..28c48fcb 100644 --- a/src/quickquip/adapters/nonebot/record_content.py +++ b/src/quickquip/adapters/nonebot/record_content.py @@ -16,7 +16,12 @@ async def prepare_body(message, scope, bot=None, command=None, extra_ids=()): snapshot.names.update(names) for part in body["parts"]: if part["type"] == "member": - part["name"] = part.get("name") or snapshot.names.get(part["qq"]) or snapshot.index.resolve_user(part["qq"]).canonical_name or "" + part["name"] = ( + part.get("name") + or snapshot.names.get(part["qq"]) + or snapshot.index.resolve_user(part["qq"]).canonical_name + or "" + ) return body, snapshot diff --git a/src/quickquip/adapters/nonebot/scheduler_plugin.py b/src/quickquip/adapters/nonebot/scheduler_plugin.py index c9a150ec..0c896cc4 100644 --- a/src/quickquip/adapters/nonebot/scheduler_plugin.py +++ b/src/quickquip/adapters/nonebot/scheduler_plugin.py @@ -84,12 +84,18 @@ async def _fire_llm_task(bot, job: ScheduledMessage, group_id: str, job_id: str) from quickquip.chat.awakening import is_group_llm_enabled if not rule_switch.is_enabled(group_id, _LLM_RULE_NAME): - logger.info("scheduled_msg: llm job %s skipped in group %s (rule disabled)", job.id, group_id) + logger.info( + "scheduled_msg: llm job %s skipped in group %s (rule disabled)", + job.id, group_id, + ) return _ensure_llm_bindings() svc = get_llm_service() if not is_group_llm_enabled(svc, group_id): - logger.info("scheduled_msg: llm job %s skipped in group %s (group LLM disabled)", job.id, group_id) + logger.info( + "scheduled_msg: llm job %s skipped in group %s (group LLM disabled)", + job.id, group_id, + ) return from quickquip.llm.agent_records import TriggerKind diff --git a/src/quickquip/adapters/nonebot/web_admin_actions.py b/src/quickquip/adapters/nonebot/web_admin_actions.py index 5b7a3a8a..1c2c3294 100644 --- a/src/quickquip/adapters/nonebot/web_admin_actions.py +++ b/src/quickquip/adapters/nonebot/web_admin_actions.py @@ -82,7 +82,9 @@ async def _execute_runtime_action(action: WebAdminAction) -> dict[str, Any]: if action.action_type == "health_check": scope_key = _normalize_health_scope(action.payload.get("scope_key")) verbose = bool(action.payload.get("verbose", False)) - text = await svc.format_health(_chat_id(scope_key), chat_type=_chat_type(scope_key), verbose=verbose) + text = await svc.format_health( + _chat_id(scope_key), chat_type=_chat_type(scope_key), verbose=verbose + ) return {"ok": True, "text": text} if action.action_type == "clear_context": diff --git a/src/quickquip/adapters/nonebot/wordcloud_plugin.py b/src/quickquip/adapters/nonebot/wordcloud_plugin.py index 67f0f1a7..d263e0e5 100644 --- a/src/quickquip/adapters/nonebot/wordcloud_plugin.py +++ b/src/quickquip/adapters/nonebot/wordcloud_plugin.py @@ -91,7 +91,9 @@ async def _(event): return if sum(freq.values()) < WORDCLOUD_MIN_WORDS: - await cmd.finish(f"{label}有效词汇不足(需至少 {WORDCLOUD_MIN_WORDS} 个词),无法生成词云。") + await cmd.finish( + f"{label}有效词汇不足(需至少 {WORDCLOUD_MIN_WORDS} 个词),无法生成词云。" + ) return try: diff --git a/src/quickquip/app/message_pipeline.py b/src/quickquip/app/message_pipeline.py index 1027ae7d..51f56ef6 100644 --- a/src/quickquip/app/message_pipeline.py +++ b/src/quickquip/app/message_pipeline.py @@ -20,7 +20,15 @@ from quickquip.chat import rule_switch as rule_switch_module from quickquip.chat import text_rules as text_rules_module from quickquip.chat.chain_game import ChainGameDef, ChainGameManager -from quickquip.games import BlackjackGame, GameEconomyStore, GameRegistry, NiuNiuStore, NumberBombGame, RussianRouletteGame, game_scores +from quickquip.games import ( + BlackjackGame, + GameEconomyStore, + GameRegistry, + NiuNiuStore, + NumberBombGame, + RussianRouletteGame, + game_scores, +) from quickquip.games.config import load_games_config from quickquip.chat.good_girl_chain import GoodGirlChainManager @@ -113,7 +121,9 @@ def close(self) -> None: custom_chain_games = ChainGameManager([ChainGameDef.from_dict(d) for d in CHAIN_GAME_CONFIGS]) stats_tracker = GroupStatsTracker() rule_switch = GroupRuleSwitch() -recent_messages = RecentMessageBuffer(max_messages_per_group=20, ttl_seconds=RECENT_CONTEXT_TTL_SECONDS) +recent_messages = RecentMessageBuffer( + max_messages_per_group=20, ttl_seconds=RECENT_CONTEXT_TTL_SECONDS +) message_deduper = RecentMessageDeduper() awakening_state = _get_awakening_state() @@ -140,7 +150,9 @@ def close(self) -> None: game_registry.register(NumberBombGame(config=games_config.number_bomb)) game_economy = GameEconomyStore(config=games_config.economy) game_registry.register(BlackjackGame(economy=game_economy, config=games_config.blackjack)) -game_registry.register(RussianRouletteGame(economy=game_economy, config=games_config.russian_roulette)) +game_registry.register( + RussianRouletteGame(economy=game_economy, config=games_config.russian_roulette) +) niuniu_store = NiuNiuStore(config=games_config.niuniu) # 贴吧服务:构造不做磁盘 IO,帖子池由 startup()/web 装配显式 load() @@ -148,7 +160,9 @@ def close(self) -> None: DATA_DIR.mkdir(exist_ok=True) stats_tracker.load(STATS_PATH) -_record_identities.names_provider = lambda gid: getattr(stats_tracker.get_stats(gid), "user_names", {}) +_record_identities.names_provider = lambda gid: getattr( + stats_tracker.get_stats(gid), "user_names", {} +) rule_switch.load(RULE_SWITCH_PATH) _llm_bindings_done = False diff --git a/src/quickquip/app/web/action_queue.py b/src/quickquip/app/web/action_queue.py index deb986e5..598a7327 100644 --- a/src/quickquip/app/web/action_queue.py +++ b/src/quickquip/app/web/action_queue.py @@ -65,8 +65,12 @@ def _ensure_schema(self) -> None: ) self._schema_ready = True - def _reap_stale_running_locked(self, conn: sqlite3.Connection, timeout_seconds: int = 300) -> int: - cutoff = (datetime.now(timezone.utc) - timedelta(seconds=max(1, int(timeout_seconds)))).isoformat() + def _reap_stale_running_locked( + self, conn: sqlite3.Connection, timeout_seconds: int = 300 + ) -> int: + cutoff = ( + datetime.now(timezone.utc) - timedelta(seconds=max(1, int(timeout_seconds))) + ).isoformat() cur = conn.execute( """ UPDATE web_admin_actions diff --git a/src/quickquip/app/web/app.py b/src/quickquip/app/web/app.py index 38cd64eb..c8454fa8 100644 --- a/src/quickquip/app/web/app.py +++ b/src/quickquip/app/web/app.py @@ -2,7 +2,35 @@ from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from quickquip.app.web import auth -from quickquip.app.web.routes import stats, rules, groups, config, logs, diagnostics, memory, summaries, period_reports, personas, conversations, group_settings, rate_limit, tieba, wordcloud, llm_about, mcp_dashboard, cron_dashboard, audit, game_economy, niuniu, quotes, sensitive_filter, awakening, llm_runtime, llm_usage, scheduled_messages +from quickquip.app.web.routes import ( + stats, + rules, + groups, + config, + logs, + diagnostics, + memory, + summaries, + period_reports, + personas, + conversations, + group_settings, + rate_limit, + tieba, + wordcloud, + llm_about, + mcp_dashboard, + cron_dashboard, + audit, + game_economy, + niuniu, + quotes, + sensitive_filter, + awakening, + llm_runtime, + llm_usage, + scheduled_messages, +) from quickquip.app.web.settings import load_web_env from quickquip.common.env import PROJECT_ROOT @@ -33,28 +61,60 @@ def create_app() -> FastAPI: app.include_router(groups.router, prefix="/ops/api", dependencies=auth.protected_dependencies) app.include_router(config.router, prefix="/ops/api", dependencies=auth.protected_dependencies) app.include_router(logs.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(diagnostics.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + app.include_router( + diagnostics.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) app.include_router(memory.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(summaries.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(period_reports.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + app.include_router( + summaries.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + app.include_router( + period_reports.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) app.include_router(personas.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(conversations.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(group_settings.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(rate_limit.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + app.include_router( + conversations.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + app.include_router( + group_settings.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + app.include_router( + rate_limit.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) app.include_router(tieba.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(wordcloud.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(llm_about.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(mcp_dashboard.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(cron_dashboard.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + app.include_router( + wordcloud.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + app.include_router( + llm_about.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + app.include_router( + mcp_dashboard.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + app.include_router( + cron_dashboard.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) app.include_router(audit.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(game_economy.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + app.include_router( + game_economy.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) app.include_router(niuniu.router, prefix="/ops/api", dependencies=auth.protected_dependencies) app.include_router(quotes.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(sensitive_filter.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(awakening.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(llm_runtime.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(llm_usage.router, prefix="/ops/api", dependencies=auth.protected_dependencies) - app.include_router(scheduled_messages.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + app.include_router( + sensitive_filter.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + app.include_router( + awakening.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + app.include_router( + llm_runtime.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + app.include_router( + llm_usage.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + app.include_router( + scheduled_messages.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) _register_root_redirect(app) diff --git a/src/quickquip/app/web/routes/conversations.py b/src/quickquip/app/web/routes/conversations.py index 8ea35248..a4243ee6 100644 --- a/src/quickquip/app/web/routes/conversations.py +++ b/src/quickquip/app/web/routes/conversations.py @@ -176,7 +176,9 @@ def loop_detail(group_key: str, loop_id: str): for tool in tools: turn_index.setdefault(tool["turn_id"], {}).setdefault("tools", []).append(dict(tool)) for delivery in deliveries: - turn_index.setdefault(delivery["turn_id"], {}).setdefault("deliveries", []).append(dict(delivery)) + turn_index.setdefault(delivery["turn_id"], {}).setdefault( + "deliveries", [] + ).append(dict(delivery)) ordered = sorted(turn_index.values(), key=lambda t: t.get("turn_index") or 0) return { "loop": { diff --git a/src/quickquip/app/web/routes/game_economy.py b/src/quickquip/app/web/routes/game_economy.py index ec09fd2a..4b74ecf4 100644 --- a/src/quickquip/app/web/routes/game_economy.py +++ b/src/quickquip/app/web/routes/game_economy.py @@ -128,7 +128,8 @@ async def get_account(group_id: str, user_id: str, request: Request): store: GameEconomyStore = game_economy with store.connect() as conn: row = conn.execute( - "SELECT user_id, gold, affection, sign_streak, last_sign_date FROM gold_accounts WHERE user_id = ? AND group_id = ?", + "SELECT user_id, gold, affection, sign_streak, last_sign_date " + "FROM gold_accounts WHERE user_id = ? AND group_id = ?", (user_id, group_id), ).fetchone() if row is None: diff --git a/src/quickquip/app/web/routes/group_settings.py b/src/quickquip/app/web/routes/group_settings.py index a56c2db7..bbfa5bfe 100644 --- a/src/quickquip/app/web/routes/group_settings.py +++ b/src/quickquip/app/web/routes/group_settings.py @@ -25,7 +25,9 @@ def _validate_group_id(group_id: str) -> None: if not _SCOPE_KEY_RE.match(group_id): - raise HTTPException(status_code=422, detail="scope key must be 5-12 digits or 'private:USER_ID'") + raise HTTPException( + status_code=422, detail="scope key must be 5-12 digits or 'private:USER_ID'" + ) def _store() -> LLMStore: @@ -117,10 +119,24 @@ def _format_group_entry(group_id: str, row) -> dict: if row is not None: entry.update({ "enabled": None if row["enabled"] is None else bool(row["enabled"]), - "memory_enabled": None if row["memory_enabled"] is None else bool(row["memory_enabled"]), - "auto_memory_enabled": None if row["auto_memory_enabled"] is None else bool(row["auto_memory_enabled"]), - "agent_delivery_intermediate_enabled": None if row["agent_delivery_intermediate_enabled"] is None else bool(row["agent_delivery_intermediate_enabled"]), - "agent_delivery_final_enabled": None if row["agent_delivery_final_enabled"] is None else bool(row["agent_delivery_final_enabled"]), + "memory_enabled": ( + None if row["memory_enabled"] is None else bool(row["memory_enabled"]) + ), + "auto_memory_enabled": ( + None + if row["auto_memory_enabled"] is None + else bool(row["auto_memory_enabled"]) + ), + "agent_delivery_intermediate_enabled": ( + None + if row["agent_delivery_intermediate_enabled"] is None + else bool(row["agent_delivery_intermediate_enabled"]) + ), + "agent_delivery_final_enabled": ( + None + if row["agent_delivery_final_enabled"] is None + else bool(row["agent_delivery_final_enabled"]) + ), "provider_id": row["provider_id"], "model": row["model"], "persona_id": row["persona_id"], @@ -147,7 +163,10 @@ def list_group_settings(): with store._connect() as conn: rows = conn.execute( """ - SELECT group_id, enabled, memory_enabled, auto_memory_enabled, agent_delivery_intermediate_enabled, agent_delivery_final_enabled, provider_id, model, persona_id, + SELECT group_id, enabled, memory_enabled, auto_memory_enabled, + agent_delivery_intermediate_enabled, + agent_delivery_final_enabled, provider_id, model, + persona_id, trigger_prefix, allow_prefix, allow_at, history_limit, updated_at FROM group_settings ORDER BY updated_at DESC diff --git a/src/quickquip/app/web/routes/groups.py b/src/quickquip/app/web/routes/groups.py index 35e18437..fb433480 100644 --- a/src/quickquip/app/web/routes/groups.py +++ b/src/quickquip/app/web/routes/groups.py @@ -113,7 +113,10 @@ def run_briefing_now(group_id: str, body: BriefingNowBody, request: Request): _validate_group_id(group_id) from quickquip.app.message_pipeline import daily_briefing_enabled_groups, rule_switch - if not daily_briefing_enabled_groups.contains(group_id) or not rule_switch.is_enabled(group_id, "daily_briefing"): + if ( + not daily_briefing_enabled_groups.contains(group_id) + or not rule_switch.is_enabled(group_id, "daily_briefing") + ): raise HTTPException(status_code=409, detail="daily briefing is not enabled for this group") from quickquip.chat.daily_briefing import normalize_period @@ -143,7 +146,9 @@ def _period_report_enabled_groups(period_type: str): raise ValueError(f"unknown period_type: {period_type!r}") -def _set_period_report_group(period_type: str, group_id: str, body: GroupToggle, request: Request) -> None: +def _set_period_report_group( + period_type: str, group_id: str, body: GroupToggle, request: Request +) -> None: enabled_groups = _period_report_enabled_groups(period_type) if body.enabled: enabled_groups.add(group_id) @@ -161,8 +166,12 @@ def _run_period_report_now(period_type: str, group_id: str, request: Request): _validate_group_id(group_id) enabled_groups = _period_report_enabled_groups(period_type) if not enabled_groups.contains(group_id): - raise HTTPException(status_code=409, detail=f"{period_type} report is not enabled for this group") - action = action_queue.enqueue("period_report_now", {"group_id": group_id, "period_type": period_type}) + raise HTTPException( + status_code=409, detail=f"{period_type} report is not enabled for this group" + ) + action = action_queue.enqueue( + "period_report_now", {"group_id": group_id, "period_type": period_type} + ) audit_logger.log( request, action="queue", diff --git a/src/quickquip/app/web/routes/llm_about.py b/src/quickquip/app/web/routes/llm_about.py index 29256fc7..0b2605fc 100644 --- a/src/quickquip/app/web/routes/llm_about.py +++ b/src/quickquip/app/web/routes/llm_about.py @@ -79,7 +79,11 @@ def _file_meta(scope: str, kind: str) -> dict: "filename": _KINDS[kind]["filename"], "label": _KINDS[kind]["label"], "description": _KINDS[kind]["description"], - "path": f"llm_about/{_KINDS[kind]['filename']}" if scope == "global" else f"llm_about/{scope}/{_KINDS[kind]['filename']}", + "path": ( + f"llm_about/{_KINDS[kind]['filename']}" + if scope == "global" + else f"llm_about/{scope}/{_KINDS[kind]['filename']}" + ), "exists": exists, "size": path.stat().st_size if exists else 0, "mtime": int(path.stat().st_mtime) if exists else 0, @@ -123,7 +127,9 @@ def _validate_identities_content(content: str) -> None: if line and not line.startswith(" ") and ":" in line } if not sections.intersection({"people", "special_accounts"}): - raise HTTPException(status_code=400, detail="identities.yaml must contain people or special_accounts") + raise HTTPException( + status_code=400, detail="identities.yaml must contain people or special_accounts" + ) tmp_path = "" try: @@ -204,7 +210,12 @@ def get_llm_about_file(scope: str, kind: str): path = _resolve(scope, kind) if not path.exists(): return {"scope": scope, "kind": kind, "content": "", "missing": True} - return {"scope": scope, "kind": kind, "content": path.read_text(encoding="utf-8"), "missing": False} + return { + "scope": scope, + "kind": kind, + "content": path.read_text(encoding="utf-8"), + "missing": False, + } @router.put("/llm-about/{scope}/{kind}") @@ -220,7 +231,10 @@ def put_llm_about_file(scope: str, kind: str, body: LLMAboutContent, request: Re except Exception: tmp.unlink(missing_ok=True) raise - logger.warning("llm_about updated via web admin: %s/%s (%d bytes)", scope, kind, len(body.content)) + logger.warning( + "llm_about updated via web admin: %s/%s (%d bytes)", + scope, kind, len(body.content), + ) audit_logger.log( request, action="update", diff --git a/src/quickquip/app/web/routes/llm_runtime.py b/src/quickquip/app/web/routes/llm_runtime.py index c064d3eb..d188547a 100644 --- a/src/quickquip/app/web/routes/llm_runtime.py +++ b/src/quickquip/app/web/routes/llm_runtime.py @@ -31,7 +31,9 @@ class HealthBody(BaseModel): def _validate_scope_key(scope_key: str) -> str: key = scope_key.strip() if not _SCOPE_KEY_RE.match(key): - raise HTTPException(status_code=422, detail="scope_key must be 5-12 digits or 'private:USER_ID'") + raise HTTPException( + status_code=422, detail="scope_key must be 5-12 digits or 'private:USER_ID'" + ) return key @@ -59,14 +61,26 @@ def queue_health_check(body: HealthBody, request: Request): @router.post("/llm-runtime/reload") def reload_runtime(request: Request): action = action_queue.enqueue("llm_reload") - audit_logger.log(request, action="queue", target_type="llm_runtime", target_id="config", summary_after={"action_id": action["id"]}) + audit_logger.log( + request, + action="queue", + target_type="llm_runtime", + target_id="config", + summary_after={"action_id": action["id"]}, + ) return {"ok": True, "queued": True, "action": action} @router.post("/llm-runtime/mcp/reload") def reload_mcp(request: Request): action = action_queue.enqueue("mcp_reload") - audit_logger.log(request, action="queue", target_type="llm_runtime", target_id="mcp", summary_after={"action_id": action["id"]}) + audit_logger.log( + request, + action="queue", + target_type="llm_runtime", + target_id="mcp", + summary_after={"action_id": action["id"]}, + ) return {"ok": True, "queued": True, "action": action} diff --git a/src/quickquip/app/web/routes/llm_usage.py b/src/quickquip/app/web/routes/llm_usage.py index a67253fa..1019ac9d 100644 --- a/src/quickquip/app/web/routes/llm_usage.py +++ b/src/quickquip/app/web/routes/llm_usage.py @@ -25,7 +25,9 @@ def _days(range_key: str) -> int: try: return _RANGES[range_key] except KeyError as exc: - raise HTTPException(status_code=422, detail="range must be one of 1d, 7d, 30d, 90d") from exc + raise HTTPException( + status_code=422, detail="range must be one of 1d, 7d, 30d, 90d" + ) from exc def _filters( diff --git a/src/quickquip/app/web/routes/memory.py b/src/quickquip/app/web/routes/memory.py index 07ddb950..0fa55bc2 100644 --- a/src/quickquip/app/web/routes/memory.py +++ b/src/quickquip/app/web/routes/memory.py @@ -85,7 +85,10 @@ def create_memory(group_id: str, body: MemoryCreate, request: Request): action="create", target_type="memory", target_id=f"{group_id}:{mem_id}", - summary_after={"scope": body.scope, "content": (render(parts) if parts is not None else body.content)[:100]}, + summary_after={ + "scope": body.scope, + "content": (render(parts) if parts is not None else body.content)[:100], + }, ) return {"id": mem_id} @@ -106,7 +109,13 @@ def update_memory(group_id: str, mem_id: int, body: MemoryUpdate, request: Reque old_tags = row["tags_json"] old_conf = row["confidence"] parts = _validated_parts(body.content_parts) - new_content = render(parts) if parts is not None else body.content if body.content is not None else old_content + new_content = ( + render(parts) + if parts is not None + else body.content + if body.content is not None + else old_content + ) new_tags = json.dumps(body.tags, ensure_ascii=False) if body.tags is not None else old_tags new_conf = body.confidence if body.confidence is not None else old_conf now = datetime.now(timezone.utc).isoformat() @@ -115,7 +124,10 @@ def update_memory(group_id: str, mem_id: int, body: MemoryUpdate, request: Reque (new_content, new_tags, new_conf, now, mem_id), ) if parts is not None or body.content is not None: - save_parts(conn, "memories", mem_id, group_id, parts if parts is not None else plain(new_content)) + save_parts( + conn, "memories", mem_id, group_id, + parts if parts is not None else plain(new_content), + ) logger.info("memory updated: group=%s id=%d", group_id, mem_id) audit_logger.log( request, @@ -140,7 +152,11 @@ def delete_memory(group_id: str, mem_id: int, request: Request): ).fetchone() if not row: raise HTTPException(status_code=404, detail="memory not found") - summary_before = {"content": row["content"][:100], "tags": row["tags_json"], "confidence": row["confidence"]} + summary_before = { + "content": row["content"][:100], + "tags": row["tags_json"], + "confidence": row["confidence"], + } cur = conn.execute( "DELETE FROM memories WHERE id = ? AND group_id = ?", (mem_id, group_id), @@ -200,6 +216,9 @@ def search_members( entry = snapshot.index.by_qq.get(qq) aliases = entry.aliases if entry else [] name = snapshot.name(qq) - if not query or any(query.casefold() in value.casefold() for value in [qq, name, snapshot.names.get(qq, ""), *aliases]): + if not query or any( + query.casefold() in value.casefold() + for value in [qq, name, snapshot.names.get(qq, ""), *aliases] + ): result.append({"qq": qq, "name": name, "aliases": aliases}) return result[offset:offset + limit] diff --git a/src/quickquip/app/web/routes/period_reports.py b/src/quickquip/app/web/routes/period_reports.py index 8a23e886..fb4d217d 100644 --- a/src/quickquip/app/web/routes/period_reports.py +++ b/src/quickquip/app/web/routes/period_reports.py @@ -60,7 +60,8 @@ def list_period_reports(group_id: str, period_type: str): conn = _connect() try: rows = conn.execute( - """SELECT group_id, period_type, period_key, generated_at, published_at, model_used, char_count + """SELECT group_id, period_type, period_key, generated_at, published_at, + model_used, char_count FROM period_reports WHERE group_id = ? AND period_type = ? ORDER BY period_key DESC""", @@ -81,7 +82,8 @@ def get_period_report(group_id: str, period_type: str, period_key: str): conn = _connect() try: row = conn.execute( - "SELECT * FROM period_reports WHERE group_id = ? AND period_type = ? AND period_key = ?", + "SELECT * FROM period_reports " + "WHERE group_id = ? AND period_type = ? AND period_key = ?", (group_id, period_type, period_key), ).fetchone() if not row: @@ -91,7 +93,10 @@ def get_period_report(group_id: str, period_type: str, period_key: str): conn.close() -@router.get("/period-reports/{group_id}/{period_type}/{period_key}/text", response_class=PlainTextResponse) +@router.get( + "/period-reports/{group_id}/{period_type}/{period_key}/text", + response_class=PlainTextResponse, +) def get_period_report_text(group_id: str, period_type: str, period_key: str): _validate_group_id(group_id) _validate_period_type(period_type) @@ -101,7 +106,8 @@ def get_period_report_text(group_id: str, period_type: str, period_key: str): conn = _connect() try: row = conn.execute( - "SELECT content FROM period_reports WHERE group_id = ? AND period_type = ? AND period_key = ?", + "SELECT content FROM period_reports " + "WHERE group_id = ? AND period_type = ? AND period_key = ?", (group_id, period_type, period_key), ).fetchone() if not row: diff --git a/src/quickquip/app/web/routes/personas.py b/src/quickquip/app/web/routes/personas.py index e3b36906..81e70604 100644 --- a/src/quickquip/app/web/routes/personas.py +++ b/src/quickquip/app/web/routes/personas.py @@ -30,7 +30,10 @@ class PersonaCreate(BaseModel): def _validate_name(name: str) -> None: if not _NAME_RE.match(name): - raise HTTPException(status_code=422, detail="persona name must match [A-Za-z0-9_][A-Za-z0-9_-]{0,63}") + raise HTTPException( + status_code=422, + detail="persona name must match [A-Za-z0-9_][A-Za-z0-9_-]{0,63}", + ) def _persona_path(name: str) -> Path: diff --git a/src/quickquip/app/web/routes/quotes.py b/src/quickquip/app/web/routes/quotes.py index 49771b30..d7450939 100644 --- a/src/quickquip/app/web/routes/quotes.py +++ b/src/quickquip/app/web/routes/quotes.py @@ -38,7 +38,14 @@ async def list_quotes( store: GroupQuoteStore = group_quote_store from quickquip.app.identities import web_identities snapshot = web_identities.snapshot(group_id) - rows, total = await asyncio.to_thread(store.list_quotes, group_id, offset=offset, limit=limit, keyword=keyword, identity_snapshot=snapshot) + rows, total = await asyncio.to_thread( + store.list_quotes, + group_id, + offset=offset, + limit=limit, + keyword=keyword, + identity_snapshot=snapshot, + ) rows = _enrich_quote_rows(rows, group_id, snapshot) return {"entries": rows, "total": total, "has_more": offset + limit < total} diff --git a/src/quickquip/app/web/routes/summaries.py b/src/quickquip/app/web/routes/summaries.py index b8a4c675..7a97f08c 100644 --- a/src/quickquip/app/web/routes/summaries.py +++ b/src/quickquip/app/web/routes/summaries.py @@ -192,7 +192,8 @@ def summary_generation_log(group_id: str, summary_date: str): conn = _connect() try: row = conn.execute( - "SELECT group_id, summary_date, generated_at, run_id FROM summaries WHERE group_id = ? AND summary_date = ?", + "SELECT group_id, summary_date, generated_at, run_id " + "FROM summaries WHERE group_id = ? AND summary_date = ?", (group_id, summary_date), ).fetchone() if not row: diff --git a/src/quickquip/app/web/routes/tieba.py b/src/quickquip/app/web/routes/tieba.py index 291b5c6c..164c7625 100644 --- a/src/quickquip/app/web/routes/tieba.py +++ b/src/quickquip/app/web/routes/tieba.py @@ -77,8 +77,11 @@ def list_threads( if keyword: kw = keyword.strip().lower() threads = [ - t for t in threads - if kw in t.title.lower() or kw in t.main_post_text.lower() or kw in t.author_name.lower() + t + for t in threads + if kw in t.title.lower() + or kw in t.main_post_text.lower() + or kw in t.author_name.lower() ] total = len(threads) page = threads[offset:offset + limit] diff --git a/src/quickquip/app/web/session_store.py b/src/quickquip/app/web/session_store.py index 67a0b438..5cfc1825 100644 --- a/src/quickquip/app/web/session_store.py +++ b/src/quickquip/app/web/session_store.py @@ -73,7 +73,8 @@ def create_session( with self._connect() as conn: conn.execute( """ - INSERT INTO admin_sessions (session_id, created_at, expires_at, last_seen_at, client_ip, user_agent) + INSERT INTO admin_sessions + (session_id, created_at, expires_at, last_seen_at, client_ip, user_agent) VALUES (?, ?, ?, ?, ?, ?) """, ( diff --git a/src/quickquip/app/web/settings.py b/src/quickquip/app/web/settings.py index 4ee787a5..aef74239 100644 --- a/src/quickquip/app/web/settings.py +++ b/src/quickquip/app/web/settings.py @@ -17,7 +17,10 @@ def load_web_env() -> None: def get_web_admin_host() -> str: """WEB_ADMIN_HOST(.env),缺省/空白回退默认。web_api 与 webview_launcher 同源。""" load_web_env() - return os.environ.get("WEB_ADMIN_HOST", DEFAULT_WEB_ADMIN_HOST).strip() or DEFAULT_WEB_ADMIN_HOST + return ( + os.environ.get("WEB_ADMIN_HOST", DEFAULT_WEB_ADMIN_HOST).strip() + or DEFAULT_WEB_ADMIN_HOST + ) def get_web_admin_port() -> int: diff --git a/src/quickquip/chat/awakening/triggers.py b/src/quickquip/chat/awakening/triggers.py index 28bdacf3..b23d1e00 100644 --- a/src/quickquip/chat/awakening/triggers.py +++ b/src/quickquip/chat/awakening/triggers.py @@ -86,13 +86,34 @@ class AwakeningTriggerResult: ) AWAKENING_RULE_NAMES: frozenset[str] = frozenset(name for name, _label in AWAKENING_RULES) -_BOREDOM_INSTRUCTION = "群聊沉寂已久,你可以自然地冒个泡说点什么。不要说明自己是因为无聊唤醒或定时机制才发言。" -_EXTEND_INSTRUCTION = "这名群友刚刚显式召唤过你,现在仍在同一段短对话窗口内。只有能自然接上时才回应,保持简短,不要说明唤醒延长或触发机制。" -_INTEREST_INSTRUCTION_TEMPLATE = "这条群聊消息命中了你感兴趣的话题「{topic}」。请围绕这条消息自然接话,不要说明兴趣话题、关键词或唤醒机制。" -_FALLBACK_INSTRUCTION = "你低概率决定参与这条群聊。只有在能自然接上时才简短回应,不要强行扩展,不要说明兜底概率或唤醒机制。" -_RELEVANCE_INSTRUCTION = "判定结果显示用户在延续你之前的对话。请自然回应当前消息,不要说明相关性判定或唤醒机制。" -_QA_INSTRUCTION = "判定结果显示用户提出了可能需要你回答的问题。请直接回答当前问题,不要说明答疑判定或唤醒机制。" -_PASSIVE_IMAGE_INSTRUCTION = "这条触发消息包含图片,请结合图片与文字自然回应;如果图片不可见或信息不足,不要编造具体图像细节。" +_BOREDOM_INSTRUCTION = ( + "群聊沉寂已久,你可以自然地冒个泡说点什么。" + "不要说明自己是因为无聊唤醒或定时机制才发言。" +) +_EXTEND_INSTRUCTION = ( + "这名群友刚刚显式召唤过你,现在仍在同一段短对话窗口内。" + "只有能自然接上时才回应,保持简短,不要说明唤醒延长或触发机制。" +) +_INTEREST_INSTRUCTION_TEMPLATE = ( + "这条群聊消息命中了你感兴趣的话题「{topic}」。" + "请围绕这条消息自然接话,不要说明兴趣话题、关键词或唤醒机制。" +) +_FALLBACK_INSTRUCTION = ( + "你低概率决定参与这条群聊。只有在能自然接上时才简短回应," + "不要强行扩展,不要说明兜底概率或唤醒机制。" +) +_RELEVANCE_INSTRUCTION = ( + "判定结果显示用户在延续你之前的对话。请自然回应当前消息," + "不要说明相关性判定或唤醒机制。" +) +_QA_INSTRUCTION = ( + "判定结果显示用户提出了可能需要你回答的问题。请直接回答当前问题," + "不要说明答疑判定或唤醒机制。" +) +_PASSIVE_IMAGE_INSTRUCTION = ( + "这条触发消息包含图片,请结合图片与文字自然回应;" + "如果图片不可见或信息不足,不要编造具体图像细节。" +) def _passive_trigger_allows_images(rule_name: str) -> bool: diff --git a/src/quickquip/chat/context_rules.py b/src/quickquip/chat/context_rules.py index 7ae98bfd..c3da714e 100644 --- a/src/quickquip/chat/context_rules.py +++ b/src/quickquip/chat/context_rules.py @@ -59,7 +59,10 @@ def recompile_patterns() -> None: for rule in CONTEXT_REPLY_RULES ] for idx, rule in enumerate(CONTEXT_REPLY_RULES): - if rule.get("type", "regex_context") == "regex_context" and not _COMPILED_CONTEXT_CONDITIONS[idx]: + if ( + rule.get("type", "regex_context") == "regex_context" + and not _COMPILED_CONTEXT_CONDITIONS[idx] + ): logger.warning( "context rule %s 是 regex_context 但未配置 context_conditions,该规则将不会触发", rule.get("name", f"#{idx}"), @@ -85,7 +88,11 @@ def _check_regex_context( """本地历史判定:在最近 N 条消息中搜索 context_conditions。空条件视为不放行。""" if not conditions: return False - window = recent_messages[-context_window:] if len(recent_messages) > context_window else recent_messages + window = ( + recent_messages[-context_window:] + if len(recent_messages) > context_window + else recent_messages + ) for msg in window: if _match_any(conditions, msg.get("text", "")): return True @@ -136,7 +143,10 @@ async def _check_llm_context( ) try: - with usage_scope("context_rule_judge", group_id=str(group_id) if group_id is not None else None): + with usage_scope( + "context_rule_judge", + group_id=str(group_id) if group_id is not None else None, + ): raw = await asyncio.wait_for( llm_service.quick_judge(full_prompt, max_tokens=64), timeout=timeout, diff --git a/src/quickquip/chat/daily_briefing.py b/src/quickquip/chat/daily_briefing.py index 11130963..ee9a60d7 100644 --- a/src/quickquip/chat/daily_briefing.py +++ b/src/quickquip/chat/daily_briefing.py @@ -164,7 +164,11 @@ def _sample_messages(messages: list[dict], limit: int) -> list[dict]: sampled.append( { **item, - "time_label": datetime.fromtimestamp(ts, tz=_LOCAL_TZ).strftime("%H:%M") if ts else "", + "time_label": ( + datetime.fromtimestamp(ts, tz=_LOCAL_TZ).strftime("%H:%M") + if ts + else "" + ), } ) return sampled diff --git a/src/quickquip/chat/daily_summary.py b/src/quickquip/chat/daily_summary.py index 62487286..edf93a11 100644 --- a/src/quickquip/chat/daily_summary.py +++ b/src/quickquip/chat/daily_summary.py @@ -86,7 +86,8 @@ def upsert( content = excluded.content, published_at = NULL """, - (str(group_id), summary_date, generated_at, model_used, run_id, len(content), content), + (str(group_id), summary_date, generated_at, + model_used, run_id, len(content), content), ) conn.commit() finally: diff --git a/src/quickquip/chat/festival.py b/src/quickquip/chat/festival.py index 6b03325b..f6218a55 100644 --- a/src/quickquip/chat/festival.py +++ b/src/quickquip/chat/festival.py @@ -14,23 +14,42 @@ class Festival: _FESTIVALS: list[Festival] = [ - Festival(name="元旦", month=1, day=1, calendar="solar", greeting="新年快乐!愿新的一年大家万事顺遂。"), - Festival(name="春节", month=1, day=1, calendar="lunar", greeting="新春快乐!给大家拜年啦,祝大家身体健康、阖家幸福!"), - Festival(name="元宵节", month=1, day=15, calendar="lunar", greeting="元宵节快乐!记得吃汤圆哦~"), - Festival(name="端午节", month=5, day=5, calendar="lunar", greeting="端午安康!今天吃粽子了吗?"), - Festival(name="中秋节", month=8, day=15, calendar="lunar", greeting="中秋快乐!月圆人团圆,别忘了吃月饼~"), + Festival( + name="元旦", month=1, day=1, calendar="solar", + greeting="新年快乐!愿新的一年大家万事顺遂。", + ), + Festival( + name="春节", month=1, day=1, calendar="lunar", + greeting="新春快乐!给大家拜年啦,祝大家身体健康、阖家幸福!", + ), + Festival( + name="元宵节", month=1, day=15, calendar="lunar", + greeting="元宵节快乐!记得吃汤圆哦~", + ), + Festival( + name="端午节", month=5, day=5, calendar="lunar", + greeting="端午安康!今天吃粽子了吗?", + ), + Festival( + name="中秋节", month=8, day=15, calendar="lunar", + greeting="中秋快乐!月圆人团圆,别忘了吃月饼~", + ), ] _active_festival: Festival | None = None _checked_date: date | None = None _PERSONA_APPENDIX: dict[str, str] = { - "元旦": "今天是元旦,新年的第一天。请在回复中自然地融入新年的祝福和积极向上的语气,但不要生硬。", + "元旦": ( + "今天是元旦,新年的第一天。请在回复中自然地融入新年的祝福和积极向上的语气,但不要生硬。" + ), "春节": "今天是春节。请在回复中自然地融入新春祝福的语气,可以适当使用拜年用语,但不要生硬。", "元宵节": "今天是元宵节。可以在回复中自然地提到元宵、汤圆、团圆等元素,语气温馨一些。", "端午节": "今天是端午节。可以在回复中自然地提到粽子、龙舟等元素,语气可以适当体现节日氛围。", "中秋节": "今天是中秋节。可以在回复中自然地提到月亮、月饼、团圆等元素,语气温馨一些。", - "除夕": "今天是除夕,辞旧迎新之际。请在回复中自然地融入辞旧迎新的氛围,可以祝福大家新年进步,但不要生硬。", + "除夕": ( + "今天是除夕,辞旧迎新之际。请在回复中自然地融入辞旧迎新的氛围,可以祝福大家新年进步,但不要生硬。" + ), } diff --git a/src/quickquip/chat/group_quotes.py b/src/quickquip/chat/group_quotes.py index e14afa2a..470d0a5e 100644 --- a/src/quickquip/chat/group_quotes.py +++ b/src/quickquip/chat/group_quotes.py @@ -46,8 +46,12 @@ def resolve_quote_display_name( if not uid: return snapshot, False - identities_for_row = identity_snapshot or IdentitySnapshot(identity_index or IdentityIndex(), dict(user_names or {})) - resolved = identities_for_row.name(uid, snapshot if snapshot not in _UNKNOWN_SNAPSHOT_NAMES else "") + identities_for_row = identity_snapshot or IdentitySnapshot( + identity_index or IdentityIndex(), dict(user_names or {}) + ) + resolved = identities_for_row.name( + uid, snapshot if snapshot not in _UNKNOWN_SNAPSHOT_NAMES else "" + ) if snapshot in {*_UNKNOWN_SNAPSHOT_NAMES, uid, f"QQ{uid}"} or resolved == snapshot: return resolved, False return resolved, True @@ -167,7 +171,8 @@ def add( next_seq = int(row[0]) if row else 1 cur = self._db.execute( "INSERT INTO quotes" - " (group_id, quoted_user_id, quoted_sender_name, content, saved_by_user_id, saved_at, group_seq)" + " (group_id, quoted_user_id, quoted_sender_name, " + "content, saved_by_user_id, saved_at, group_seq)" " VALUES (?, ?, ?, ?, ?, ?, ?)", (gid, str(quoted_user_id), quoted_sender_name, content, str(saved_by_user_id), int(self._time()), next_seq), @@ -209,7 +214,8 @@ def random(self, group_id: str | int, *, identity_snapshot=None) -> dict | None: if recent_ids: placeholders = ",".join("?" for _ in recent_ids) row = self._db.execute( - "SELECT id, group_seq, quoted_user_id, quoted_sender_name, content, saved_at, content_parts_json" + "SELECT id, group_seq, quoted_user_id, quoted_sender_name, " + "content, saved_at, content_parts_json" f" FROM quotes WHERE group_id=? AND id NOT IN ({placeholders})" " ORDER BY RANDOM() LIMIT 1", (group_key, *recent_ids), @@ -219,7 +225,8 @@ def random(self, group_id: str | int, *, identity_snapshot=None) -> dict | None: if recent_ids: self._recent_random_ids.pop(group_key, None) row = self._db.execute( - "SELECT id, group_seq, quoted_user_id, quoted_sender_name, content, saved_at, content_parts_json" + "SELECT id, group_seq, quoted_user_id, quoted_sender_name, " + "content, saved_at, content_parts_json" " FROM quotes WHERE group_id=? ORDER BY RANDOM() LIMIT 1", (group_key,), ).fetchone() @@ -283,7 +290,10 @@ def search( # Isolate a long read from writes on the bot's event-loop connection. with closing(sqlite3.connect(self._path)) as conn: conn.row_factory = sqlite3.Row - rows = conn.execute(f"SELECT {_QUOTE_ROW_COLUMNS} FROM quotes WHERE group_id=? ORDER BY id DESC", (str(group_id),)) + rows = conn.execute( + f"SELECT {_QUOTE_ROW_COLUMNS} FROM quotes WHERE group_id=? ORDER BY id DESC", + (str(group_id),), + ) for row in rows: if matcher.matches(dict(row)): if offset <= total < offset + limit: diff --git a/src/quickquip/chat/offline_messages.py b/src/quickquip/chat/offline_messages.py index 8a06c08e..c814b221 100644 --- a/src/quickquip/chat/offline_messages.py +++ b/src/quickquip/chat/offline_messages.py @@ -27,7 +27,10 @@ class PendingMessage: def format_display(self, snapshot=None) -> str: ts = datetime.fromtimestamp(self.created_at).strftime("%m-%d %H:%M") snapshot = snapshot or identities.snapshot(self.group_id) - return f"[{snapshot.name(self.from_user_id, self.from_sender_name)} {ts}] {render(decode(self.content, self.content_parts_json), snapshot)}" + return ( + f"[{snapshot.name(self.from_user_id, self.from_sender_name)} {ts}] " + f"{render(decode(self.content, self.content_parts_json), snapshot)}" + ) class OfflineMessageStore: @@ -58,7 +61,8 @@ def __init__(self, db_path: str | Path): migrate(self._db, "offline_messages") self._db.commit() # Fast-reject set: (group_id, to_user_id) pairs that have pending rows. - # Conservative: false positives cause one wasted DELETE RETURNING; false negatives would miss delivery. + # Conservative: false positives cause one wasted DELETE RETURNING; + # false negatives would miss delivery. self._pending: set[tuple[str, str]] = { (r[0], r[1]) for r in self._db.execute( @@ -104,7 +108,8 @@ def pop_pending(self, group_id: str | int, to_user_id: str | int) -> list[Pendin return [] rows = self._db.execute( "DELETE FROM offline_messages WHERE group_id=? AND to_user_id=?" - " RETURNING id, from_user_id, from_sender_name, content, created_at, content_parts_json, group_id", + " RETURNING id, from_user_id, from_sender_name, " + "content, created_at, content_parts_json, group_id", key, ).fetchall() self._db.commit() @@ -135,7 +140,8 @@ def list_pending_for(self, group_id: str | int, to_user_id: str | int) -> list[P if key not in self._pending: return [] rows = self._db.execute( - "SELECT id, from_user_id, from_sender_name, content, created_at, content_parts_json, group_id" + "SELECT id, from_user_id, from_sender_name, " + "content, created_at, content_parts_json, group_id" " FROM offline_messages WHERE group_id=? AND to_user_id=? ORDER BY id", key, ).fetchall() diff --git a/src/quickquip/chat/period_report.py b/src/quickquip/chat/period_report.py index aee9b0a4..b15a7962 100644 --- a/src/quickquip/chat/period_report.py +++ b/src/quickquip/chat/period_report.py @@ -159,7 +159,8 @@ def upsert( conn.execute( """ INSERT INTO period_reports - (group_id, period_type, period_key, generated_at, model_used, run_id, char_count, content) + (group_id, period_type, period_key, generated_at, model_used, + run_id, char_count, content) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(group_id, period_type, period_key) DO UPDATE SET generated_at = excluded.generated_at, @@ -169,7 +170,8 @@ def upsert( content = excluded.content, published_at = NULL """, - (str(group_id), period_type, period_key, generated_at, model_used, run_id, len(content), content), + (str(group_id), period_type, period_key, generated_at, + model_used, run_id, len(content), content), ) conn.commit() finally: @@ -181,7 +183,8 @@ def get(self, group_id: int | str, period_type: str, period_key: str) -> dict | conn = self._connect() try: row = conn.execute( - "SELECT * FROM period_reports WHERE group_id = ? AND period_type = ? AND period_key = ?", + "SELECT * FROM period_reports " + "WHERE group_id = ? AND period_type = ? AND period_key = ?", (str(group_id), period_type, period_key), ).fetchone() return dict(row) if row else None @@ -238,7 +241,9 @@ def compute_period_window(period_type: str, now: datetime) -> tuple[float, float """ if period_type == PERIOD_WEEKLY: # 本周一 - this_week_monday = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0) + this_week_monday = (now - timedelta(days=now.weekday())).replace( + hour=0, minute=0, second=0, microsecond=0 + ) start = this_week_monday - timedelta(weeks=1) end = this_week_monday ref_date = start.date() # 上周内任意一天都映射到同一 ISO 周 @@ -249,7 +254,9 @@ def compute_period_window(period_type: str, now: datetime) -> tuple[float, float if period_type == PERIOD_MONTHLY: # 本月 1 日 this_month_first = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - start = (this_month_first - timedelta(days=1)).replace(day=1, hour=0, minute=0, second=0, microsecond=0) + start = (this_month_first - timedelta(days=1)).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) end = this_month_first ref_date = start.date() key = period_key_for(period_type, ref_date) diff --git a/src/quickquip/chat/reply_probability.py b/src/quickquip/chat/reply_probability.py index 3dcc83d6..670da199 100644 --- a/src/quickquip/chat/reply_probability.py +++ b/src/quickquip/chat/reply_probability.py @@ -73,8 +73,16 @@ def roll_reply( suppress_after_hit = entry.get("suppress_after_hit", 0) pity_step = entry.get("pity_step", 0) - suppress_after_hit = suppress_after_hit if isinstance(suppress_after_hit, int) and not isinstance(suppress_after_hit, bool) else 0 - pity_step = pity_step if isinstance(pity_step, (int, float)) and not isinstance(pity_step, bool) else 0 + suppress_after_hit = ( + suppress_after_hit + if isinstance(suppress_after_hit, int) and not isinstance(suppress_after_hit, bool) + else 0 + ) + pity_step = ( + pity_step + if isinstance(pity_step, (int, float)) and not isinstance(pity_step, bool) + else 0 + ) tracks_state = suppress_after_hit > 0 or pity_step > 0 state_key = _state_key(identity or rate_limit_key, group_id) diff --git a/src/quickquip/chat/scheduled_messages.py b/src/quickquip/chat/scheduled_messages.py index 7b8b310c..725ce447 100644 --- a/src/quickquip/chat/scheduled_messages.py +++ b/src/quickquip/chat/scheduled_messages.py @@ -301,7 +301,8 @@ def update_for_audit( return None, None def update(self, job_id: str, **fields: Any) -> ScheduledMessage | None: - """更新指定字段(cron/group_ids/message/enabled/kind/recurring),返回更新后的任务;不存在返回 None。 + """更新指定字段(cron/group_ids/message/enabled/kind/recurring), + 返回更新后的任务;不存在返回 None。 无有效字段时为空操作:直接返回当前任务,不产生 updated_at 跳动、 落盘、审计与 reload 的副作用链。 diff --git a/src/quickquip/chat/summary_jobs.py b/src/quickquip/chat/summary_jobs.py index 5a8d5ac1..6ff381eb 100644 --- a/src/quickquip/chat/summary_jobs.py +++ b/src/quickquip/chat/summary_jobs.py @@ -264,7 +264,9 @@ async def run_period_generation( iter(llm_config.personas.values()), None ) if persona is None: - logger.warning("period_report[%s]: no persona available for group %s", period_type, group_id) + logger.warning( + "period_report[%s]: no persona available for group %s", period_type, group_id + ) return None gs = stats_tracker.get_stats(group_id) @@ -314,7 +316,10 @@ async def generate_period_one( ) if result is not None: content, model_used = result - store.upsert(group_id, period_type, period_key, content, model_used, run_id=current_usage_run_id()) + store.upsert( + group_id, period_type, period_key, content, model_used, + run_id=current_usage_run_id(), + ) return result @@ -355,10 +360,14 @@ async def publish_period_one( try: await send(row) store.mark_published(group_id, period_type, period_key) - logger.info("period_report[%s]: published for group %s (%s)", period_type, group_id, period_key) + logger.info( + "period_report[%s]: published for group %s (%s)", + period_type, group_id, period_key, + ) except Exception: logger.warning( - "period_report[%s]: publish failed for group %s (%s)", period_type, group_id, period_key, + "period_report[%s]: publish failed for group %s (%s)", + period_type, group_id, period_key, exc_info=True, ) diff --git a/src/quickquip/chat/text_rules.py b/src/quickquip/chat/text_rules.py index 2ad00296..97d99abb 100644 --- a/src/quickquip/chat/text_rules.py +++ b/src/quickquip/chat/text_rules.py @@ -26,7 +26,9 @@ def recompile_patterns() -> None: recompile_patterns() -def build_rule_context(user_id: int | str, sender_name: str, now: Optional[datetime] = None) -> dict: +def build_rule_context( + user_id: int | str, sender_name: str, now: Optional[datetime] = None +) -> dict: current_dt = now or datetime.now(ZoneInfo(BEIJING_TIMEZONE)) return { "current_time": current_dt.strftime(BEIJING_TIME_FORMAT), diff --git a/src/quickquip/common/bot_action_trace.py b/src/quickquip/common/bot_action_trace.py index 22020a99..6a7543e6 100644 --- a/src/quickquip/common/bot_action_trace.py +++ b/src/quickquip/common/bot_action_trace.py @@ -38,7 +38,9 @@ class BotActionTrace: source: str = "" -_current_trace: ContextVar[BotActionTrace | None] = ContextVar("quickquip_bot_action_trace", default=None) +_current_trace: ContextVar[BotActionTrace | None] = ContextVar( + "quickquip_bot_action_trace", default=None +) _installed_api_hooks: set[str] = set() _TRACE_FIELD_NAMES = {field.name for field in fields(BotActionTrace)} @@ -79,7 +81,9 @@ def _message_types(message: Any) -> list[str]: return [type(message).__name__] -def _infer_chat_fields(api: str, data: dict[str, Any], trace: BotActionTrace | None) -> tuple[str, str, str]: +def _infer_chat_fields( + api: str, data: dict[str, Any], trace: BotActionTrace | None +) -> tuple[str, str, str]: chat_type = trace.chat_type if trace else "" group_id = trace.group_id if trace else "" user_id = trace.user_id if trace else "" @@ -143,7 +147,9 @@ def build_bot_action_trace_payload( "incoming_preview": "", "api": api, "outcome": "failed" if exception else "sent", - "error": "" if exception is None else f"{type(exception).__name__}: {_preview(exception, 240)}", + "error": ( + "" if exception is None else f"{type(exception).__name__}: {_preview(exception, 240)}" + ), "sent_message_id": "" if exception else _sent_message_id(result), "message_types": _message_types(message) or _message_types(messages), "reply_preview": "", @@ -254,7 +260,9 @@ def install_nonebot_api_trace_hook(BotClass: type[Any]) -> bool: return False @BotClass.on_called_api - async def _quickquip_bot_action_trace_hook(bot, exception, api: str, data: dict[str, Any], result: Any) -> None: + async def _quickquip_bot_action_trace_hook( + bot, exception, api: str, data: dict[str, Any], result: Any + ) -> None: if not _is_action_api(api): return log_bot_action_trace(api=api, data=data, result=result, exception=exception) diff --git a/src/quickquip/common/identity.py b/src/quickquip/common/identity.py index 2e54a665..a02d5ddf 100644 --- a/src/quickquip/common/identity.py +++ b/src/quickquip/common/identity.py @@ -225,7 +225,11 @@ def merge(self, other: "IdentityIndex") -> "IdentityIndex": for entry in self.entries: remaining = [q for q in entry.qq_ids if q not in overridden] if remaining: - entries.append(IdentityEntry(entry.canonical_name, remaining, list(entry.aliases), entry.note)) + entries.append( + IdentityEntry( + entry.canonical_name, remaining, list(entry.aliases), entry.note + ) + ) result = IdentityIndex(entries=[*entries, *other.entries]) result._build_indexes() return result diff --git a/src/quickquip/common/identity_sources.py b/src/quickquip/common/identity_sources.py index 21913a69..f773ee56 100644 --- a/src/quickquip/common/identity_sources.py +++ b/src/quickquip/common/identity_sources.py @@ -99,9 +99,17 @@ def _read(self, path, loader, empty): def snapshot(self, scope) -> IdentitySnapshot: scope = str(scope) with self._lock: - index = self._base_override if self._base_override is not None else self._read(self.path, _load_index, IdentityIndex()) + index = ( + self._base_override + if self._base_override is not None + else self._read(self.path, _load_index, IdentityIndex()) + ) if scope.isascii() and scope.isdigit(): - group = self._read(self.path.parent / scope / "identities.yaml", _load_index, IdentityIndex()) + group = self._read( + self.path.parent / scope / "identities.yaml", + _load_index, + IdentityIndex(), + ) cached = self._merged.get(scope) if cached is None or cached[0] is not index or cached[1] is not group: cached = (index, group, index.merge(group)) @@ -132,7 +140,8 @@ def _declares_substantive_entries(data) -> bool: def _load_index(path): - # Validate the document before the compatibility parser; incomplete writes must not replace a valid index. + # Validate the document before the compatibility parser; incomplete writes + # must not replace a valid index. import yaml raw = path.read_text(encoding="utf-8") data = yaml.safe_load(raw) diff --git a/src/quickquip/common/record_content.py b/src/quickquip/common/record_content.py index bc015ac1..f2bd3800 100644 --- a/src/quickquip/common/record_content.py +++ b/src/quickquip/common/record_content.py @@ -7,11 +7,23 @@ QQ = re.compile(r"[1-9][0-9]{0,19}\Z") CQ = re.compile(r"\[CQ:([a-zA-Z_]+)((?:,[^,\[\]]+=[^,\[\]]*)*)\]") LEGACY_AT = re.compile(r"(? 4096: @@ -31,9 +48,20 @@ def validate(body, max_length=4096): if kind == "text" and isinstance(part.get("text"), str): result.append({"type": kind, "text": part["text"]}) elif kind == "member" and isinstance(part.get("qq"), str) and QQ.fullmatch(part["qq"]): - if not isinstance(part.get("usage", "mention"), str) or part.get("usage", "mention") not in {"mention", "identity"} or not isinstance(part.get("name", ""), str): + if ( + not isinstance(part.get("usage", "mention"), str) + or part.get("usage", "mention") not in {"mention", "identity"} + or not isinstance(part.get("name", ""), str) + ): raise ValueError("invalid member reference") - result.append({"type": kind, "qq": part["qq"], "name": part.get("name", ""), "usage": part.get("usage", "mention")}) + result.append( + { + "type": kind, + "qq": part["qq"], + "name": part.get("name", ""), + "usage": part.get("usage", "mention"), + } + ) elif kind == "all": result.append({"type": "all"}) elif kind == "media" and isinstance(part.get("media"), str) and part["media"] in MEDIA: @@ -51,11 +79,17 @@ def from_segments(message, command=None): command_pending = bool(command) for segment in message: kind = segment.get("type") if isinstance(segment, dict) else getattr(segment, "type", "") - data = segment.get("data", {}) if isinstance(segment, dict) else getattr(segment, "data", {}) + data = ( + segment.get("data", {}) + if isinstance(segment, dict) + else getattr(segment, "data", {}) + ) if kind == "text": text = str(data.get("text", "")) if command_pending: - text, count = re.subn(r"^\s*[/!]?" + re.escape(command) + r"(?=\s|$)\s*", "", text, count=1) + text, count = re.subn( + r"^\s*[/!]?" + re.escape(command) + r"(?=\s|$)\s*", "", text, count=1 + ) if count or text.strip(): command_pending = False parts.append({"type": "text", "text": text}) @@ -64,7 +98,14 @@ def from_segments(message, command=None): if qq == "all": parts.append({"type": "all"}) elif QQ.fullmatch(qq): - parts.append({"type": "member", "qq": qq, "name": str(data.get("name", "") or ""), "usage": "mention"}) + parts.append( + { + "type": "member", + "qq": qq, + "name": str(data.get("name", "") or ""), + "usage": "mention", + } + ) elif kind in MEDIA: parts.append({"type": "media", "media": kind}) return {"version": 1, "parts": parts} @@ -82,7 +123,9 @@ def add_text(value): pos = 0 for match in CQ.finditer(text): data = dict(item.split("=", 1) for item in match[2].lstrip(",").split(",") if "=" in item) - body = from_segments([{"type": match[1], "data": {k: unescape(v) for k, v in data.items()}}]) + body = from_segments( + [{"type": match[1], "data": {k: unescape(v) for k, v in data.items()}}] + ) if not body["parts"]: continue add_text(text[pos:match.start()]) @@ -95,7 +138,10 @@ def add_text(value): def decode(content, encoded=None): if encoded: try: - return validate(json.loads(encoded) if isinstance(encoded, str) else encoded, max_length=1_000_000) + return validate( + json.loads(encoded) if isinstance(encoded, str) else encoded, + max_length=1_000_000, + ) except (ValueError, TypeError): pass return legacy(str(content)) @@ -108,7 +154,11 @@ def render(body, snapshot=None): if kind == "text": result.append(part["text"]) elif kind == "member": - name = snapshot.name(part["qq"], part.get("name", "")) if snapshot else part.get("name") or "QQ" + part["qq"] + name = ( + snapshot.name(part["qq"], part.get("name", "")) + if snapshot + else part.get("name") or "QQ" + part["qq"] + ) result.append(("@" if part.get("usage", "mention") == "mention" else "") + name) elif kind == "all": result.append("@全体成员") diff --git a/src/quickquip/common/record_search.py b/src/quickquip/common/record_search.py index 92e70afd..679ef9a1 100644 --- a/src/quickquip/common/record_search.py +++ b/src/quickquip/common/record_search.py @@ -7,7 +7,11 @@ def __init__(self, query, snapshot): self.query = query or "" self.needle = self.query.casefold() self.snapshot = snapshot - self.member_ids = snapshot.candidates(self.query) | references(legacy(self.query)) if self.query else set() + self.member_ids = ( + snapshot.candidates(self.query) | references(legacy(self.query)) + if self.query + else set() + ) def matches(self, row, include_owner=False): if not self.query or self.needle in str(row["content"]).casefold(): @@ -15,7 +19,10 @@ def matches(self, row, include_owner=False): if include_owner and str(row.get("user_id") or "") in self.member_ids: return True body = row.get("content_parts") or decode(row["content"], row.get("content_parts_json")) - return bool(self.member_ids & references(body)) or self.needle in render(body, self.snapshot).casefold() + return ( + bool(self.member_ids & references(body)) + or self.needle in render(body, self.snapshot).casefold() + ) def matches(row, query, snapshot, include_owner=False): diff --git a/src/quickquip/common/record_storage.py b/src/quickquip/common/record_storage.py index 4aabab93..1dd7f4b2 100644 --- a/src/quickquip/common/record_storage.py +++ b/src/quickquip/common/record_storage.py @@ -18,13 +18,30 @@ def migrate(conn, table): except sqlite3.OperationalError: if "content_parts_json" not in {r[1] for r in conn.execute(f"PRAGMA table_info({table})")}: raise - conn.execute(f"CREATE TABLE IF NOT EXISTS {table}_member_refs (group_id TEXT NOT NULL, record_id INTEGER NOT NULL, qq TEXT NOT NULL, PRIMARY KEY(record_id, qq))") - conn.execute(f"CREATE INDEX IF NOT EXISTS idx_{table}_member_refs ON {table}_member_refs(group_id, qq, record_id)") - conn.execute(f"CREATE TRIGGER IF NOT EXISTS delete_{table}_refs AFTER DELETE ON {table} BEGIN DELETE FROM {table}_member_refs WHERE record_id=OLD.id; END") + conn.execute( + f"CREATE TABLE IF NOT EXISTS {table}_member_refs " + f"(group_id TEXT NOT NULL, record_id INTEGER NOT NULL, " + f"qq TEXT NOT NULL, PRIMARY KEY(record_id, qq))" + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_{table}_member_refs " + f"ON {table}_member_refs(group_id, qq, record_id)" + ) + conn.execute( + f"CREATE TRIGGER IF NOT EXISTS delete_{table}_refs " + f"AFTER DELETE ON {table} BEGIN " + f"DELETE FROM {table}_member_refs WHERE record_id=OLD.id; END" + ) def save_parts(conn, table, record_id, scope, body): checked_table(table) - conn.execute(f"UPDATE {table} SET content_parts_json=? WHERE id=?", (json.dumps(body, ensure_ascii=False), record_id)) + conn.execute( + f"UPDATE {table} SET content_parts_json=? WHERE id=?", + (json.dumps(body, ensure_ascii=False), record_id), + ) conn.execute(f"DELETE FROM {table}_member_refs WHERE record_id=?", (record_id,)) - conn.executemany(f"INSERT INTO {table}_member_refs(group_id, record_id, qq) VALUES (?, ?, ?)", [(str(scope), record_id, qq) for qq in references(body)]) + conn.executemany( + f"INSERT INTO {table}_member_refs(group_id, record_id, qq) VALUES (?, ?, ?)", + [(str(scope), record_id, qq) for qq in references(body)], + ) diff --git a/src/quickquip/games/__init__.py b/src/quickquip/games/__init__.py index 97b685ce..10e798f8 100644 --- a/src/quickquip/games/__init__.py +++ b/src/quickquip/games/__init__.py @@ -1,6 +1,10 @@ from __future__ import annotations -from quickquip.games.registry import BaseGame as BaseGame, GameRegistry as GameRegistry, GameResult as GameResult +from quickquip.games.registry import ( + BaseGame as BaseGame, + GameRegistry as GameRegistry, + GameResult as GameResult, +) from quickquip.games.scores import GameScores as GameScores from quickquip.games.scores import game_scores as game_scores from quickquip.games.blackjack import BlackjackGame as BlackjackGame diff --git a/src/quickquip/games/blackjack.py b/src/quickquip/games/blackjack.py index d3736e39..32de703d 100644 --- a/src/quickquip/games/blackjack.py +++ b/src/quickquip/games/blackjack.py @@ -106,7 +106,12 @@ def name(self) -> str: def aliases(self) -> list[str]: return ["blackjack", "bj", "21"] - def __init__(self, economy: GameEconomyStore | None = None, config: BlackjackConfig | None = None, max_sessions: int = 512): + def __init__( + self, + economy: GameEconomyStore | None = None, + config: BlackjackConfig | None = None, + max_sessions: int = 512, + ): self._economy = economy self._config = config or BlackjackConfig() self._sessions: OrderedDict[str, _BJSession] = OrderedDict() @@ -208,7 +213,9 @@ def _add_player(self, key: str, s: _BJSession, uid: str, text: str) -> Optional[ return GameResult(reply="用法:入场 <金额>,例如 入场 500") return self._add_player_with_bet(key, s, uid, bet) - def _add_player_with_bet(self, key: str, s: _BJSession, uid: str, bet: int) -> Optional[GameResult]: + def _add_player_with_bet( + self, key: str, s: _BJSession, uid: str, bet: int + ) -> Optional[GameResult]: gid = key # Already joined? @@ -422,7 +429,9 @@ def _settle(self, key: str, s: _BJSession, reason: str) -> GameResult: win = p.bet * 2 if self._economy: self._economy.add_gold(uid, gid, win) - lines.append(f"QQ:{uid} [{_cards_str(p.cards)}] {p_score} 点 庄家爆牌 — +{p.bet} 💰") + lines.append( + f"QQ:{uid} [{_cards_str(p.cards)}] {p_score} 点 庄家爆牌 — +{p.bet} 💰" + ) continue if p_bj and not dealer_bj: @@ -430,12 +439,16 @@ def _settle(self, key: str, s: _BJSession, reason: str) -> GameResult: win = p.bet + int(p.bet * 1.5) if self._economy: self._economy.add_gold(uid, gid, win) - lines.append(f"QQ:{uid} [{_cards_str(p.cards)}] Blackjack! — +{int(p.bet * 1.5)} 💰") + lines.append( + f"QQ:{uid} [{_cards_str(p.cards)}] Blackjack! — +{int(p.bet * 1.5)} 💰" + ) continue if dealer_bj and not p_bj: # Dealer blackjack beats player - lines.append(f"QQ:{uid} [{_cards_str(p.cards)}] {p_score} 点 庄家 Blackjack — -{p.bet} 💰") + lines.append( + f"QQ:{uid} [{_cards_str(p.cards)}] {p_score} 点 庄家 Blackjack — -{p.bet} 💰" + ) continue if p_score > dealer_score: @@ -443,14 +456,21 @@ def _settle(self, key: str, s: _BJSession, reason: str) -> GameResult: win = p.bet * 2 if self._economy: self._economy.add_gold(uid, gid, win) - lines.append(f"QQ:{uid} [{_cards_str(p.cards)}] {p_score} > {dealer_score} 胜! — +{p.bet} 💰") + lines.append( + f"QQ:{uid} [{_cards_str(p.cards)}] {p_score} > {dealer_score} 胜! — +{p.bet} 💰" + ) elif p_score < dealer_score: - lines.append(f"QQ:{uid} [{_cards_str(p.cards)}] {p_score} < {dealer_score} 负 — -{p.bet} 💰") + lines.append( + f"QQ:{uid} [{_cards_str(p.cards)}] {p_score} < {dealer_score} 负 — -{p.bet} 💰" + ) else: # Push — refund if self._economy: self._economy.add_gold(uid, gid, p.bet) - lines.append(f"QQ:{uid} [{_cards_str(p.cards)}] {p_score} = {dealer_score} 平 — 退还 {p.bet} 💰") + lines.append( + f"QQ:{uid} [{_cards_str(p.cards)}] {p_score} = {dealer_score} 平 " + f"— 退还 {p.bet} 💰" + ) self._sessions.pop(key, None) return GameResult( diff --git a/src/quickquip/games/economy.py b/src/quickquip/games/economy.py index d53449c2..fe940bef 100644 --- a/src/quickquip/games/economy.py +++ b/src/quickquip/games/economy.py @@ -187,7 +187,15 @@ def get_rank( """, (str(group_id), top_n), ).fetchall() - return [{"user_id": r["user_id"], "gold": r["gold"], "affection": r["affection"], "sign_streak": r["sign_streak"]} for r in rows] + return [ + { + "user_id": r["user_id"], + "gold": r["gold"], + "affection": r["affection"], + "sign_streak": r["sign_streak"], + } + for r in rows + ] # ── sign-in ────────────────────────────────────────────────────────── @@ -277,7 +285,8 @@ def add_affection(self, user_id: str, group_id: str, amount: int) -> int: with self._connect() as conn: self._ensure_account(conn, user_id, group_id) conn.execute( - "UPDATE gold_accounts SET affection = affection + ? WHERE user_id = ? AND group_id = ?", + "UPDATE gold_accounts SET affection = affection + ? " + "WHERE user_id = ? AND group_id = ?", (amount, str(user_id), str(group_id)), ) row = conn.execute( diff --git a/src/quickquip/games/niuniu/dynamics.py b/src/quickquip/games/niuniu/dynamics.py index 099bb0f9..9056e22c 100644 --- a/src/quickquip/games/niuniu/dynamics.py +++ b/src/quickquip/games/niuniu/dynamics.py @@ -325,9 +325,17 @@ def fence_resolve( if oppo_is_bot: msgs = text.fence_bot["win"] if i_win else text.fence_bot["lose"] elif i_win: - msgs = (chosen.get("win_neg") or text.fence_shared["win_neg"]) if my_len < 0 else (chosen.get("win_pos") or text.fence_shared["win_pos"]) + msgs = ( + (chosen.get("win_neg") or text.fence_shared["win_neg"]) + if my_len < 0 + else (chosen.get("win_pos") or text.fence_shared["win_pos"]) + ) else: - msgs = (chosen.get("devoured_neg") or text.fence_shared["lose_neg"]) if my_len < 0 else (chosen.get("devoured_pos") or text.fence_shared["lose_pos"]) + msgs = ( + (chosen.get("devoured_neg") or text.fence_shared["lose_neg"]) + if my_len < 0 + else (chosen.get("devoured_pos") or text.fence_shared["lose_pos"]) + ) msg = random.choice(msgs).format(gain=steal, loss=loss_val, my_len=my_len) return FenceOutcome(my_new=my_len, oppo_new=oppo_len, msg=msg) @@ -585,15 +593,31 @@ def fence_resolve_zerohsum( if oppo_is_bot: msgs = text.fence_bot["win"] if i_win else text.fence_bot["lose"] elif i_win: - msgs = (chosen.get("win_neg") or text.fence_shared["win_neg"]) if my_len < 0 else (chosen.get("win_pos") or text.fence_shared["win_pos"]) + msgs = ( + (chosen.get("win_neg") or text.fence_shared["win_neg"]) + if my_len < 0 + else (chosen.get("win_pos") or text.fence_shared["win_pos"]) + ) else: - msgs = (chosen.get("devoured_neg") or text.fence_shared["lose_neg"]) if my_len < 0 else (chosen.get("devoured_pos") or text.fence_shared["lose_pos"]) + msgs = ( + (chosen.get("devoured_neg") or text.fence_shared["lose_neg"]) + if my_len < 0 + else (chosen.get("devoured_pos") or text.fence_shared["lose_pos"]) + ) msg = random.choice(msgs).format(gain=stake, loss=loss_val, my_len=my_len) elif msg_branch == "dominate_sever": if i_win: - msgs = chosen.get("sever_pos", chosen.get("win_pos")) if old_oppo > 0 else chosen.get("sever_neg", chosen.get("win_neg")) + msgs = ( + chosen.get("sever_pos", chosen.get("win_pos")) + if old_oppo > 0 + else chosen.get("sever_neg", chosen.get("win_neg")) + ) else: - msgs = chosen.get("severed_pos", chosen.get("lose_pos")) if old_my > 0 else chosen.get("severed_neg", chosen.get("lose_neg")) + msgs = ( + chosen.get("severed_pos", chosen.get("lose_pos")) + if old_my > 0 + else chosen.get("severed_neg", chosen.get("lose_neg")) + ) msg = random.choice(msgs).format( gain=stake, loss=loss_val, my_len=my_len, old_oppo=old_oppo, new_oppo=oppo_len, old_my=old_my, new_my=my_len, @@ -602,9 +626,17 @@ def fence_resolve_zerohsum( if oppo_is_bot: msgs = text.fence_bot["win"] if i_win else text.fence_bot["lose"] elif i_win: - msgs = (chosen.get("win_neg") or text.fence_shared["win_neg"]) if my_len < 0 else (chosen.get("win_pos") or text.fence_shared["win_pos"]) + msgs = ( + (chosen.get("win_neg") or text.fence_shared["win_neg"]) + if my_len < 0 + else (chosen.get("win_pos") or text.fence_shared["win_pos"]) + ) else: - msgs = (chosen.get("lose_neg") or text.fence_shared["lose_neg"]) if my_len < 0 else (chosen.get("lose_pos") or text.fence_shared["lose_pos"]) + msgs = ( + (chosen.get("lose_neg") or text.fence_shared["lose_neg"]) + if my_len < 0 + else (chosen.get("lose_pos") or text.fence_shared["lose_pos"]) + ) msg = random.choice(msgs).format(gain=stake, loss=loss_val, my_len=my_len) return FenceOutcome(my_new=my_len, oppo_new=oppo_len, msg=msg) diff --git a/src/quickquip/games/niuniu/events.py b/src/quickquip/games/niuniu/events.py index 72643e6b..fd84ff12 100644 --- a/src/quickquip/games/niuniu/events.py +++ b/src/quickquip/games/niuniu/events.py @@ -319,7 +319,8 @@ def get_comment(length: float, text=None) -> str: "🪓 牛头人断头台!对方牛牛被斩落 {loss} cm,你增长了 {gain} cm!", ], "sever_neg": [ - "👹 牛头人支配!你击穿了对方的防线!深度从 {old_oppo} 翻倍至 {new_oppo} cm!你吸收 {gain} cm!", + "👹 牛头人支配!你击穿了对方的防线!" + "深度从 {old_oppo} 翻倍至 {new_oppo} cm!你吸收 {gain} cm!", "深渊之力!牛头人的一击让对方的凹度暴增至 {new_oppo} cm!你获得 {gain} cm!", ], "severed_pos": [ diff --git a/src/quickquip/games/niuniu/store.py b/src/quickquip/games/niuniu/store.py index 97eb5e82..b83390a7 100644 --- a/src/quickquip/games/niuniu/store.py +++ b/src/quickquip/games/niuniu/store.py @@ -341,7 +341,9 @@ def register(self, uid: str) -> float: today = self._today_str() with self._connect() as conn: conn.execute( - "INSERT INTO niuniu_users (uid, length, luck, luck_date, fence_luck, fence_luck_date, created_at, updated_at) " + "INSERT INTO niuniu_users " + "(uid, length, luck, luck_date, fence_luck, " + "fence_luck_date, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (uid, length, glue_luck, today, fence_luck, today, now, now), ) @@ -381,7 +383,9 @@ def count(self) -> int: def _add_record(self, uid: str, action: str, origin: float, new: float) -> None: with self._connect() as conn: conn.execute( - "INSERT INTO niuniu_records (uid, action, origin_length, new_length, created_at) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO niuniu_records " + "(uid, action, origin_length, new_length, created_at) " + "VALUES (?, ?, ?, ?, ?)", (uid, action, round(origin, 2), round(new, 2), _utc_now()), ) @@ -390,7 +394,8 @@ def get_records(self, uid: str, limit: int = 10) -> list[dict]: raise RuntimeError("牛牛大作战 数据库不可用") with self._connect() as conn: rows = conn.execute( - "SELECT action, origin_length, new_length, created_at FROM niuniu_records WHERE uid = ? ORDER BY id DESC LIMIT ?", + "SELECT action, origin_length, new_length, created_at " + "FROM niuniu_records WHERE uid = ? ORDER BY id DESC LIMIT ?", (uid, limit), ).fetchall() return [ @@ -409,7 +414,8 @@ def latest_record_time(self, uid: str, action: str) -> str: raise RuntimeError("牛牛大作战 数据库不可用") with self._connect() as conn: row = conn.execute( - "SELECT created_at FROM niuniu_records WHERE uid = ? AND action = ? ORDER BY id DESC LIMIT 1", + "SELECT created_at FROM niuniu_records " + "WHERE uid = ? AND action = ? ORDER BY id DESC LIMIT 1", (uid, action), ).fetchone() return row["created_at"] if row else "暂无记录" @@ -424,12 +430,15 @@ def rank_by_length(self, limit: int = 10, user_ids: list[str] | None = None) -> if user_ids: placeholders = ",".join("?" for _ in user_ids) rows = conn.execute( - f"SELECT uid, length FROM niuniu_users WHERE length > 0 AND uid IN ({placeholders}) ORDER BY length DESC LIMIT ?", + f"SELECT uid, length FROM niuniu_users " + f"WHERE length > 0 AND uid IN ({placeholders}) " + f"ORDER BY length DESC LIMIT ?", [*user_ids, limit], ).fetchall() else: rows = conn.execute( - "SELECT uid, length FROM niuniu_users WHERE length > 0 ORDER BY length DESC LIMIT ?", + "SELECT uid, length FROM niuniu_users " + "WHERE length > 0 ORDER BY length DESC LIMIT ?", (limit,), ).fetchall() return [{"uid": r["uid"], "length": r["length"]} for r in rows] @@ -442,12 +451,15 @@ def rank_by_depth(self, limit: int = 10, user_ids: list[str] | None = None) -> l if user_ids: placeholders = ",".join("?" for _ in user_ids) rows = conn.execute( - f"SELECT uid, length FROM niuniu_users WHERE length < 0 AND uid IN ({placeholders}) ORDER BY length ASC LIMIT ?", + f"SELECT uid, length FROM niuniu_users " + f"WHERE length < 0 AND uid IN ({placeholders}) " + f"ORDER BY length ASC LIMIT ?", [*user_ids, limit], ).fetchall() else: rows = conn.execute( - "SELECT uid, length FROM niuniu_users WHERE length < 0 ORDER BY length ASC LIMIT ?", + "SELECT uid, length FROM niuniu_users " + "WHERE length < 0 ORDER BY length ASC LIMIT ?", (limit,), ).fetchall() return [{"uid": r["uid"], "length": abs(r["length"])} for r in rows] @@ -460,7 +472,9 @@ def rank_by_natural(self, limit: int = 10, user_ids: list[str] | None = None) -> if user_ids: placeholders = ",".join("?" for _ in user_ids) rows = conn.execute( - f"SELECT uid, length FROM niuniu_users WHERE uid IN ({placeholders}) ORDER BY length DESC LIMIT ?", + f"SELECT uid, length FROM niuniu_users " + f"WHERE uid IN ({placeholders}) " + f"ORDER BY length DESC LIMIT ?", [*user_ids, limit], ).fetchall() else: @@ -478,7 +492,9 @@ def rank_by_absolute(self, limit: int = 10, user_ids: list[str] | None = None) - if user_ids: placeholders = ",".join("?" for _ in user_ids) rows = conn.execute( - f"SELECT uid, length FROM niuniu_users WHERE uid IN ({placeholders}) ORDER BY ABS(length) DESC LIMIT ?", + f"SELECT uid, length FROM niuniu_users " + f"WHERE uid IN ({placeholders}) " + f"ORDER BY ABS(length) DESC LIMIT ?", [*user_ids, limit], ).fetchall() else: diff --git a/src/quickquip/games/niuniu/text.py b/src/quickquip/games/niuniu/text.py index 2b265a36..3c558aea 100644 --- a/src/quickquip/games/niuniu/text.py +++ b/src/quickquip/games/niuniu/text.py @@ -432,7 +432,8 @@ def _default_fence_events() -> list[dict[str, Any]]: "🪓 牛头人断头台!对方牛牛被斩落 {loss} cm,你增长了 {gain} cm!", ], "sever_neg": [ - "👹 牛头人支配!你击穿了对方的防线!深度从 {old_oppo} 翻倍至 {new_oppo} cm!你吸收 {gain} cm!", + "👹 牛头人支配!你击穿了对方的防线!" + "深度从 {old_oppo} 翻倍至 {new_oppo} cm!你吸收 {gain} cm!", "深渊之力!牛头人的一击让对方的凹度暴增至 {new_oppo} cm!你获得 {gain} cm!", ], "severed_pos": [ @@ -572,14 +573,21 @@ def _default_commands() -> dict[str, Any]: return { "register.already_exists": "你已经有过牛牛啦!当前长度 {length} cm", "register.positive": "牛牛长出来啦!足足有 {length} cm 呢!", - "register.negative": "牛牛长出来了?牛牛不见了!你是个可爱的女孩子!!深度足足有 {abs_length} cm 呢!", + "register.negative": ( + "牛牛长出来了?牛牛不见了!你是个可爱的女孩子!!" + "深度足足有 {abs_length} cm 呢!" + ), "register.missing": "你还没有牛牛呢!请发送 /注册牛牛 领取你的牛牛!", "unsubscribe.success": "从今往后你就没有牛牛啦!", - "unsubscribe.insufficient_gold": "你的金币不足 {required},无法注销牛牛!(当前 {balance} 金币)", + "unsubscribe.insufficient_gold": ( + "你的金币不足 {required},无法注销牛牛!(当前 {balance} 金币)" + ), "my.header": "🐂 我的牛牛", "my.length_line": "当前长度:{length} cm", "my.rank_positive": "第 {rank} 名", - "my.rank_negative": "总榜第 {natural_rank} 名 | 深度榜第 {depth_rank} 名 | 绝对值榜第 {abs_rank} 名", + "my.rank_negative": ( + "总榜第 {natural_rank} 名 | 深度榜第 {depth_rank} 名 | 绝对值榜第 {abs_rank} 名" + ), "my.glue_luck": "打胶运势:{luck}({label})", "my.fence_luck": "击剑运势:{luck}({label})", "my.last_glue": "最后打胶:{time}", @@ -610,7 +618,11 @@ def _default_commands() -> dict[str, Any]: "rank.abs_header": "🏆 牛牛绝对值排行:", "rank.abs_global_header": "🏆 牛牛绝对值排行(全局):", "rank.line": "{index}. QQ:{uid} — {length} {unit}", - "text_mode.view": "📝 本群牛牛文案模式:{mode}\n可用模式:{available}\n管理员可使用 /牛牛文案 <模式名> 进行切换", + "text_mode.view": ( + "📝 本群牛牛文案模式:{mode}\n" + "可用模式:{available}\n" + "管理员可使用 /牛牛文案 <模式名> 进行切换" + ), "text_mode.switched": "📝 本群牛牛文案已切换为:{mode}", "text_mode.unknown": "未知的文案模式:{mode}\n可用模式:{available}", "text_mode.no_permission": "只有群管理员才能切换文案模式哦~", diff --git a/src/quickquip/games/russian_roulette.py b/src/quickquip/games/russian_roulette.py index fd1a622f..2f01122f 100644 --- a/src/quickquip/games/russian_roulette.py +++ b/src/quickquip/games/russian_roulette.py @@ -78,7 +78,12 @@ def name(self) -> str: def aliases(self) -> list[str]: return ["russian", "轮盘", "rr"] - def __init__(self, economy: GameEconomyStore | None = None, config: RussianRouletteConfig | None = None, max_sessions: int = 512): + def __init__( + self, + economy: GameEconomyStore | None = None, + config: RussianRouletteConfig | None = None, + max_sessions: int = 512, + ): self._economy = economy self._config = config or RussianRouletteConfig() self._sessions: OrderedDict[str, _RRSession] = OrderedDict() @@ -89,7 +94,10 @@ def __init__(self, economy: GameEconomyStore | None = None, config: RussianRoule def start(self, group_id: str, user_id: str, start_arg: str = "") -> str: bet = self._parse_bet(start_arg) if bet is None: - return f"用法:/game start 俄罗斯轮盘 <赌注>\n赌注范围:{self._config.min_bet} ~ 你的金币余额" + return ( + f"用法:/game start 俄罗斯轮盘 <赌注>\n" + f"赌注范围:{self._config.min_bet} ~ 你的金币余额" + ) if bet < self._config.min_bet: return f"最低赌注为 {self._config.min_bet} 金币" diff --git a/src/quickquip/generation/audio.py b/src/quickquip/generation/audio.py index 85fb077f..88aebb0f 100644 --- a/src/quickquip/generation/audio.py +++ b/src/quickquip/generation/audio.py @@ -343,7 +343,9 @@ async def retrieve_generated_file( mime_type=str(payload_data.get("mime_type", "")).strip(), ) if download: - file_result.bytes, detected_mime = await _download_bytes(file_url, timeout=provider.timeout_seconds) + file_result.bytes, detected_mime = await _download_bytes( + file_url, timeout=provider.timeout_seconds + ) if not file_result.mime_type: file_result.mime_type = detected_mime return file_result @@ -531,7 +533,11 @@ async def _openai_tts( ) if not audio_bytes: raise GenerationProviderError("OpenAI TTS 返回空响应") - mime_type = _mime_for_audio_format(model_config.format) if model_config.format else detected_mime + mime_type = ( + _mime_for_audio_format(model_config.format) + if model_config.format + else detected_mime + ) return GeneratedAudioResult( audio_bytes=audio_bytes, mime_type=mime_type, diff --git a/src/quickquip/generation/config.py b/src/quickquip/generation/config.py index 797b2d9b..0a94e9e8 100644 --- a/src/quickquip/generation/config.py +++ b/src/quickquip/generation/config.py @@ -365,7 +365,9 @@ def _build_asr_model(entry: dict[str, Any]) -> AsrModelConfig: ) -def _image_provider_factory(pid: str, entry: dict[str, Any], models: list[Any]) -> ImageProviderConfig: +def _image_provider_factory( + pid: str, entry: dict[str, Any], models: list[Any] +) -> ImageProviderConfig: return ImageProviderConfig( id=pid, protocol=str(entry.get("protocol", "openai_images")).strip() or "openai_images", @@ -380,7 +382,9 @@ def _image_provider_factory(pid: str, entry: dict[str, Any], models: list[Any]) ) -def _audio_provider_factory(pid: str, entry: dict[str, Any], models: list[Any]) -> AudioProviderConfig: +def _audio_provider_factory( + pid: str, entry: dict[str, Any], models: list[Any] +) -> AudioProviderConfig: return AudioProviderConfig( id=pid, protocol=str(entry.get("protocol", "minimax_t2a_http")).strip() or "minimax_t2a_http", @@ -395,7 +399,9 @@ def _audio_provider_factory(pid: str, entry: dict[str, Any], models: list[Any]) ) -def _music_provider_factory(pid: str, entry: dict[str, Any], models: list[Any]) -> MusicProviderConfig: +def _music_provider_factory( + pid: str, entry: dict[str, Any], models: list[Any] +) -> MusicProviderConfig: return MusicProviderConfig( id=pid, protocol=str(entry.get("protocol", "minimax_music")).strip() or "minimax_music", @@ -413,7 +419,10 @@ def _music_provider_factory(pid: str, entry: dict[str, Any], models: list[Any]) def _asr_provider_factory(pid: str, entry: dict[str, Any], models: list[Any]) -> AsrProviderConfig: return AsrProviderConfig( id=pid, - protocol=str(entry.get("protocol", "openai_transcriptions")).strip() or "openai_transcriptions", + protocol=( + str(entry.get("protocol", "openai_transcriptions")).strip() + or "openai_transcriptions" + ), base_url=str(entry.get("base_url", "")).strip(), api_key_env=str(entry.get("api_key_env", "")).strip(), timeout_seconds=float(entry.get("timeout_seconds", 60)), diff --git a/src/quickquip/generation/svg_sanitize.py b/src/quickquip/generation/svg_sanitize.py index c398e0ef..f2df5b3d 100644 --- a/src/quickquip/generation/svg_sanitize.py +++ b/src/quickquip/generation/svg_sanitize.py @@ -35,7 +35,10 @@ class SvgSanitizeError(ValueError): ) # 属性值的三种引号形态;所有属性级检查统一经 _iter_tag_bodies 锚定到标签内 _EVENT_ATTR_RE = re.compile(r"""\s+on[a-zA-Z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)""") -_HREF_ATTR_RE = re.compile(r"""(\s+(?:xlink:)?href\s*=\s*)("[^"]*"|'[^']*'|[^\s>]+)""", re.IGNORECASE) +_HREF_ATTR_RE = re.compile( + r"""(\s+(?:xlink:)?href\s*=\s*)("[^"]*"|'[^']*'|[^\s>]+)""", + re.IGNORECASE, +) _TAG_RE = re.compile(r"<[^>]+>") _VIEWBOX_RE = re.compile(r"""viewBox\s*=\s*(["'])\s*([-\d.eE+,\s]+?)\1""", re.IGNORECASE) _SVG_ROOT_TAG_RE = re.compile(r"]*>", re.IGNORECASE) @@ -245,4 +248,6 @@ def _check_filter_region(filter_tag: str) -> None: rf"""{attr}\s*=\s*["']([\d.]+)%["']""", filter_tag, re.IGNORECASE ) if raw is not None and float(raw.group(1)) > MAX_FILTER_REGION_RATIO * 100: - raise SvgSanitizeError(f"filter {attr} 区域不能超过 {MAX_FILTER_REGION_RATIO * 100:.0f}%") + raise SvgSanitizeError( + f"filter {attr} 区域不能超过 {MAX_FILTER_REGION_RATIO * 100:.0f}%" + ) diff --git a/src/quickquip/llm/briefing.py b/src/quickquip/llm/briefing.py index 4b000f84..5e4a10de 100644 --- a/src/quickquip/llm/briefing.py +++ b/src/quickquip/llm/briefing.py @@ -104,7 +104,9 @@ def _build_user_prompt(context: DailyBriefingContext, *, identity_resolver=None) lines.append("消息样本:") lines.append("=== 样本开始 ===") - lines.append(_format_sample_messages(context.sample_messages, identity_resolver=identity_resolver)) + lines.append( + _format_sample_messages(context.sample_messages, identity_resolver=identity_resolver) + ) lines.append("=== 样本结束 ===") lines.append("") lines.append("请直接输出最终播报正文,不要附加解释。") @@ -133,7 +135,9 @@ async def generate_daily_briefing( default_model: str, identity_resolver=None, ) -> tuple[str, str]: - set_usage_scope("briefing", group_id=str(group_id), persona_id=persona.id, run_id=new_usage_run_id()) + set_usage_scope( + "briefing", group_id=str(group_id), persona_id=persona.id, run_id=new_usage_run_id() + ) system_prompt = _build_system_prompt(persona, context, briefing_config) user_message = LLMConversationMessage( role="user", content=_build_user_prompt(context, identity_resolver=identity_resolver) @@ -197,7 +201,11 @@ async def generate_daily_briefing( ) last_error = RuntimeError(f"non-normal finish_reason: {response.finish_reason!r}") continue - logger.warning("daily_briefing: %s/%s returned empty text, trying next", provider_id, model) + logger.warning( + "daily_briefing: %s/%s returned empty text, trying next", + provider_id, + model, + ) except LLMProviderError as exc: logger.warning( "daily_briefing: %s/%s provider error: %s, trying next", diff --git a/src/quickquip/llm/config.py b/src/quickquip/llm/config.py index 5cacfbee..27547bef 100644 --- a/src/quickquip/llm/config.py +++ b/src/quickquip/llm/config.py @@ -176,7 +176,10 @@ class ImagePreprocessingConfig: # 群聊用户可见的"当前 provider 已禁用"提示:回复主链 / 单发命令 / 当前探活共用,勿在各处另行拼装 -DISABLED_PROVIDER_REPLY = "当前 provider 已禁用:{provider_id}(enabled = false),请用 /llm use 切换其他 provider。" +DISABLED_PROVIDER_REPLY = ( + "当前 provider 已禁用:{provider_id}(enabled = false)" + ",请用 /llm use 切换其他 provider。" +) @dataclass(slots=True) @@ -361,7 +364,9 @@ def resolve_epoch_params(self, provider: ProviderConfig | None = None) -> "Epoch overrides[param_field] = getattr(base, param_field) if value is None else value merged = EpochParams(**overrides) if not _epoch_params_valid(merged): - logger.warning("provider %s 的 epoch_* 覆盖参数关系非法,回退 [runtime] 值", provider.id) + logger.warning( + "provider %s 的 epoch_* 覆盖参数关系非法,回退 [runtime] 值", provider.id + ) return base return merged @@ -381,7 +386,8 @@ def _epoch_params_valid(params: "EpochParams") -> bool: return ( params.context_tokens > 0 and params.cold_idle_seconds >= 0 - and 0 < params.cold_target_tokens < params.cold_trigger_tokens <= params.hot_target_tokens < params.cap_tokens + and 0 < params.cold_target_tokens < params.cold_trigger_tokens + <= params.hot_target_tokens < params.cap_tokens ) _KNOWN_PERSONA_KEYS = {"id", "display_name", "system_prompt", "style_prompt", "scope"} @@ -397,7 +403,9 @@ def _read_personas(raw_personas: list[dict[str, Any]]) -> dict[str, PersonaConfi raw_scope = entry.get("scope", []) if isinstance(raw_scope, str): raw_scope = [raw_scope] - parsed_scope = [s for s in (str(s).strip().lower() for s in raw_scope) if s in {"group", "private"}] + parsed_scope = [ + s for s in (str(s).strip().lower() for s in raw_scope) if s in {"group", "private"} + ] extras = {k: v for k, v in entry.items() if k not in _KNOWN_PERSONA_KEYS} personas[persona_id] = PersonaConfig( id=persona_id, @@ -485,26 +493,42 @@ def _load_personas_from_dir(personas_dir: Path) -> list[dict[str, Any]]: # Inject shared content if shared_system: existing = str(entry.get("system_prompt", "")).rstrip() - entry["system_prompt"] = (existing + "\n\n" + shared_system).lstrip() if existing else shared_system + entry["system_prompt"] = ( + (existing + "\n\n" + shared_system).lstrip() if existing else shared_system + ) if shared_style: existing = str(entry.get("style_prompt", "")).rstrip() - entry["style_prompt"] = (existing + "\n\n" + shared_style).lstrip() if existing else shared_style + entry["style_prompt"] = ( + (existing + "\n\n" + shared_style).lstrip() if existing else shared_style + ) personas.append(entry) elif "personas" in data: for entry in data["personas"]: entry = dict(entry) if shared_system: existing = str(entry.get("system_prompt", "")).rstrip() - entry["system_prompt"] = (existing + "\n\n" + shared_system).lstrip() if existing else shared_system + entry["system_prompt"] = ( + (existing + "\n\n" + shared_system).lstrip() + if existing + else shared_system + ) if shared_style: existing = str(entry.get("style_prompt", "")).rstrip() - entry["style_prompt"] = (existing + "\n\n" + shared_style).lstrip() if existing else shared_style + entry["style_prompt"] = ( + (existing + "\n\n" + shared_style).lstrip() + if existing + else shared_style + ) personas.append(entry) return personas -def _read_providers(raw_providers: list[dict[str, Any]], *, style_profiles: dict[str, str] | None = None) -> dict[str, ProviderConfig]: +def _read_providers( + raw_providers: list[dict[str, Any]], + *, + style_profiles: dict[str, str] | None = None, +) -> dict[str, ProviderConfig]: style_profiles = style_profiles or {} providers: dict[str, ProviderConfig] = {} for entry in raw_providers: @@ -561,7 +585,11 @@ def _parse_single_provider( default_model=str(entry.get("default_model", "")).strip(), models=models, enabled=as_bool(entry.get("enabled", True), default=True), - non_vision_models=[str(item).strip() for item in entry.get("non_vision_models", []) if str(item).strip()], + non_vision_models=[ + str(item).strip() + for item in entry.get("non_vision_models", []) + if str(item).strip() + ], timeout_seconds=float(entry.get("timeout_seconds", 45)), temperature=float(entry.get("temperature", 0.8)), max_output_tokens=int(entry.get("max_output_tokens", 800)), @@ -571,7 +599,11 @@ def _parse_single_provider( user_agent=str(entry.get("user_agent", "")).strip(), extra_body=expand_env_value(as_dict(entry.get("extra_body"))), aliases=aliases, - fallback_urls=[str(item).strip() for item in entry.get("fallback_urls", []) if str(item).strip()], + fallback_urls=[ + str(item).strip() + for item in entry.get("fallback_urls", []) + if str(item).strip() + ], proxy=str(entry.get("proxy", "")).strip(), prompt_caching=as_bool(entry.get("prompt_caching"), default=False), cache_ttl=str(entry.get("cache_ttl", "")).strip(), @@ -735,7 +767,11 @@ def _read_mcp_servers(raw_servers: list[dict[str, Any]]) -> list[MCPServerConfig headers={str(k): str(v) for k, v in raw_headers.items()}, image=str(entry.get("image", "")).strip(), docker_command=str(entry.get("docker_command", "docker")).strip() or "docker", - docker_args=[str(item) for item in entry.get("docker_args", []) if str(item).strip()], + docker_args=[ + str(item) + for item in entry.get("docker_args", []) + if str(item).strip() + ], mounts=[str(item).strip() for item in entry.get("mounts", []) if str(item).strip()], network=str(entry.get("network", "")).strip() or None, container_workdir=str(entry.get("container_workdir", "")).strip() or None, @@ -772,7 +808,9 @@ def load_personas_only(config_path: str | Path) -> dict[str, PersonaConfig]: return _read_personas(raw_personas) -def _read_providers_safe(raw_providers: Any, style_profiles: dict[str, str]) -> dict[str, ProviderConfig]: +def _read_providers_safe( + raw_providers: Any, style_profiles: dict[str, str] +) -> dict[str, ProviderConfig]: try: return _read_providers( raw_providers if isinstance(raw_providers, list) else [], @@ -823,7 +861,11 @@ def load_llm_config(path: str | Path) -> LLMConfig: monthly_report_raw = expand_env_value(as_dict(data.get("monthly_report"))) image_preprocessing_raw = expand_env_value(as_dict(data.get("image_preprocessing"))) raw_style_profiles = expand_env_value(as_dict(data.get("style_profiles"))) - style_profiles = {str(k).strip(): str(v).strip() for k, v in raw_style_profiles.items() if str(k).strip() and str(v).strip()} + style_profiles = { + str(k).strip(): str(v).strip() + for k, v in raw_style_profiles.items() + if str(k).strip() and str(v).strip() + } raw_pricing = as_dict(data.get("pricing")) raw_providers = data.get("providers", []) raw_mcp_servers = mcp_raw.get("servers", []) @@ -839,7 +881,8 @@ def load_llm_config(path: str | Path) -> LLMConfig: if _enabled_tools and "enabled_mode" not in tools_raw: # v1.11 及更早 enabled 非空 = 精确白名单;未显式声明 mode 的升级部署提示语义变化 logger.warning( - "[tools] enabled 非空且未设置 enabled_mode,按 append 语义在默认白名单与 MCP 工具之上追加;" + "[tools] enabled 非空且未设置 enabled_mode," + "按 append 语义在默认白名单与 MCP 工具之上追加;" '如需精确白名单请显式设置 enabled_mode = "replace"' ) @@ -871,17 +914,27 @@ def load_llm_config(path: str | Path) -> LLMConfig: default_provider=str(runtime_raw.get("default_provider", "")).strip() or None, default_persona=str(runtime_raw.get("default_persona", "")).strip() or None, history_limit=int(runtime_raw.get("history_limit", 10)), - history_max_messages_per_group=int(runtime_raw.get("history_max_messages_per_group", 40)), + history_max_messages_per_group=int( + runtime_raw.get("history_max_messages_per_group", 40) + ), memory_limit=int(runtime_raw.get("memory_limit", 6)), memory_max_items_per_group=int(runtime_raw.get("memory_max_items_per_group", 200)), max_prompt_chars=int(runtime_raw.get("max_prompt_chars", 4000)), - tool_calling_enabled=as_bool(runtime_raw.get("tool_calling_enabled", False), default=False), + tool_calling_enabled=as_bool( + runtime_raw.get("tool_calling_enabled", False), default=False + ), tool_max_rounds=int(runtime_raw.get("tool_max_rounds", 8)), tool_max_calls_per_round=int(runtime_raw.get("tool_max_calls_per_round", 16)), - retry_max_attempts=int(runtime_raw.get("retry_max_attempts", DEFAULT_RETRY_MAX_ATTEMPTS)), + retry_max_attempts=int( + runtime_raw.get("retry_max_attempts", DEFAULT_RETRY_MAX_ATTEMPTS) + ), retry_base_delay=float(runtime_raw.get("retry_base_delay", DEFAULT_RETRY_BASE_DELAY)), - retry_jitter=min(1.0, max(0.0, float(runtime_raw.get("retry_jitter", DEFAULT_RETRY_JITTER)))), - auto_memory_enabled=as_bool(runtime_raw.get("auto_memory_enabled", False), default=False), + retry_jitter=min( + 1.0, max(0.0, float(runtime_raw.get("retry_jitter", DEFAULT_RETRY_JITTER))) + ), + auto_memory_enabled=as_bool( + runtime_raw.get("auto_memory_enabled", False), default=False + ), auto_memory_prompt=str(runtime_raw.get("auto_memory_prompt", "")).strip(), auto_memory_max_tokens=max(32, int(runtime_raw.get("auto_memory_max_tokens", 256))), epoch_context_tokens=int(runtime_raw.get("epoch_context_tokens", 8000)), @@ -943,7 +996,9 @@ def load_llm_config(path: str | Path) -> LLMConfig: ), auto_search=AutoSearchConfig( enabled=as_bool(auto_search_raw.get("enabled", False), default=False), - search_max_calls_per_round=max(1, min(int(auto_search_raw.get("search_max_calls_per_round", 3)), 32)), + search_max_calls_per_round=max( + 1, min(int(auto_search_raw.get("search_max_calls_per_round", 3)), 32) + ), ), quick_judge=QuickJudgeConfig( provider_id=str(quick_judge_raw.get("provider_id", "")).strip(), @@ -957,7 +1012,9 @@ def load_llm_config(path: str | Path) -> LLMConfig: discovery_mode=str(tools_raw.get("discovery_mode", "auto")).strip().lower() or "auto", discovery_min_tools=max(1, int(tools_raw.get("discovery_min_tools", 10))), discovery_search_limit=max(1, min(int(tools_raw.get("discovery_search_limit", 5)), 20)), - discovery_max_loaded_tools=max(1, min(int(tools_raw.get("discovery_max_loaded_tools", 12)), 64)), + discovery_max_loaded_tools=max( + 1, min(int(tools_raw.get("discovery_max_loaded_tools", 12)), 64) + ), always_loaded=[ str(item).strip() for item in tools_raw.get("always_loaded", []) @@ -972,8 +1029,10 @@ def load_llm_config(path: str | Path) -> LLMConfig: personas=personas, daily_summary=DailySummaryConfig( enabled=as_bool(daily_summary_raw.get("enabled", False), default=False), - generate_cron=str(daily_summary_raw.get("generate_cron", "0 6 * * *")).strip() or "0 6 * * *", - publish_cron=str(daily_summary_raw.get("publish_cron", "0 12 * * *")).strip() or "0 12 * * *", + generate_cron=str(daily_summary_raw.get("generate_cron", "0 6 * * *")).strip() + or "0 6 * * *", + publish_cron=str(daily_summary_raw.get("publish_cron", "0 12 * * *")).strip() + or "0 12 * * *", min_messages=max(1, int(daily_summary_raw.get("min_messages", 30))), summary_length_hint=max(100, int(daily_summary_raw.get("summary_length_hint", 2000))), model_cascade=[ @@ -984,9 +1043,12 @@ def load_llm_config(path: str | Path) -> LLMConfig: ), daily_briefing=DailyBriefingConfig( enabled=as_bool(daily_briefing_raw.get("enabled", False), default=False), - morning_cron=str(daily_briefing_raw.get("morning_cron", "0 8 * * *")).strip() or "0 8 * * *", - noon_cron=str(daily_briefing_raw.get("noon_cron", "0 12 * * *")).strip() or "0 12 * * *", - evening_cron=str(daily_briefing_raw.get("evening_cron", "0 22 * * *")).strip() or "0 22 * * *", + morning_cron=str(daily_briefing_raw.get("morning_cron", "0 8 * * *")).strip() + or "0 8 * * *", + noon_cron=str(daily_briefing_raw.get("noon_cron", "0 12 * * *")).strip() + or "0 12 * * *", + evening_cron=str(daily_briefing_raw.get("evening_cron", "0 22 * * *")).strip() + or "0 22 * * *", min_messages_for_llm=max(1, int(daily_briefing_raw.get("min_messages_for_llm", 5))), active_users_limit=max(1, int(daily_briefing_raw.get("active_users_limit", 5))), hot_words_limit=max(1, int(daily_briefing_raw.get("hot_words_limit", 5))), @@ -1001,8 +1063,10 @@ def load_llm_config(path: str | Path) -> LLMConfig: ), weekly_report=WeeklyReportConfig( enabled=as_bool(weekly_report_raw.get("enabled", False), default=False), - generate_cron=str(weekly_report_raw.get("generate_cron", "0 9 * * 1")).strip() or "0 9 * * 1", - publish_cron=str(weekly_report_raw.get("publish_cron", "0 10 * * *")).strip() or "0 10 * * *", + generate_cron=str(weekly_report_raw.get("generate_cron", "0 9 * * 1")).strip() + or "0 9 * * 1", + publish_cron=str(weekly_report_raw.get("publish_cron", "0 10 * * *")).strip() + or "0 10 * * *", min_messages=max(1, int(weekly_report_raw.get("min_messages", 100))), length_hint=max(200, int(weekly_report_raw.get("length_hint", 2000))), model_cascade=[ @@ -1013,8 +1077,10 @@ def load_llm_config(path: str | Path) -> LLMConfig: ), monthly_report=MonthlyReportConfig( enabled=as_bool(monthly_report_raw.get("enabled", False), default=False), - generate_cron=str(monthly_report_raw.get("generate_cron", "0 9 1 * *")).strip() or "0 9 1 * *", - publish_cron=str(monthly_report_raw.get("publish_cron", "0 10 * * *")).strip() or "0 10 * * *", + generate_cron=str(monthly_report_raw.get("generate_cron", "0 9 1 * *")).strip() + or "0 9 1 * *", + publish_cron=str(monthly_report_raw.get("publish_cron", "0 10 * * *")).strip() + or "0 10 * * *", min_messages=max(1, int(monthly_report_raw.get("min_messages", 300))), length_hint=max(200, int(monthly_report_raw.get("length_hint", 2500))), input_char_budget=max( @@ -1096,7 +1162,9 @@ def _validate_and_fix_config(config: LLMConfig) -> None: config.runtime.default_persona = next(iter(config.personas)) elif config.runtime.default_persona not in config.personas: fallback = next(iter(config.personas)) - errors.append(f"默认 persona {config.runtime.default_persona!r} 不存在,已回退为 {fallback!r}") + errors.append( + f"默认 persona {config.runtime.default_persona!r} 不存在,已回退为 {fallback!r}" + ) config.runtime.default_persona = fallback # -- tools -- @@ -1110,9 +1178,13 @@ def _validate_and_fix_config(config: LLMConfig) -> None: if provider.protocol not in {"openai", "claude", "gemini"}: provider_errors.append(f"未知协议 {provider.protocol!r}") if provider.auth_method not in {"api_key", "bearer"}: - provider_errors.append(f"未知 auth_method {provider.auth_method!r}(仅支持 api_key / bearer)") + provider_errors.append( + f"未知 auth_method {provider.auth_method!r}(仅支持 api_key / bearer)" + ) if provider.protocol == "claude" and provider.cache_ttl not in ("", "5m", "1h"): - provider_errors.append(f"非法 cache_ttl {provider.cache_ttl!r}(claude 仅支持 5m / 1h,留空=默认 5min)") + provider_errors.append( + f"非法 cache_ttl {provider.cache_ttl!r}(claude 仅支持 5m / 1h,留空=默认 5min)" + ) if provider.builtin_search and provider.protocol != "gemini": # 非 gemini 协议不剪除 provider:键误配只影响该键本身,记录 # warning 即可,请求级生效由 provider_builtin_search_active 兜底为惰性。 @@ -1129,7 +1201,11 @@ def _validate_and_fix_config(config: LLMConfig) -> None: provider_errors.append("缺少 default_model") if provider.default_model and provider.default_model not in provider.models: provider.models.insert(0, provider.default_model) - logger.warning("provider %s 的 default_model %r 不在 models 列表中,已自动添加", pid, provider.default_model) + logger.warning( + "provider %s 的 default_model %r 不在 models 列表中,已自动添加", + pid, + provider.default_model, + ) if provider_errors: logger.error("provider %s 配置无效:%s,已跳过", pid, "; ".join(provider_errors)) @@ -1167,10 +1243,26 @@ def _validate_and_fix_config(config: LLMConfig) -> None: ) for cascade_name, feature_enabled, cascade_list in [ - ("daily_summary.model_cascade", config.daily_summary.enabled, config.daily_summary.model_cascade), - ("daily_briefing.model_cascade", config.daily_briefing.enabled, config.daily_briefing.model_cascade), - ("weekly_report.model_cascade", config.weekly_report.enabled, config.weekly_report.model_cascade), - ("monthly_report.model_cascade", config.monthly_report.enabled, config.monthly_report.model_cascade), + ( + "daily_summary.model_cascade", + config.daily_summary.enabled, + config.daily_summary.model_cascade, + ), + ( + "daily_briefing.model_cascade", + config.daily_briefing.enabled, + config.daily_briefing.model_cascade, + ), + ( + "weekly_report.model_cascade", + config.weekly_report.enabled, + config.weekly_report.model_cascade, + ), + ( + "monthly_report.model_cascade", + config.monthly_report.enabled, + config.monthly_report.model_cascade, + ), ]: if not feature_enabled: continue diff --git a/src/quickquip/llm/epoch.py b/src/quickquip/llm/epoch.py index e4550a2f..80004ec5 100644 --- a/src/quickquip/llm/epoch.py +++ b/src/quickquip/llm/epoch.py @@ -137,12 +137,16 @@ def maybe_advance( # 冷场:provider 侧缓存已死,重置是免费 miss,缩回冷场水位。 candidate = self._pick_anchor_by_tokens(rows, params.cold_target_tokens) if candidate > state.anchor_id: - event = self._advance(state, store, key, candidate, reason="cold", epoch_tokens=total) + event = self._advance( + state, store, key, candidate, reason="cold", epoch_tokens=total + ) elif total > params.cap_tokens: # 触顶:付费 miss 仅这一次,缩到热水位保住长话题。 candidate = self._pick_anchor_by_tokens(rows, params.hot_target_tokens) if candidate > state.anchor_id: - event = self._advance(state, store, key, candidate, reason="hot", epoch_tokens=total) + event = self._advance( + state, store, key, candidate, reason="hot", epoch_tokens=total + ) return event def note_activity(self, key: EpochKey) -> None: @@ -157,7 +161,11 @@ def current_anchor(self, key: EpochKey) -> int | None: def oldest_anchor(self, scope_key: str) -> int | None: """该 scope 所有键中最老的锚点(crop 的 floor);无状态返回 None。""" - anchors = [state.anchor_id for key, state in self._states.items() if key.scope_key == scope_key] + anchors = [ + state.anchor_id + for key, state in self._states.items() + if key.scope_key == scope_key + ] return min(anchors) if anchors else None def reset_scope(self, scope_key: str) -> None: @@ -189,7 +197,9 @@ def advance_to_cold_water( if total > params.cold_trigger_tokens: candidate = self._pick_anchor_by_tokens(rows, params.cold_target_tokens) if candidate > state.anchor_id: - event = self._advance(state, store, key, candidate, reason=reason, epoch_tokens=total) + event = self._advance( + state, store, key, candidate, reason=reason, epoch_tokens=total + ) # persona 切换后缓存重新烧入,T 从切换点重新计。 state.last_activity_at = self._clock() return event @@ -238,10 +248,14 @@ def _lazy_init(self, key: EpochKey, store: LLMStore, params: EpochParams) -> Epo ``ASC + LIMIT`` 会读到最旧一批行,CTX 跨度就量在了错误的一端。 """ start = store.find_anchor_row_id_by_rows(key.scope_key, DEFAULT_EPOCH_MAX_ROWS) or 0 - rows = store.list_conversation_messages_since(key.scope_key, start, limit=DEFAULT_EPOCH_MAX_ROWS) + rows = store.list_conversation_messages_since( + key.scope_key, start, limit=DEFAULT_EPOCH_MAX_ROWS + ) anchor = 0 if rows: - anchor = self._pair_align(store, key.scope_key, self._pick_anchor_by_tokens(rows, params.context_tokens)) + anchor = self._pair_align( + store, key.scope_key, self._pick_anchor_by_tokens(rows, params.context_tokens) + ) return EpochState(anchor_id=anchor, last_activity_at=self._clock()) def _advance( @@ -258,7 +272,13 @@ def _advance( state.anchor_id = self._pair_align(store, key.scope_key, candidate_anchor) logger.info( "epoch advance scope=%s provider=%s model=%s reason=%s anchor=%d->%d tokens=%d", - key.scope_key, key.provider_id, key.model, reason, old_anchor, state.anchor_id, epoch_tokens, + key.scope_key, + key.provider_id, + key.model, + reason, + old_anchor, + state.anchor_id, + epoch_tokens, ) return EpochResetEvent( reason=reason, diff --git a/src/quickquip/llm/health.py b/src/quickquip/llm/health.py index 413e7494..fd444dec 100644 --- a/src/quickquip/llm/health.py +++ b/src/quickquip/llm/health.py @@ -220,7 +220,8 @@ async def build_health_report( HealthCheckItem( "tools", tool_status, - f"工具调用 {'开启' if config.runtime.tool_calling_enabled else '关闭'},可用工具 {enabled_tool_count} 个", + f"工具调用 {'开启' if config.runtime.tool_calling_enabled else '关闭'}," + f"可用工具 {enabled_tool_count} 个", {"enabled": config.runtime.tool_calling_enabled, "tools": tool_names}, ) ) @@ -383,7 +384,9 @@ async def build_health_report( ) ) - bindings_ok = recent_buffer_bound and (chat_type == "private" or (stats_bound and rule_switch_bound)) + bindings_ok = recent_buffer_bound and ( + chat_type == "private" or (stats_bound and rule_switch_bound) + ) items.append( HealthCheckItem( "runtime_bindings", diff --git a/src/quickquip/llm/history_projection.py b/src/quickquip/llm/history_projection.py index 1bc5b921..6588afeb 100644 --- a/src/quickquip/llm/history_projection.py +++ b/src/quickquip/llm/history_projection.py @@ -142,7 +142,8 @@ def _validate_tool_pairing(turn: LoadedTurn) -> None: terminal = execution.status in {"succeeded", "failed", "indeterminate", "not_executed"} if not terminal: raise HistoryProjectionError( - f"turn={turn.turn_id} execution={execution.execution_id} 无终态({execution.status})" + f"turn={turn.turn_id} execution={execution.execution_id} " + f"无终态({execution.status})" ) if ( execution.status in {"succeeded", "failed"} @@ -266,7 +267,10 @@ def _project_turn_structured( blocks = _turn_native_blocks(turn) if blocks is not None: thinking_blocks = [ - block for block in blocks if block.get("type") in {"thinking", "redacted_thinking", "reasoning", "gemini_part"} + block + for block in blocks + if block.get("type") + in {"thinking", "redacted_thinking", "reasoning", "gemini_part"} ] messages = [ LLMConversationMessage( @@ -358,7 +362,11 @@ def project_loops( if _turn_native_blocks(turn) is not None ) for turn in loop.turns: - loop_messages.extend(_project_turn_structured(turn, loop.loop_id, native_owner_match=owner_match)) + loop_messages.extend( + _project_turn_structured( + turn, loop.loop_id, native_owner_match=owner_match + ) + ) else: loop_messages = _project_loop_archive(loop) messages.extend(loop_messages) @@ -445,7 +453,12 @@ def _excerpt(text: str, budget: int) -> str: summary = "、".join(f"{name}×{count}" for name, count in counts.items()) lines.append(f"(Turn {turn.turn_index} 工具:{summary},正文未保留)") return [ - LLMConversationMessage(role="user", content=trigger if char_budget >= len(trigger) else _excerpt(trigger, per_turn)), + LLMConversationMessage( + role="user", + content=( + trigger if char_budget >= len(trigger) else _excerpt(trigger, per_turn) + ), + ), LLMConversationMessage(role="assistant", content="\n".join(lines)), ] diff --git a/src/quickquip/llm/identity.py b/src/quickquip/llm/identity.py index 521d95f0..7e6dd83e 100644 --- a/src/quickquip/llm/identity.py +++ b/src/quickquip/llm/identity.py @@ -39,7 +39,11 @@ def collect_known_participants( participants: list[dict[str, str]] = [] seen_user_ids: set[str] = set() - def _push(raw_user_id: int | str | None, raw_sender_name: str = "", raw_canonical_name: str = "") -> None: + def _push( + raw_user_id: int | str | None, + raw_sender_name: str = "", + raw_canonical_name: str = "", + ) -> None: user_key = str(raw_user_id or "").strip() if user_key and not user_key.isdigit(): # 合成触发源(boredom_timer/scheduled_timer 等)不是群成员, diff --git a/src/quickquip/llm/image_routing.py b/src/quickquip/llm/image_routing.py index 1dd07c80..4658b7dd 100644 --- a/src/quickquip/llm/image_routing.py +++ b/src/quickquip/llm/image_routing.py @@ -10,8 +10,12 @@ from quickquip.llm.prompting import collect_recent_image_urls -IMAGE_PREPROCESSING_UNAVAILABLE_REPLY = "当前模型无法直接读取图片,且前置图片识别服务不可用。请稍后重试或切换视觉模型。" -IMAGE_PREPROCESSING_FAILED_REPLY = "前置图片识别失败,为避免错误猜测,本次没有调用主模型。请稍后重试或切换视觉模型。" +IMAGE_PREPROCESSING_UNAVAILABLE_REPLY = ( + "当前模型无法直接读取图片,且前置图片识别服务不可用。请稍后重试或切换视觉模型。" +) +IMAGE_PREPROCESSING_FAILED_REPLY = ( + "前置图片识别失败,为避免错误猜测,本次没有调用主模型。请稍后重试或切换视觉模型。" +) # 候选来源标签前缀:service.py 落库/并入逻辑按此前缀过滤(转发并入转发文本、 # 近期缓冲图注不落库),改名必须同步,故收敛为单一事实来源。 @@ -77,7 +81,8 @@ def plan_non_vision_images( return ImageRoutingPlan( candidates=[], error_reply=( - f"一次最多识别 {MAX_IMAGES_PER_PREPROCESSING_REQUEST} 张图片,请减少图片数量后重试。" + f"一次最多识别 {MAX_IMAGES_PER_PREPROCESSING_REQUEST} 张图片," + "请减少图片数量后重试。" ), ) diff --git a/src/quickquip/llm/mcp/client.py b/src/quickquip/llm/mcp/client.py index 0db8a1cb..d0c80eb7 100644 --- a/src/quickquip/llm/mcp/client.py +++ b/src/quickquip/llm/mcp/client.py @@ -256,7 +256,9 @@ async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> MCPToolC raise MCPError(f"MCP 工具 {tool_name} 返回了不可识别的响应") return _format_tool_result(result) - async def _call_tool_modern(self, tool_name: str, arguments: dict[str, Any]) -> MCPToolCallResult: + async def _call_tool_modern( + self, tool_name: str, arguments: dict[str, Any] + ) -> MCPToolCallResult: assert self._modern_session is not None result = await self._modern_session.request( "tools/call", @@ -310,7 +312,11 @@ def _is_retryable(exc: Exception) -> bool: if isinstance(exc, (MCPLegacyFallbackSignal,)): return False if isinstance(exc, MCPError): - if exc.failure_kind in (MCP_FAILURE_AUTH, MCP_FAILURE_CONFIG, MCP_FAILURE_MODERN_NEGOTIATION): + if exc.failure_kind in ( + MCP_FAILURE_AUTH, + MCP_FAILURE_CONFIG, + MCP_FAILURE_MODERN_NEGOTIATION, + ): return False if exc.http_status and 400 <= exc.http_status < 500: return False diff --git a/src/quickquip/llm/mcp/jsonrpc.py b/src/quickquip/llm/mcp/jsonrpc.py index 79e5a39a..26319763 100644 --- a/src/quickquip/llm/mcp/jsonrpc.py +++ b/src/quickquip/llm/mcp/jsonrpc.py @@ -33,7 +33,9 @@ def __init__(self, transport: Transport, *, server_id: str, timeout_seconds: flo async def start(self) -> None: await self._transport.start() - self._reader_task = asyncio.create_task(self._reader_loop(), name=f"mcp-session-{self._server_id}") + self._reader_task = asyncio.create_task( + self._reader_loop(), name=f"mcp-session-{self._server_id}" + ) async def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]: request_id = self._next_id diff --git a/src/quickquip/llm/mcp/transport.py b/src/quickquip/llm/mcp/transport.py index 2682f0f5..b2878145 100644 --- a/src/quickquip/llm/mcp/transport.py +++ b/src/quickquip/llm/mcp/transport.py @@ -149,7 +149,9 @@ def _build_command(self) -> tuple[list[str], dict[str, str], str | None, dict[st if not self.config.image: raise MCPError(f"MCP server {self.config.id} 缺少 image") - command = [self.config.docker_command, "run", "-i", "--rm", "--pull", self.config.pull_policy] + command = [ + self.config.docker_command, "run", "-i", "--rm", "--pull", self.config.pull_policy + ] if self.config.network: command.extend(["--network", self.config.network]) if self.config.container_workdir: @@ -164,12 +166,18 @@ def _build_command(self) -> tuple[list[str], dict[str, str], str | None, dict[st env = dict(os.environ) return command, env, self.config.cwd, dict(self.config.env) - raise MCPError(f"MCP server {self.config.id} 使用了未知 stdio transport:{self.config.transport}") + raise MCPError( + f"MCP server {self.config.id} 使用了未知 stdio transport:{self.config.transport}" + ) async def start(self) -> None: command, env, cwd, docker_env = self._build_command() self._stdout_buffer.clear() - logger.info("Starting MCP server %s with transport=%s", self.config.id, self.config.transport) + logger.info( + "Starting MCP server %s with transport=%s", + self.config.id, + self.config.transport, + ) with _temp_env_file(docker_env) as env_file: if env_file is not None: image_idx = command.index(self.config.image) @@ -183,8 +191,12 @@ async def start(self) -> None: env=env, ) # temp file is deleted here; the child process already captured its env - self._reader_task = asyncio.create_task(self._reader_loop(), name=f"mcp-reader-{self.config.id}") - self._stderr_task = asyncio.create_task(self._stderr_loop(), name=f"mcp-stderr-{self.config.id}") + self._reader_task = asyncio.create_task( + self._reader_loop(), name=f"mcp-reader-{self.config.id}" + ) + self._stderr_task = asyncio.create_task( + self._stderr_loop(), name=f"mcp-stderr-{self.config.id}" + ) async def send(self, payload: dict[str, Any]) -> None: if self.process is None or self.process.stdin is None: @@ -373,7 +385,11 @@ async def send(self, payload: dict[str, Any]) -> None: http_status=status, ) from exc except httpx.RequestError as exc: - kind = MCP_FAILURE_TIMEOUT if isinstance(exc, httpx.TimeoutException) else MCP_FAILURE_TRANSPORT + kind = ( + MCP_FAILURE_TIMEOUT + if isinstance(exc, httpx.TimeoutException) + else MCP_FAILURE_TRANSPORT + ) raise MCPError( f"MCP server {self.config.id} 请求失败:{_sanitize_error_message(exc)}", failure_kind=kind, @@ -440,7 +456,9 @@ async def start(self) -> None: if self._endpoint_error is not None: await self._cancel_sse_task() - raise MCPError(f"MCP server {self.config.id} SSE 连接失败:{self._endpoint_error}") from self._endpoint_error + raise MCPError( + f"MCP server {self.config.id} SSE 连接失败:{self._endpoint_error}" + ) from self._endpoint_error async def send(self, payload: dict[str, Any]) -> None: if self._client is None or self._post_url is None: @@ -462,7 +480,11 @@ async def send(self, payload: dict[str, Any]) -> None: http_status=status, ) from exc except httpx.RequestError as exc: - kind = MCP_FAILURE_TIMEOUT if isinstance(exc, httpx.TimeoutException) else MCP_FAILURE_TRANSPORT + kind = ( + MCP_FAILURE_TIMEOUT + if isinstance(exc, httpx.TimeoutException) + else MCP_FAILURE_TRANSPORT + ) raise MCPError( f"MCP server {self.config.id} 请求失败:{_sanitize_error_message(exc)}", failure_kind=kind, diff --git a/src/quickquip/llm/mcp/types.py b/src/quickquip/llm/mcp/types.py index 8889735f..de70a92f 100644 --- a/src/quickquip/llm/mcp/types.py +++ b/src/quickquip/llm/mcp/types.py @@ -592,7 +592,10 @@ def deliver_mcp_tool_result( LLMInlineImage( data=decoded, media_type=candidate.mime_type.lower(), - source_label=f"MCP/{_safe_metadata(server_id)}/{_safe_metadata(tool_name)} image {len(images) + 1}", + source_label=( + f"MCP/{_safe_metadata(server_id)}/{_safe_metadata(tool_name)} " + f"image {len(images) + 1}" + ), ) ) return LLMToolOutput( diff --git a/src/quickquip/llm/message_segments.py b/src/quickquip/llm/message_segments.py index 50fbcebe..b0988362 100644 --- a/src/quickquip/llm/message_segments.py +++ b/src/quickquip/llm/message_segments.py @@ -37,7 +37,12 @@ def message_has_segments(message) -> bool: segments = list(message) except TypeError: return False - return bool(segments and any(hasattr(segment, "type") or isinstance(segment, dict) for segment in segments)) + return bool( + segments + and any( + hasattr(segment, "type") or isinstance(segment, dict) for segment in segments + ) + ) def render_segment_leaf( @@ -58,7 +63,13 @@ def render_segment_leaf( if qq and qq in bot_keys: return "", [], True if qq: - return identities.render_mention(qq, fallback_name=names.get(qq) or str(data.get("name", "") or "")), [], False + return ( + identities.render_mention( + qq, fallback_name=names.get(qq) or str(data.get("name", "") or "") + ), + [], + False, + ) return "", [], False if segment_type == "text": diff --git a/src/quickquip/llm/profile.py b/src/quickquip/llm/profile.py index 0556ece3..245a1e7c 100644 --- a/src/quickquip/llm/profile.py +++ b/src/quickquip/llm/profile.py @@ -115,11 +115,15 @@ async def generate_profile( ) -> tuple[str, str]: set_usage_scope("profile") sections = [ - f"请以你的语气,为群友「{target_name}」写一篇人物志,目标长度约 {profile_mode.target_chars} 字。", + f"请以你的语气,为群友「{target_name}」写一篇人物志," + f"目标长度约 {profile_mode.target_chars} 字。", f"\n群内发言总数:{message_count} 条", ] if profile_mode.id == "short": - sections[0] = f"请以你的语气,写一段关于群友「{target_name}」的简短人物志,目标长度约 {profile_mode.target_chars} 字。" + sections[0] = ( + f"请以你的语气,写一段关于群友「{target_name}」的简短人物志," + f"目标长度约 {profile_mode.target_chars} 字。" + ) sections.append("风格自然随意,像在群里聊天,不要正式介绍。") else: sections.extend([ @@ -137,8 +141,16 @@ async def generate_profile( sections, recent_samples, profile_mode.max_input_tokens ) if fitted_samples: - sample_title = "完整发言记录(按时间顺序,受输入上限约束)" if profile_mode.full_records else "近期发言样本(按时间顺序节选)" - sample_note = "\n(注:由于发言量较大,上方记录已在输入上限内保留最近部分。)" if samples_truncated else "" + sample_title = ( + "完整发言记录(按时间顺序,受输入上限约束)" + if profile_mode.full_records + else "近期发言样本(按时间顺序节选)" + ) + sample_note = ( + "\n(注:由于发言量较大,上方记录已在输入上限内保留最近部分。)" + if samples_truncated + else "" + ) sections.append( f"\n{sample_title}:\n" + "\n".join(f"- {s}" for s in fitted_samples) + sample_note ) diff --git a/src/quickquip/llm/prompting.py b/src/quickquip/llm/prompting.py index 9cda0014..50b0898a 100644 --- a/src/quickquip/llm/prompting.py +++ b/src/quickquip/llm/prompting.py @@ -65,11 +65,22 @@ def format_participant_label( # 合成触发源(boredom_timer / scheduled_timer)不是 QQ 号:直接以名字呈现, # 不包装成「(QQ xxx,未登记)」伪身份——system prompt 教模型按 QQ 号认人 return normalized_sender_name or normalized_user_id - if normalized_canonical_name and normalized_sender_name and normalized_canonical_name != normalized_sender_name: - return f"{normalized_canonical_name}(QQ {normalized_user_id},当前显示名:{normalized_sender_name})" + if ( + normalized_canonical_name + and normalized_sender_name + and normalized_canonical_name != normalized_sender_name + ): + return ( + f"{normalized_canonical_name}(QQ {normalized_user_id}," + f"当前显示名:{normalized_sender_name})" + ) if normalized_canonical_name: return f"{normalized_canonical_name}(QQ {normalized_user_id})" - if normalized_sender_name and normalized_user_id and normalized_sender_name != normalized_user_id: + if ( + normalized_sender_name + and normalized_user_id + and normalized_sender_name != normalized_user_id + ): if include_unregistered_note: return f"{normalized_sender_name}(QQ {normalized_user_id},未登记)" return f"{normalized_sender_name}(QQ {normalized_user_id})" @@ -210,7 +221,8 @@ def build_system_prompt( group_id: int | str, tool_specs: list[LLMToolSpec], search_tool_name: str, - search_mode: str = "none", # "builtin"(provider 内置 grounding)| "searxng"(search_web)| "none" + # "builtin"(provider 内置 grounding)| "searxng"(search_web)| "none" + search_mode: str = "none", tool_discovery_enabled: bool = False, tool_search_name: str = "tool_search", tool_list_name: str = "tool_list", @@ -241,19 +253,36 @@ def build_system_prompt( lines.append("认人规则:") lines.append("- 优先按标准身份(名字)识别发言人;名字后括号内的 QQ 号仅用于区分同名成员。") lines.append("- 不同 QQ 号默认视为不同的人,不要把两个人合并成同一发言者。") - lines.append('- 上下文里已按「名字(QQ …)」标注发言者时,后续继续沿用该名字,不要自行改口或张冠李戴。') - lines.append("- 正文中被艾特的成员以「@名字」出现;以「@QQ 号」数字形态出现的艾特与发言标注中的号码一一对应。") - lines.append("- 只输出给用户看的最终回答,禁止输出任何内部推理、思维链、草稿、隐藏分析或 // 之类标签。") + lines.append( + '- 上下文里已按「名字(QQ …)」标注发言者时,' + '后续继续沿用该名字,不要自行改口或张冠李戴。' + ) + lines.append( + "- 正文中被艾特的成员以「@名字」出现;" + '以「@QQ 号」数字形态出现的艾特与发言标注中的号码一一对应。' + ) + lines.append( + "- 只输出给用户看的最终回答," + "禁止输出任何内部推理、思维链、草稿、隐藏分析或 " + "// 之类标签。" + ) lines.append("引用判定:") lines.append("- 当前提问者永远是本条消息的发送者;引用发送者只是被引用对象,不是当前说话者。") - lines.append("- 当 A 引用 B 的消息向你提问时,始终把 A 视为当前提问者,把 B 视为引用来源,不要把 B 当成当前发言者。") + lines.append( + "- 当 A 引用 B 的消息向你提问时," + "始终把 A 视为当前提问者,把 B 视为引用来源," + "不要把 B 当成当前发言者。" + ) lines.append("- 即使引用来源是机器人自己,也要把当前提问者和引用来源分开理解。") lines.append("消息格式说明:") lines.append("- 所有消息均标注了发言者身份,格式为:身份(QQ 号)或 身份(QQ 号,当前显示名)") lines.append(f"- 以「{SCENE_MARKER_CURRENT}」标记的是当前需要回复的消息") lines.append(f"- 以「{SCENE_MARKER_CONTEXT}」标记的是上文对话历史") - lines.append(f"- 以「{SCENE_MARKER_LIVE}」标记的是上一轮对话之后群内的其他发言(现场氛围,非直接对话)") + lines.append( + f"- 以「{SCENE_MARKER_LIVE}」标记的是上一轮对话之后群内的其他发言" + f"(现场氛围,非直接对话)" + ) if chat_type == "private": lines.append("- 当前会话类型:私聊") @@ -287,9 +316,13 @@ def build_system_prompt( ] if tool_discovery_enabled: tool_lines.extend([ - f"- 当前只展示常驻工具;需要未展示的外部能力、MCP 能力或专门查询能力时,先调用 {tool_search_name}。", + f"- 当前只展示常驻工具;" + f"需要未展示的外部能力、MCP 能力或专门查询能力时," + f"先调用 {tool_search_name}。", f"- {tool_search_name} 会按能力描述返回并加载少量相关工具,之后再调用对应工具名。", - f"- 如果 {tool_search_name} 没找到但你认为工具存在,用 {tool_list_name} 查看工具组、名称或摘要;确认工具名后用 {tool_list_name} 的 load 模式加载。", + f"- 如果 {tool_search_name} 没找到但你认为工具存在," + f"用 {tool_list_name} 查看工具组、名称或摘要;" + f"确认工具名后用 {tool_list_name} 的 load 模式加载。", ]) categories = [item for item in deferred_tool_categories or [] if item.strip()] if categories: @@ -298,7 +331,8 @@ def build_system_prompt( tool_lines.extend([ "- 当前联网后端:SearXNG。", f"- {_CURRENT_INFO_TRIGGER_HINT}链接的问题时,请主动调用 {search_tool_name}。", - f"- 当前 {search_tool_name} 走项目内 SearXNG;搜索结果不够时,可以继续多次调用 {search_tool_name} 细化检索。", + f"- 当前 {search_tool_name} 走项目内 SearXNG;" + f"搜索结果不够时,可以继续多次调用 {search_tool_name} 细化检索。", "- 优先先搜再答,再根据搜索结果组织结论。", ]) tool_lines.append("- 工具结果不足时,明确告诉用户不足,不要编造。") @@ -332,7 +366,10 @@ def build_turn_envelope( 配对键),空列表整段省略。 """ lines: list[str] = ["【轮次上下文】"] - lines.append(f"- 当前时间:{now:%Y-%m-%d} {_WEEKDAY_NAMES[now.weekday()]} {now:%H:%M}(北京时间)") + lines.append( + f"- 当前时间:{now:%Y-%m-%d} {_WEEKDAY_NAMES[now.weekday()]} " + f"{now:%H:%M}(北京时间)" + ) festival_appendix = get_festival_persona_appendix(today=now.date()) if festival_appendix: @@ -387,7 +424,9 @@ def build_turn_envelope( return "\n".join(lines) -def _resolve_canonical_name(identities, user_id: str, sender_name: str, stored_canonical: str) -> str: +def _resolve_canonical_name( + identities, user_id: str, sender_name: str, stored_canonical: str +) -> str: if identities is None or not user_id.strip(): return stored_canonical match = identities.resolve_user(user_id, sender_name) @@ -441,7 +480,8 @@ def _build_scenes_from_history( user_id = str(item.get("user_id") or "") sender_name = str(item.get("sender_name") or "") raw_text = _history_text(item) - # 渲染冻结:history 行信任落库定格的 canonical_name(前缀稳定契约,见 docs/dev/llm-module.md §4.2) + # 渲染冻结:history 行信任落库定格的 canonical_name + # (前缀稳定契约,见 docs/dev/llm-module.md §4.2) canonical_name = str(item.get("canonical_name") or "") pending_speakers.append({ "user_id": user_id, @@ -519,7 +559,11 @@ def _build_scene_from_current_message( if quoted_text.strip() or (quoted_image_urls or []): q_user_id = quoted_user_id.strip() q_sender = "机器人自己" if quoted_is_bot_self else quoted_sender_name.strip() - q_canonical = "机器人自己" if quoted_is_bot_self else _resolve_canonical_name(identities, q_user_id, q_sender, "") + q_canonical = ( + "机器人自己" + if quoted_is_bot_self + else _resolve_canonical_name(identities, q_user_id, q_sender, "") + ) q_text = quoted_text.strip() if q_text: suffix = f" [附图 {len(quoted_image_urls)} 张]" if quoted_image_urls else "" @@ -553,7 +597,9 @@ def _build_scene_from_current_message( successful = [ desc for desc in image_descriptions - if getattr(desc, "success", False) and str(getattr(desc, "text_description", "")).strip() + if getattr(desc, "success", False) and str( + getattr(desc, "text_description", "") + ).strip() ] per_description_budget = min( MAX_IMAGE_DESCRIPTION_CHARS, @@ -711,7 +757,8 @@ def _flush_pending(): user_id = str(item.get("user_id") or "") sender_name = str(item.get("sender_name") or "") raw_text = _history_text(item) - # 渲染冻结:history 行信任落库定格的 canonical_name(前缀稳定契约,见 docs/dev/llm-module.md §4.2) + # 渲染冻结:history 行信任落库定格的 canonical_name + # (前缀稳定契约,见 docs/dev/llm-module.md §4.2) canonical_name = str(item.get("canonical_name") or "") pending_speakers.append({ "user_id": user_id, @@ -731,7 +778,9 @@ def _flush_pending(): # 群里最近分享的图。newest-first 由 collect_recent_image_urls 保证,重复跳过。 # 图片源与文本补丁解耦:被动唤醒的近期图是全量快照语义(TTL 窗),服务层 # 传入 recent_images_messages;缺省回落到补丁列表(显式注入路径同源)。 - images_source = recent_images_messages if recent_images_messages is not None else recent_messages + images_source = ( + recent_images_messages if recent_images_messages is not None else recent_messages + ) recent_images: list[str] = [] if images_source and include_recent_images: recent_images = collect_recent_image_urls( diff --git a/src/quickquip/llm/provider/base.py b/src/quickquip/llm/provider/base.py index 4f4c457a..65cfdc73 100644 --- a/src/quickquip/llm/provider/base.py +++ b/src/quickquip/llm/provider/base.py @@ -476,7 +476,9 @@ async def _download_image_uncached(self, image_url: str) -> LLMImageInput: image_url, headers={"User-Agent": "QuickQuip/1.0"} ) response.raise_for_status() - media_type = response.headers.get("content-type", "image/jpeg").split(";")[0].strip() + media_type = ( + response.headers.get("content-type", "image/jpeg").split(";")[0].strip() + ) if not media_type.startswith("image/"): raise LLMProviderError(f"图片 URL 不是受支持的图片类型:{image_url}") raw = response.content @@ -492,7 +494,9 @@ async def _download_image_uncached(self, image_url: str) -> LLMImageInput: if not raw: raise LLMProviderError(f"图片内容为空:{image_url}") if len(raw) > MAX_IMAGE_BYTES: - raise LLMProviderError(f"图片过大,当前限制为 {MAX_IMAGE_BYTES // (1024 * 1024)}MB:{image_url}") + raise LLMProviderError( + f"图片过大,当前限制为 {MAX_IMAGE_BYTES // (1024 * 1024)}MB:{image_url}" + ) return LLMImageInput( source_url=image_url, @@ -533,7 +537,9 @@ async def _prepare_image_inputs( remaining = MAX_IMAGES_PER_REQUEST - len(candidates) for image in (inline_images or [])[:remaining]: candidates.append((image.source_label, image.data, image.media_type)) - budget = budget if budget is not None else InlineMediaBudget(self.config.max_inline_media_bytes) + budget = ( + budget if budget is not None else InlineMediaBudget(self.config.max_inline_media_bytes) + ) kept, _dropped = budget.guard(candidates) return [ LLMImageInput( @@ -569,7 +575,9 @@ async def _prepare_request_images( urls = message.image_urls if message.role == "user" else [] if not urls and not message.inline_images: continue - images[index] = await self._prepare_image_inputs(urls, message.inline_images, budget=budget) + images[index] = await self._prepare_image_inputs( + urls, message.inline_images, budget=budget + ) return images def _swap_base_url(self, url: str, new_base: str) -> str: @@ -584,7 +592,9 @@ def _candidate_urls(self, url: str): for fb in self.config.fallback_urls: yield self._swap_base_url(url, fb) - async def _execute_with_fallback(self, fn, url: str, headers: dict[str, str], payload: dict[str, Any]) -> tuple[Any, str]: + async def _execute_with_fallback( + self, fn, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> tuple[Any, str]: """按候选端点链执行,返回 ``(结果, 实际成功的 URL)``(§7.3)。 失败的可重试错误切换下一候选;不可重试立即抛。调用方用返回的 @@ -602,19 +612,27 @@ async def _execute_with_fallback(self, fn, url: str, headers: dict[str, str], pa last_exc = exc raise last_exc # type: ignore[misc] - async def _post_json_with_fallback(self, url: str, headers: dict[str, str], payload: dict[str, Any]) -> dict[str, Any]: + async def _post_json_with_fallback( + self, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> dict[str, Any]: data, _ = await self._execute_with_fallback(self._post_json, url, headers, payload) return data - async def _post_stream_sse_with_fallback(self, url: str, headers: dict[str, str], payload: dict[str, Any]) -> list[dict[str, Any]]: + async def _post_stream_sse_with_fallback( + self, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> list[dict[str, Any]]: events, _ = await self._execute_with_fallback(self._post_stream_sse, url, headers, payload) return events - async def _post_json_candidate(self, url: str, headers: dict[str, str], payload: dict[str, Any]) -> tuple[dict[str, Any], str]: + async def _post_json_candidate( + self, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> tuple[dict[str, Any], str]: """``_post_json_with_fallback`` 的候选可观测变体:带回实际端点。""" return await self._execute_with_fallback(self._post_json, url, headers, payload) - async def _post_stream_sse_candidate(self, url: str, headers: dict[str, str], payload: dict[str, Any]) -> tuple[list[dict[str, Any]], str]: + async def _post_stream_sse_candidate( + self, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> tuple[list[dict[str, Any]], str]: return await self._execute_with_fallback(self._post_stream_sse, url, headers, payload) def _combine_stream_trace( @@ -626,7 +644,9 @@ def _combine_stream_trace( f"{type(self).__name__} must reconstruct its streamed response" ) - async def _post_json(self, url: str, headers: dict[str, str], payload: dict[str, Any]) -> dict[str, Any]: + async def _post_json( + self, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> dict[str, Any]: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") request_headers = _headers_to_text(headers) started = time.monotonic() @@ -739,7 +759,9 @@ async def _post_json(self, url: str, headers: dict[str, str], payload: dict[str, ) return result - async def _post_stream_sse(self, url: str, headers: dict[str, str], payload: dict[str, Any]) -> list[dict[str, Any]]: + async def _post_stream_sse( + self, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> list[dict[str, Any]]: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers = {**headers, "accept": "text/event-stream"} started = time.monotonic() diff --git a/src/quickquip/llm/provider/claude.py b/src/quickquip/llm/provider/claude.py index 02d0ccca..6ef6ced9 100644 --- a/src/quickquip/llm/provider/claude.py +++ b/src/quickquip/llm/provider/claude.py @@ -80,7 +80,9 @@ def _cache_creation_tokens(usage: dict[str, Any]) -> int | None: class ClaudeProviderClient(BaseProviderClient): - def _serialize_user_message(self, message: LLMConversationMessage, image_inputs: list[LLMImageInput]) -> dict[str, Any]: + def _serialize_user_message( + self, message: LLMConversationMessage, image_inputs: list[LLMImageInput] + ) -> dict[str, Any]: if image_inputs: content: list[dict[str, Any]] = [ *[ @@ -100,7 +102,9 @@ def _serialize_user_message(self, message: LLMConversationMessage, image_inputs: return {"role": "user", "content": content} return {"role": "user", "content": message.content} - async def _serialize_messages(self, messages: list[LLMConversationMessage]) -> list[dict[str, Any]]: + async def _serialize_messages( + self, messages: list[LLMConversationMessage] + ) -> list[dict[str, Any]]: serialized: list[dict[str, Any]] = [] prepared_images = await self._prepare_request_images(messages) pending_tool_results: list[tuple[LLMConversationMessage, list[LLMImageInput]]] = [] @@ -136,7 +140,12 @@ async def _flush_tool_results() -> None: # 原生路径(§7.2):历史记录的原样 content 块深拷贝回放, # 不再从 text/tool_calls/thinking_blocks 重建(避免双写)。 serialized.append( - {"role": "assistant", "content": [deepcopy(block) for block in message.native_content]} + { + "role": "assistant", + "content": [ + deepcopy(block) for block in message.native_content + ], + } ) continue content: list[dict[str, Any]] = [*message.thinking_blocks] @@ -155,7 +164,12 @@ async def _flush_tool_results() -> None: "input": tool_input, } ) - serialized.append({"role": "assistant", "content": content or [{"type": "text", "text": ""}]}) + serialized.append( + { + "role": "assistant", + "content": content or [{"type": "text", "text": ""}], + } + ) continue serialized.append(self._serialize_user_message(message, image_inputs)) @@ -185,7 +199,9 @@ def _serialize_tool_result_content( ], ] - async def _build_request_parts(self, request: LLMRequest) -> tuple[str, dict[str, str], dict[str, Any]]: + async def _build_request_parts( + self, request: LLMRequest + ) -> tuple[str, dict[str, str], dict[str, Any]]: url = self.config.base_url.rstrip("/") + "/messages?beta=true" api_key = self._get_api_key() auth_key = "authorization" if self.config.auth_method == "bearer" else "x-api-key" @@ -246,7 +262,13 @@ async def _build_request_parts(self, request: LLMRequest) -> tuple[str, dict[str if last_block.get("type") not in ("thinking", "redacted_thinking"): last_block["cache_control"] = dict(cache_control) elif isinstance(content, str) and content: - last_msg["content"] = [{"type": "text", "text": content, "cache_control": dict(cache_control)}] + last_msg["content"] = [ + { + "type": "text", + "text": content, + "cache_control": dict(cache_control), + } + ] payload: dict[str, Any] = { "model": request.model, @@ -284,7 +306,11 @@ def _parse_response(data: dict[str, Any], fallback_model: str) -> LLMResponse: continue t = item.get("type") if t == "thinking": - block = {"type": "thinking", "thinking": item.get("thinking", ""), "signature": item.get("signature", "")} + block = { + "type": "thinking", + "thinking": item.get("thinking", ""), + "signature": item.get("signature", ""), + } thinking_blocks.append(block) native_blocks.append(dict(block)) elif t == "redacted_thinking": @@ -331,7 +357,8 @@ def _parse_response(data: dict[str, Any], fallback_model: str) -> LLMResponse: def _assemble_stream_response(chunks: list[dict[str, Any]], fallback_model: str) -> LLMResponse: text_acc: dict[int, str] = {} # block_index -> 累积文本(保序表示需要) tool_calls_acc: dict[int, dict[str, str]] = {} # block_index -> {id, name, input_json} - thinking_acc: dict[int, dict[str, str]] = {} # block_index -> {type, thinking, signature} 或 redacted {type, data} + # block_index -> {type, thinking, signature} 或 redacted {type, data} + thinking_acc: dict[int, dict[str, str]] = {} finish_reason: str | None = None model = fallback_model input_tokens: int | None = None @@ -365,7 +392,11 @@ def _assemble_stream_response(chunks: list[dict[str, Any]], fallback_model: str) "input_json": "", } elif block.get("type") == "thinking": - thinking_acc[current_block_index] = {"type": "thinking", "thinking": "", "signature": ""} + thinking_acc[current_block_index] = { + "type": "thinking", + "thinking": "", + "signature": "", + } elif block.get("type") == "redacted_thinking": # redacted_thinking 的完整 data 载荷只出现在 start 事件,无后续 delta thinking_acc[current_block_index] = { @@ -411,7 +442,12 @@ def _assemble_stream_response(chunks: list[dict[str, Any]], fallback_model: str) ( {"type": "redacted_thinking", "data": acc["data"]} if acc.get("type") == "redacted_thinking" - else {"type": "thinking", "thinking": acc["thinking"], "signature": acc["signature"]} + else + { + "type": "thinking", + "thinking": acc["thinking"], + "signature": acc["signature"], + } ) for _, acc in sorted(thinking_acc.items()) ] @@ -423,7 +459,14 @@ def _assemble_stream_response(chunks: list[dict[str, Any]], fallback_model: str) indexed_blocks.append((idx, {"type": "redacted_thinking", "data": acc["data"]})) else: indexed_blocks.append( - (idx, {"type": "thinking", "thinking": acc["thinking"], "signature": acc["signature"]}) + ( + idx, + { + "type": "thinking", + "thinking": acc["thinking"], + "signature": acc["signature"], + }, + ) ) for idx, text in text_acc.items(): indexed_blocks.append((idx, {"type": "text", "text": text})) @@ -433,7 +476,15 @@ def _assemble_stream_response(chunks: list[dict[str, Any]], fallback_model: str) except json.JSONDecodeError: tool_input = {} indexed_blocks.append( - (idx, {"type": "tool_use", "id": acc["id"] or f"tool_{idx + 1}", "name": acc["name"], "input": tool_input}) + ( + idx, + { + "type": "tool_use", + "id": acc["id"] or f"tool_{idx + 1}", + "name": acc["name"], + "input": tool_input, + }, + ) ) native_blocks = [block for _, block in sorted(indexed_blocks, key=lambda pair: pair[0])] return LLMResponse( @@ -495,9 +546,14 @@ def _combine_stream_trace( block["text"] = str(block.get("text", "")) + str(delta.get("text", "")) elif delta_type == "thinking_delta": block["type"] = "thinking" - block["thinking"] = str(block.get("thinking", "")) + str(delta.get("thinking", "")) + block["thinking"] = ( + str(block.get("thinking", "")) + str(delta.get("thinking", "")) + ) elif delta_type == "signature_delta": - block["signature"] = str(block.get("signature", "")) + str(delta.get("signature", "")) + block["signature"] = ( + str(block.get("signature", "")) + + str(delta.get("signature", "")) + ) elif delta_type == "input_json_delta": tool_json[index] = tool_json.get(index, "") + str(delta.get("partial_json", "")) elif event == "message_delta": diff --git a/src/quickquip/llm/provider/factory.py b/src/quickquip/llm/provider/factory.py index e5b74e7c..1ef8e868 100644 --- a/src/quickquip/llm/provider/factory.py +++ b/src/quickquip/llm/provider/factory.py @@ -14,7 +14,9 @@ from quickquip.llm.provider.retry import RetryPolicy -def build_provider_client(config: ProviderConfig, *, retry_policy: RetryPolicy | None = None) -> BaseProviderClient: +def build_provider_client( + config: ProviderConfig, *, retry_policy: RetryPolicy | None = None +) -> BaseProviderClient: if config.protocol == "openai": return OpenAIProviderClient(config, retry_policy=retry_policy) if config.protocol == "claude": diff --git a/src/quickquip/llm/provider/gemini.py b/src/quickquip/llm/provider/gemini.py index cbeac2dd..93318fc5 100644 --- a/src/quickquip/llm/provider/gemini.py +++ b/src/quickquip/llm/provider/gemini.py @@ -21,7 +21,9 @@ class GeminiProviderClient(BaseProviderClient): - def _serialize_user_parts(self, message: LLMConversationMessage, image_inputs: list[LLMImageInput]) -> list[dict[str, Any]]: + def _serialize_user_parts( + self, message: LLMConversationMessage, image_inputs: list[LLMImageInput] + ) -> list[dict[str, Any]]: parts: list[dict[str, Any]] = [ *[ { @@ -37,7 +39,9 @@ def _serialize_user_parts(self, message: LLMConversationMessage, image_inputs: l parts.append({"text": message.content}) return parts or [{"text": ""}] - async def _serialize_messages(self, messages: list[LLMConversationMessage]) -> list[dict[str, Any]]: + async def _serialize_messages( + self, messages: list[LLMConversationMessage] + ) -> list[dict[str, Any]]: serialized: list[dict[str, Any]] = [] prepared_images = await self._prepare_request_images(messages) pending_tool_results: list[tuple[LLMConversationMessage, list[LLMImageInput]]] = [] @@ -49,7 +53,9 @@ async def _flush_tool_results() -> None: serialized.append( { "role": "user", - "parts": self._serialize_function_response_parts([item for item, _ in pending_tool_results]), + "parts": self._serialize_function_response_parts( + [item for item, _ in pending_tool_results] + ), } ) # Gemini requires the complete functionResponse batch to stay in one @@ -71,7 +77,12 @@ async def _flush_tool_results() -> None: # 原生路径(§7.2):历史记录的原样 parts 深拷贝回放, # 保留 functionCall 与 thoughtSignature 的原始位置。 serialized.append( - {"role": "model", "parts": [deepcopy(part) for part in message.native_content]} + { + "role": "model", + "parts": [ + deepcopy(part) for part in message.native_content + ], + } ) continue parts = self._replay_parts(message.thinking_blocks) @@ -93,7 +104,12 @@ async def _flush_tool_results() -> None: serialized.append({"role": "model", "parts": parts or [{"text": ""}]}) continue - serialized.append({"role": "user", "parts": self._serialize_user_parts(message, image_inputs)}) + serialized.append( + { + "role": "user", + "parts": self._serialize_user_parts(message, image_inputs), + } + ) await _flush_tool_results() return serialized @@ -141,7 +157,9 @@ def _serialize_tool_result_image_parts( for image in image_inputs ] - async def _build_request_parts(self, request: LLMRequest, *, stream: bool = False) -> tuple[str, dict[str, str], dict[str, Any]]: + async def _build_request_parts( + self, request: LLMRequest, *, stream: bool = False + ) -> tuple[str, dict[str, str], dict[str, Any]]: api_key = self._get_api_key() action = "streamGenerateContent" if stream else "generateContent" url = self.config.base_url.rstrip("/") + f"/models/{request.model}:{action}" @@ -340,7 +358,9 @@ def _combine_stream_trace( and "text" in parts[-1] and bool(parts[-1].get("thought")) == thought ): - parts[-1]["text"] = str(parts[-1]["text"]) + str(raw_part.get("text", "")) + parts[-1]["text"] = ( + str(parts[-1]["text"]) + str(raw_part.get("text", "")) + ) for key, value in raw_part.items(): if key != "text": parts[-1][key] = deepcopy(value) diff --git a/src/quickquip/llm/provider/media_guard.py b/src/quickquip/llm/provider/media_guard.py index 1f8d5ea0..9038cfd5 100644 --- a/src/quickquip/llm/provider/media_guard.py +++ b/src/quickquip/llm/provider/media_guard.py @@ -143,7 +143,9 @@ class InlineMediaBudget: exhausted: bool = field(default=False, init=False) _seen: set[str] = field(default_factory=set, init=False, repr=False) - def guard(self, candidates: list[tuple[str, bytes, str]]) -> tuple[list[GuardedMedia], list[str]]: + def guard( + self, candidates: list[tuple[str, bytes, str]] + ) -> tuple[list[GuardedMedia], list[str]]: kept: list[GuardedMedia] = [] dropped: list[str] = [] for index, (label, raw, declared) in enumerate(candidates): @@ -178,7 +180,11 @@ def guard(self, candidates: list[tuple[str, bytes, str]]) -> tuple[list[GuardedM break self._seen.add(content_hash) self.total += len(data) - kept.append(GuardedMedia(label=label, data=data, media_type=media_type, content_hash=content_hash)) + kept.append( + GuardedMedia( + label=label, data=data, media_type=media_type, content_hash=content_hash + ) + ) return kept, dropped diff --git a/src/quickquip/llm/provider/openai.py b/src/quickquip/llm/provider/openai.py index 496a3356..11061404 100644 --- a/src/quickquip/llm/provider/openai.py +++ b/src/quickquip/llm/provider/openai.py @@ -24,7 +24,9 @@ def _extract_reasoning_content(thinking_blocks: list[dict[str, Any]]) -> str: return str(block.get("reasoning_content", "")) return "" - def _serialize_message(self, message: LLMConversationMessage, image_inputs: list[LLMImageInput]) -> dict[str, Any]: + def _serialize_message( + self, message: LLMConversationMessage, image_inputs: list[LLMImageInput] + ) -> dict[str, Any]: if message.role == "assistant": payload: dict[str, Any] = { "role": "assistant", @@ -71,7 +73,9 @@ def _serialize_message(self, message: LLMConversationMessage, image_inputs: list return {"role": "user", "content": message.content} - async def _build_request_parts(self, request: LLMRequest) -> tuple[str, dict[str, str], dict[str, Any]]: + async def _build_request_parts( + self, request: LLMRequest + ) -> tuple[str, dict[str, str], dict[str, Any]]: url = self.config.base_url.rstrip("/") + "/chat/completions" headers = { **self.config.headers, @@ -169,8 +173,16 @@ def _parse_response(data: dict[str, Any], fallback_model: str) -> LLMResponse: finish_reason=str(choice.get("finish_reason", "")).strip() or None, input_tokens=usage.get("prompt_tokens"), output_tokens=usage.get("completion_tokens"), - cache_read_tokens=prompt_details.get("cached_tokens") if isinstance(prompt_details, dict) else None, - thinking_tokens=completion_details.get("reasoning_tokens") if isinstance(completion_details, dict) else None, + cache_read_tokens=( + prompt_details.get("cached_tokens") + if isinstance(prompt_details, dict) + else None + ), + thinking_tokens=( + completion_details.get("reasoning_tokens") + if isinstance(completion_details, dict) + else None + ), thinking_blocks=thinking_blocks, ) @@ -216,10 +228,16 @@ def _assemble_stream_response(chunks: list[dict[str, Any]], fallback_model: str) if usage.get("completion_tokens") is not None: output_tokens = usage["completion_tokens"] prompt_details = usage.get("prompt_tokens_details") or {} - if isinstance(prompt_details, dict) and prompt_details.get("cached_tokens") is not None: + if ( + isinstance(prompt_details, dict) + and prompt_details.get("cached_tokens") is not None + ): cache_read_tokens = prompt_details["cached_tokens"] completion_details = usage.get("completion_tokens_details") or {} - if isinstance(completion_details, dict) and completion_details.get("reasoning_tokens") is not None: + if ( + isinstance(completion_details, dict) + and completion_details.get("reasoning_tokens") is not None + ): thinking_tokens = completion_details["reasoning_tokens"] tool_calls = [ @@ -232,7 +250,9 @@ def _assemble_stream_response(chunks: list[dict[str, Any]], fallback_model: str) ] thinking_blocks: list[dict[str, Any]] = [] if reasoning_parts: - thinking_blocks.append({"type": "reasoning", "reasoning_content": "".join(reasoning_parts)}) + thinking_blocks.append( + {"type": "reasoning", "reasoning_content": "".join(reasoning_parts)} + ) return LLMResponse( text=strip_leading_reasoning_content("".join(text_parts)), model=model, diff --git a/src/quickquip/llm/provider/trace.py b/src/quickquip/llm/provider/trace.py index 8bcdbd6b..e25cf214 100644 --- a/src/quickquip/llm/provider/trace.py +++ b/src/quickquip/llm/provider/trace.py @@ -262,7 +262,8 @@ def _ensure_schema(self) -> None: ) if "loop_sequence" not in trace_columns: conn.execute( - "ALTER TABLE llm_http_traces ADD COLUMN loop_sequence INTEGER NOT NULL DEFAULT 1" + "ALTER TABLE llm_http_traces ADD COLUMN loop_sequence " + "INTEGER NOT NULL DEFAULT 1" ) if "response_raw_text" not in trace_columns: conn.execute( @@ -270,7 +271,8 @@ def _ensure_schema(self) -> None: ) if "response_raw_bytes" not in trace_columns: conn.execute( - "ALTER TABLE llm_http_traces ADD COLUMN response_raw_bytes INTEGER NOT NULL DEFAULT 0" + "ALTER TABLE llm_http_traces ADD COLUMN response_raw_bytes " + "INTEGER NOT NULL DEFAULT 0" ) self._schema_ready = True diff --git a/src/quickquip/llm/provider_health.py b/src/quickquip/llm/provider_health.py index 4b3cffa3..4a3692e0 100644 --- a/src/quickquip/llm/provider_health.py +++ b/src/quickquip/llm/provider_health.py @@ -87,10 +87,18 @@ async def probe_provider( return ProviderHealth(provider.id, probe_model, "ok", latency_ms=latency_ms) except asyncio.TimeoutError: latency_ms = round(timeout * 1000) - return ProviderHealth(provider.id, probe_model, "error", latency_ms=latency_ms, error="timeout") + return ProviderHealth( + provider.id, probe_model, "error", latency_ms=latency_ms, error="timeout" + ) except Exception as exc: latency_ms = round((time.monotonic() - started) * 1000, 1) - return ProviderHealth(provider.id, probe_model, "error", latency_ms=latency_ms, error=type(exc).__name__) + return ProviderHealth( + provider.id, + probe_model, + "error", + latency_ms=latency_ms, + error=type(exc).__name__, + ) async def probe_all_providers( diff --git a/src/quickquip/llm/quick_judge.py b/src/quickquip/llm/quick_judge.py index 40c3a8d2..290b6320 100644 --- a/src/quickquip/llm/quick_judge.py +++ b/src/quickquip/llm/quick_judge.py @@ -154,7 +154,9 @@ async def run_quick_judge( 不走群配置、不注入记忆、不启用工具,只发单条 system+user。 优先使用 [triggers.quick_judge] 配置的 provider/model。 """ - result = await run_quick_judge_detailed(config, prompt, max_tokens, client_builder=client_builder) + result = await run_quick_judge_detailed( + config, prompt, max_tokens, client_builder=client_builder + ) if result.outcome == "provider_error" and result.error is not None: # 保持既有公共契约:provider 异常继续上抛(调用方 fail-closed 自行处理) raise result.error diff --git a/src/quickquip/llm/rendering.py b/src/quickquip/llm/rendering.py index 23cc3c7c..fc6f93fe 100644 --- a/src/quickquip/llm/rendering.py +++ b/src/quickquip/llm/rendering.py @@ -182,7 +182,9 @@ def render_reply_for_llm( sender_name = str(getattr(reply, "nickname", "") or "").strip() if not sender_name: sender_name = user_id - is_bot_self = user_id in normalize_bot_self_ids(bot_self_id=bot_self_id, bot_self_ids=bot_self_ids) + is_bot_self = user_id in normalize_bot_self_ids( + bot_self_id=bot_self_id, bot_self_ids=bot_self_ids + ) return RenderedReply( text=rendered.text, diff --git a/src/quickquip/llm/service.py b/src/quickquip/llm/service.py index a921cb12..f606a9d6 100644 --- a/src/quickquip/llm/service.py +++ b/src/quickquip/llm/service.py @@ -195,7 +195,11 @@ def __init__( self._register_builtin_tools() self.config = load_llm_config(self.config_path) - self._identity_repository = identities if Path(self.identity_path) == identities.path else IdentityRepository(self.identity_path) + self._identity_repository = ( + identities + if Path(self.identity_path) == identities.path + else IdentityRepository(self.identity_path) + ) try: self.store = LLMStore(db_path, identity_repository=self._identity_repository) except Exception as exc: @@ -282,7 +286,9 @@ def reload_personas(self) -> tuple[int, str | None]: self.config.runtime.default_persona = next(iter(new_personas)) return len(new_personas), None - def get_chat_settings(self, chat_id: int | str, chat_type: str = "group") -> ResolvedGroupSettings: + def get_chat_settings( + self, chat_id: int | str, chat_type: str = "group" + ) -> ResolvedGroupSettings: scope_key = self.build_chat_scope_key(chat_id, chat_type) overrides = self.store.get_group_settings(scope_key) settings = resolve_group_settings(self.store, self.config, scope_key) @@ -297,7 +303,9 @@ def get_chat_settings(self, chat_id: int | str, chat_type: str = "group") -> Res def get_group_settings(self, group_id: int | str) -> ResolvedGroupSettings: return self.get_chat_settings(group_id, chat_type="group") - def _update_chat_settings(self, chat_id: int | str, chat_type: str = "group", **fields: object) -> None: + def _update_chat_settings( + self, chat_id: int | str, chat_type: str = "group", **fields: object + ) -> None: self.store.update_group_settings(self.build_chat_scope_key(chat_id, chat_type), **fields) def _build_system_prompt( @@ -321,10 +329,14 @@ def _build_system_prompt( if builtin_search_active else ("searxng" if self.config.auto_search.enabled else "none") ), - tool_discovery_enabled=self._is_tool_discovery_enabled(chat_type, provider_id=provider_id), + tool_discovery_enabled=self._is_tool_discovery_enabled( + chat_type, provider_id=provider_id + ), tool_search_name=TOOL_SEARCH_NAME, tool_list_name=TOOL_LIST_NAME, - deferred_tool_categories=self._get_deferred_tool_categories(chat_type, provider_id=provider_id), + deferred_tool_categories=self._get_deferred_tool_categories( + chat_type, provider_id=provider_id + ), chat_type=chat_type, provider_style_overrides=provider_style_overrides, session_preset=session_preset, @@ -564,9 +576,9 @@ def _begin_agent_recorder( if not store_user_message or self.store is None: return None - current_identity = self._resolve_identities(scope_key.removeprefix("private:")).resolve_user( - user_id, sender_name - ) + current_identity = self._resolve_identities( + scope_key.removeprefix("private:") + ).resolve_user(user_id, sender_name) raw_turn = build_raw_turn_text( stored_prompt, quoted_text=normalized_quoted_text, @@ -611,7 +623,11 @@ def _begin_agent_recorder( reply_max_chunks_per_loop=runtime.reply_max_chunks_per_loop, ), sink=delivery_sink or self._delivery_sink, - sensitive_scan=_get_sensitive_filter().scan if _get_sensitive_filter().is_loaded else None, + sensitive_scan=( + _get_sensitive_filter().scan + if _get_sensitive_filter().is_loaded + else None + ), ) async def _run_tool_call_loop( @@ -637,10 +653,14 @@ async def _run_tool_call_loop( search_failsafe_max_rounds=SEARCH_TOOL_FAILSAFE_MAX_ROUNDS, search_failsafe_max_calls_per_round=SEARCH_TOOL_FAILSAFE_MAX_CALLS_PER_ROUND, search_max_calls_per_round=self.config.auto_search.search_max_calls_per_round, - tool_discovery_enabled=self._is_tool_discovery_enabled(context.chat_type, provider_id=provider.id), + tool_discovery_enabled=self._is_tool_discovery_enabled( + context.chat_type, provider_id=provider.id + ), tool_search_name=TOOL_SEARCH_NAME, tool_list_name=TOOL_LIST_NAME, - enabled_tool_names=self._get_enabled_tool_names(chat_type=context.chat_type, provider_id=provider.id), + enabled_tool_names=self._get_enabled_tool_names( + chat_type=context.chat_type, provider_id=provider.id + ), initial_tool_names=[spec.name for spec in request.tools], tool_discovery_search_limit=self.config.tools.discovery_search_limit, tool_discovery_max_loaded_tools=self.config.tools.discovery_max_loaded_tools, @@ -664,7 +684,12 @@ def _load_scrubbed_history_and_participants( epoch_key: EpochKey, epoch_params: EpochParams, provider: ProviderConfig | None = None, - ) -> tuple[list[dict[str, object]], list[dict[str, str]], list[dict[str, str]] | None, dict[str, list[LLMConversationMessage]]]: + ) -> tuple[ + list[dict[str, object]], + list[dict[str, str]], + list[dict[str, str]] | None, + dict[str, list[LLMConversationMessage]], + ]: # 会话纪元读取:只追加锚点窗口(懒初始化/冷场/触顶/行数兜底的推进判定 # 全部在 EpochManager 内),纪元内前缀逐字节稳定。auto_memory 仍走 # list_recent_conversation_messages 的 DESC LIMIT 尾读——两个消费者 @@ -709,7 +734,11 @@ def _load_scrubbed_history_and_participants( } if message_id: exclude_ids.add(str(message_id)) - if recent_messages is None and chat_type == "group" and self.recent_message_buffer is not None: + if ( + recent_messages is None + and chat_type == "group" + and self.recent_message_buffer is not None + ): recent_messages = self.recent_message_buffer.list_patch( scope_key, exclude_message_ids=exclude_ids, @@ -930,7 +959,12 @@ async def _generate_reply_for_scope(self, request: ChatTurnRequest) -> ReplyResu model=settings.model or provider.default_model, ) epoch_params = self.config.resolve_epoch_params(provider) - history, participants, scene_patch, projected_segments = self._load_scrubbed_history_and_participants( + ( + history, + participants, + scene_patch, + projected_segments, + ) = self._load_scrubbed_history_and_participants( chat_id=request.chat_id, chat_type=request.chat_type, scope_key=scope_key, diff --git a/src/quickquip/llm/service_parts/agent_runtime.py b/src/quickquip/llm/service_parts/agent_runtime.py index 18ee4018..558dc123 100644 --- a/src/quickquip/llm/service_parts/agent_runtime.py +++ b/src/quickquip/llm/service_parts/agent_runtime.py @@ -327,7 +327,9 @@ async def deliver_turn(self) -> None: if receipt.status == DeliveryStatus.SENT and receipt.message_id: self._store.set_first_chunk_message_id(record.message_row_id, receipt.message_id) self._store.finish_delivery(attempt, receipt) - self._delivery_stats[str(receipt.status)] = self._delivery_stats.get(str(receipt.status), 0) + 1 + self._delivery_stats[str(receipt.status)] = ( + self._delivery_stats.get(str(receipt.status), 0) + 1 + ) self._delivery_count += 1 if receipt.status in (DeliveryStatus.FAILED, DeliveryStatus.UNKNOWN): # D3:终止当前 Loop 后续生成、工具启动和交付。 diff --git a/src/quickquip/llm/service_parts/auto_memory.py b/src/quickquip/llm/service_parts/auto_memory.py index 12ae4074..3a2d128c 100644 --- a/src/quickquip/llm/service_parts/auto_memory.py +++ b/src/quickquip/llm/service_parts/auto_memory.py @@ -165,7 +165,11 @@ async def _extract_auto_memory( name = msg.get("canonical_name") or msg.get("sender_name", "?") name = snapshot.name(msg.get("user_id"), name) source_text = str(msg.get("raw_content") or msg.get("content", "")) - content = (source_text if source_text == user_text else render(legacy(source_text), snapshot)).strip() + content = ( + source_text + if source_text == user_text + else render(legacy(source_text), snapshot) + ).strip() if not content: continue tag = {"user": "群友", "assistant": "bot"}.get(role, role) diff --git a/src/quickquip/llm/service_parts/draw_svg.py b/src/quickquip/llm/service_parts/draw_svg.py index b9b30962..d5ffaf1d 100644 --- a/src/quickquip/llm/service_parts/draw_svg.py +++ b/src/quickquip/llm/service_parts/draw_svg.py @@ -87,25 +87,36 @@ async def _tool_draw_svg( return LLMToolOutput(content="缺少 svg 参数(需要完整 SVG 源码)", is_error=True) if len(context.outbound_images) >= MAX_OUTBOUND_TOOL_IMAGES: return LLMToolOutput( - content=f"本次回复图片已达上限({MAX_OUTBOUND_TOOL_IMAGES} 张),不要再生成更多图片", + content=( + f"本次回复图片已达上限({MAX_OUTBOUND_TOOL_IMAGES} 张)," + f"不要再生成更多图片" + ), is_error=True, ) svg_config = generation_service.get_config().svg if not svg_config.enabled: - return LLMToolOutput(content="SVG 画图功能未启用(generation.toml [svg] enabled)", is_error=True) + return LLMToolOutput( + content="SVG 画图功能未启用(generation.toml [svg] enabled)", + is_error=True, + ) visible_text = extract_visible_text(svg) sensitive = _get_sensitive_filter() if sensitive.is_loaded: scan = sensitive.scan("\n".join(part for part in (visible_text, caption) if part)) if scan.blocked: - return LLMToolOutput(content="图片文本包含不允许的内容,请修改后重试", is_error=True) + return LLMToolOutput( + content="图片文本包含不允许的内容,请修改后重试", is_error=True + ) if svg_config.content_judge: safe, reason = await self._judge_svg_content(visible_text, caption) if not safe: detail = f":{reason}" if reason else "" - return LLMToolOutput(content=f"图片文本内容安全校验未通过{detail},请修改后重试", is_error=True) + return LLMToolOutput( + content=f"图片文本内容安全校验未通过{detail},请修改后重试", + is_error=True, + ) # 限流放在内容检查之后:被拦截的尝试不占渲染配额,避免低成本耗尽全局配额 if not svg_render_allowed(context.user_id, context.group_id): diff --git a/src/quickquip/llm/service_parts/health.py b/src/quickquip/llm/service_parts/health.py index 57fe7a6b..cd5b35fc 100644 --- a/src/quickquip/llm/service_parts/health.py +++ b/src/quickquip/llm/service_parts/health.py @@ -158,7 +158,10 @@ def format_status(self, group_id: int | str, chat_type: str = "group") -> str: lines.append(f"Provider:{settings.provider_id}") lines.append(f"Model:{settings.model}") lines.append(f"Persona:{settings.persona_id}") - lines.append(f"前缀触发:{'ON' if settings.allow_prefix else 'OFF'} ({settings.trigger_prefix})") + lines.append( + f"前缀触发:{'ON' if settings.allow_prefix else 'OFF'} " + f"({settings.trigger_prefix})" + ) if chat_type == "private": lines.append(f"会话状态:{'进行中' if settings.enabled else '未开启'}") lines.append("直聊触发:仅在会话开启后生效") @@ -187,13 +190,17 @@ def format_current(self, group_id: int | str, chat_type: str = "group") -> str: lines.append(f"记忆注入:{'ON' if settings.memory_enabled else 'OFF'}") lines.append(f"工具调用:{'ON' if self.config.runtime.tool_calling_enabled else 'OFF'}") lines.append(f"MCP:{self._summarize_mcp_status()}") - lines.append( - f"工具列表:{', '.join(self._get_enabled_tool_names(chat_type=chat_type, provider_id=settings.provider_id)) or '无'}" + enabled_tool_names = self._get_enabled_tool_names( + chat_type=chat_type, provider_id=settings.provider_id ) + lines.append(f"工具列表:{', '.join(enabled_tool_names) or '无'}") lines.append(f"Provider:{settings.provider_id}") lines.append(f"Model:{settings.model}") lines.append(f"Persona:{settings.persona_id}") - lines.append(f"前缀触发:{'ON' if settings.allow_prefix else 'OFF'} ({settings.trigger_prefix})") + lines.append( + f"前缀触发:{'ON' if settings.allow_prefix else 'OFF'} " + f"({settings.trigger_prefix})" + ) if chat_type == "private": lines.append(f"会话状态:{'进行中' if settings.enabled else '未开启'}") lines.append("直聊触发:仅在会话开启后生效") @@ -204,7 +211,8 @@ def format_current(self, group_id: int | str, chat_type: str = "group") -> str: f"短期会话:已存 {self.store.count_conversation_messages(scope_key)} 条 / {window_note}" ) lines.append( - f"长期记忆:已存 {self.store.count_memories(scope_key)} 条 / 上限 {MAX_STORED_MEMORY_ITEMS} 条" + f"长期记忆:已存 {self.store.count_memories(scope_key)} 条 " + f"/ 上限 {MAX_STORED_MEMORY_ITEMS} 条" ) if chat_type == "private": lines.append("临时上下文:私聊不额外注入群消息") @@ -225,7 +233,9 @@ async def build_health_report( db_path=self.store.path, vocab_path=self.vocab_path, identity_path=self.identity_path, - tool_names=self._get_enabled_tool_names(chat_type=chat_type, provider_id=settings.provider_id), + tool_names=self._get_enabled_tool_names( + chat_type=chat_type, provider_id=settings.provider_id + ), mcp_status_summary=self._summarize_mcp_status(), mcp_enabled=self.config.mcp.enabled, mcp_tool_count=(self._get_shared_mcp_health() or ("", len(self.mcp_tool_names)))[1], @@ -261,7 +271,9 @@ async def format_provider_probe(self) -> str: results = await probe_all_providers(self.config) return format_probe_results(results) - async def format_current_provider_probe(self, group_id: int | str, chat_type: str = "group") -> str: + async def format_current_provider_probe( + self, group_id: int | str, chat_type: str = "group" + ) -> str: """探活当前会话实际生效的 provider/model(/llm reload 后验证用)。""" settings = self.get_chat_settings(group_id, chat_type=chat_type) provider = self.config.providers.get(settings.provider_id) diff --git a/src/quickquip/llm/service_parts/schedule_messages_tool.py b/src/quickquip/llm/service_parts/schedule_messages_tool.py index 75f9c77b..525c8a5c 100644 --- a/src/quickquip/llm/service_parts/schedule_messages_tool.py +++ b/src/quickquip/llm/service_parts/schedule_messages_tool.py @@ -49,20 +49,32 @@ }, "message": { "type": "string", - "description": "定时发送的内容(text 类为固定文案,llm 类为任务指令),action=create 时必填", + "description": ( + "定时发送的内容(text 类为固定文案,llm 类为任务指令)," + "action=create 时必填" + ), }, "kind": { "type": "string", "enum": ["text", "llm"], - "description": "任务类型:text 固定文案(默认)/ llm 任务指令,action=create 时可选", + "description": ( + "任务类型:text 固定文案(默认)/ llm 任务指令," + "action=create 时可选" + ), }, "recurring": { "type": "boolean", - "description": "是否周期重复(默认 true);false 为一次性任务,触发后自动删除,action=create 时可选", + "description": ( + "是否周期重复(默认 true);false 为一次性任务,触发后自动删除," + "action=create 时可选" + ), }, "enabled": { "type": "boolean", - "description": "action=create 时的初始启用状态(默认 true);action=set_enabled 时的目标状态", + "description": ( + "action=create 时的初始启用状态(默认 true);" + "action=set_enabled 时的目标状态" + ), }, "job_id": { "type": "string", diff --git a/src/quickquip/llm/service_parts/single_shot.py b/src/quickquip/llm/service_parts/single_shot.py index 3b5f644e..b8141de4 100644 --- a/src/quickquip/llm/service_parts/single_shot.py +++ b/src/quickquip/llm/service_parts/single_shot.py @@ -53,7 +53,10 @@ def _turmfluch_reply_text(raw_text: str) -> str | None: _DEFECTIFY_SPEC = CommandSingleShotSpec( rate_limit_key=DEFECTIFY_RATE_LIMIT_KEY, rule_name=DEFECTIFY_RULE_NAME, - usage_reply="用法:/defectify <文字>,也可以在命令里附图,或引用一条消息/图片后直接发送 /defectify。", + usage_reply=( + "用法:/defectify <文字>,也可以在命令里附图," + "或引用一条消息/图片后直接发送 /defectify。" + ), invalid_reply="模型没有返回可显示的文本。", temperature=0.9, input_channel="defectify_input", @@ -65,7 +68,10 @@ def _turmfluch_reply_text(raw_text: str) -> str | None: _TURMFLUCH_SPEC = CommandSingleShotSpec( rate_limit_key=TURMFLUCH_RATE_LIMIT_KEY, rule_name=TURMFLUCH_RULE_NAME, - usage_reply="用法:/turmfluch <文字>,也可以在命令里附图,或引用一条消息/图片后直接发送 /turmfluch。", + usage_reply=( + "用法:/turmfluch <文字>,也可以在命令里附图," + "或引用一条消息/图片后直接发送 /turmfluch。" + ), invalid_reply="模型没有返回合法的卡牌/遗物名。", temperature=0.7, input_channel="turmfluch_input", diff --git a/src/quickquip/llm/service_parts/state.py b/src/quickquip/llm/service_parts/state.py index dd008c0e..1a2003ee 100644 --- a/src/quickquip/llm/service_parts/state.py +++ b/src/quickquip/llm/service_parts/state.py @@ -4,7 +4,10 @@ from quickquip.llm.config import ProviderConfig from quickquip.llm.epoch import EpochKey -from quickquip.llm.service_parts.constants import MAX_MEMORY_RETRIEVAL_ITEMS, MAX_STORED_MEMORY_ITEMS +from quickquip.llm.service_parts.constants import ( + MAX_MEMORY_RETRIEVAL_ITEMS, + MAX_STORED_MEMORY_ITEMS, +) from quickquip.llm.settings import DeliveryDomain from quickquip.llm.store_parts.agent_records import HistoryMutation @@ -119,7 +122,9 @@ def get_session_preset(self, scope_key: str) -> str: def set_group_enabled(self, group_id: int | str, enabled: bool) -> None: self.set_chat_enabled(group_id, enabled, chat_type="group") - def set_chat_memory_enabled(self, chat_id: int | str, enabled: bool, chat_type: str = "group") -> None: + def set_chat_memory_enabled( + self, chat_id: int | str, enabled: bool, chat_type: str = "group" + ) -> None: self._update_chat_settings(chat_id, chat_type, memory_enabled=int(enabled)) def set_group_memory_enabled(self, group_id: int | str, enabled: bool) -> None: @@ -143,9 +148,13 @@ def set_chat_agent_delivery_enabled( value = None if enabled is None else int(enabled) match domain: case DeliveryDomain.INTERMEDIATE: - self._update_chat_settings(chat_id, chat_type, agent_delivery_intermediate_enabled=value) + self._update_chat_settings( + chat_id, chat_type, agent_delivery_intermediate_enabled=value + ) case DeliveryDomain.FINAL: - self._update_chat_settings(chat_id, chat_type, agent_delivery_final_enabled=value) + self._update_chat_settings( + chat_id, chat_type, agent_delivery_final_enabled=value + ) case DeliveryDomain.ALL: self._update_chat_settings( chat_id, chat_type, @@ -153,7 +162,9 @@ def set_chat_agent_delivery_enabled( agent_delivery_final_enabled=value, ) - def set_chat_history_limit(self, chat_id: int | str, limit: int, chat_type: str = "group") -> None: + def set_chat_history_limit( + self, chat_id: int | str, limit: int, chat_type: str = "group" + ) -> None: self._update_chat_settings(chat_id, chat_type, history_limit=limit) def set_group_history_limit(self, group_id: int | str, limit: int) -> None: @@ -165,7 +176,9 @@ def reset_chat_history_limit(self, chat_id: int | str, chat_type: str = "group") def reset_group_history_limit(self, group_id: int | str) -> None: self.reset_chat_history_limit(group_id, chat_type="group") - def set_chat_model(self, chat_id: int | str, provider_id: str, model: str = "", chat_type: str = "group") -> str: + def set_chat_model( + self, chat_id: int | str, provider_id: str, model: str = "", chat_type: str = "group" + ) -> str: provider = self.config.providers.get(provider_id) if provider is None: raise ValueError(f"未知 provider:{provider_id}") @@ -184,7 +197,9 @@ def set_chat_model(self, chat_id: int | str, provider_id: str, model: str = "", def set_group_model(self, group_id: int | str, provider_id: str, model: str) -> str: return self.set_chat_model(group_id, provider_id, model, chat_type="group") - def set_chat_persona(self, chat_id: int | str, persona_id: str, chat_type: str = "group") -> None: + def set_chat_persona( + self, chat_id: int | str, persona_id: str, chat_type: str = "group" + ) -> None: if persona_id not in self.config.personas: raise ValueError(f"未知 persona:{persona_id}") # persona 切换 = system 字节变化 = 该纪元键缓存全灭 = 免费重置窗口, @@ -208,7 +223,9 @@ def set_chat_persona(self, chat_id: int | str, persona_id: str, chat_type: str = def set_group_persona(self, group_id: int | str, persona_id: str) -> None: self.set_chat_persona(group_id, persona_id, chat_type="group") - def set_chat_trigger_prefix(self, chat_id: int | str, prefix: str, chat_type: str = "group") -> None: + def set_chat_trigger_prefix( + self, chat_id: int | str, prefix: str, chat_type: str = "group" + ) -> None: prefix = prefix.strip() if not prefix: raise ValueError("触发前缀不能为空") @@ -217,7 +234,9 @@ def set_chat_trigger_prefix(self, chat_id: int | str, prefix: str, chat_type: st def set_group_trigger_prefix(self, group_id: int | str, prefix: str) -> None: self.set_chat_trigger_prefix(group_id, prefix, chat_type="group") - def set_chat_allow_prefix(self, chat_id: int | str, enabled: bool, chat_type: str = "group") -> None: + def set_chat_allow_prefix( + self, chat_id: int | str, enabled: bool, chat_type: str = "group" + ) -> None: self._update_chat_settings(chat_id, chat_type, allow_prefix=int(enabled)) def set_group_allow_prefix(self, group_id: int | str, enabled: bool) -> None: @@ -226,9 +245,22 @@ def set_group_allow_prefix(self, group_id: int | str, enabled: bool) -> None: def set_group_allow_at(self, group_id: int | str, enabled: bool) -> None: self._update_chat_settings(group_id, "group", allow_at=int(enabled)) - def remember_memory(self, chat_id: int | str, content: str, chat_type: str = "group", *, content_parts: dict | None = None) -> int: + def remember_memory( + self, + chat_id: int | str, + content: str, + chat_type: str = "group", + *, + content_parts: dict | None = None, + ) -> int: scope_key = self.build_chat_scope_key(chat_id, chat_type) - memory_id = self.store.add_memory(scope_key, content.strip(), scope="group", source="manual", content_parts=content_parts) + memory_id = self.store.add_memory( + scope_key, + content.strip(), + scope="group", + source="manual", + content_parts=content_parts, + ) self.store.prune_memories( scope_key, min(self.config.runtime.memory_max_items_per_group, MAX_STORED_MEMORY_ITEMS), @@ -238,14 +270,22 @@ def remember_memory(self, chat_id: int | str, content: str, chat_type: str = "gr def remember_group_memory(self, group_id: int | str, content: str) -> int: return self.remember_memory(group_id, content, chat_type="group") - def list_memories(self, chat_id: int | str, keyword: str | None = None, chat_type: str = "group") -> list[dict[str, object]]: - return self.store.list_memories(self.build_chat_scope_key(chat_id, chat_type), limit=10, keyword=keyword) + def list_memories( + self, chat_id: int | str, keyword: str | None = None, chat_type: str = "group" + ) -> list[dict[str, object]]: + return self.store.list_memories( + self.build_chat_scope_key(chat_id, chat_type), limit=10, keyword=keyword + ) - def list_group_memories(self, group_id: int | str, keyword: str | None = None) -> list[dict[str, object]]: + def list_group_memories( + self, group_id: int | str, keyword: str | None = None + ) -> list[dict[str, object]]: return self.list_memories(group_id, keyword=keyword, chat_type="group") def forget_memories(self, chat_id: int | str, keyword: str, chat_type: str = "group") -> int: - return self.store.delete_memories(self.build_chat_scope_key(chat_id, chat_type), keyword.strip()) + return self.store.delete_memories( + self.build_chat_scope_key(chat_id, chat_type), keyword.strip() + ) def forget_group_memories(self, group_id: int | str, keyword: str) -> int: return self.forget_memories(group_id, keyword, chat_type="group") @@ -267,7 +307,10 @@ def format_providers(self) -> str: if provider.fallback_urls: note_parts.append(f"{len(provider.fallback_urls)} 个备用") suffix = f"({', '.join(note_parts)})" if note_parts else "" - lines.append(f"- {provider.id} [{provider.protocol}] 默认:{provider.default_model}{suffix}") + lines.append( + f"- {provider.id} [{provider.protocol}] " + f"默认:{provider.default_model}{suffix}" + ) return "\n".join(lines) def format_models(self, provider_id: str | None = None) -> str: @@ -289,7 +332,11 @@ def _model_lines(provider: ProviderConfig) -> list[str]: provider = self.config.providers.get(provider_id) if provider is None: return f"未知 provider:{provider_id}" - header = f"{provider.id} 可用模型(已禁用):" if not provider.enabled else f"{provider.id} 可用模型:" + header = ( + f"{provider.id} 可用模型(已禁用):" + if not provider.enabled + else f"{provider.id} 可用模型:" + ) return "\n".join([header, *_model_lines(provider)]) lines = ["可用模型:"] @@ -306,7 +353,9 @@ def format_personas(self, chat_type: str = "group") -> str: lines.append(f"- {persona.id}:{persona.display_name}") return "\n".join(lines) - def format_memories(self, group_id: int | str, keyword: str | None = None, chat_type: str = "group") -> str: + def format_memories( + self, group_id: int | str, keyword: str | None = None, chat_type: str = "group" + ) -> str: memories = self.list_memories(group_id, keyword=keyword, chat_type=chat_type) if not memories: return f"{self._scope_subject(chat_type)}没有已保存记忆" @@ -362,9 +411,14 @@ def delete_message_from_context(self, scope_key: str, message_id: str) -> bool: for row in rows: if row["role"] == "user": # 群友撤回触发消息:按整 Loop 删除处理(§9.3)。 - deleted_any = self.store.delete_loop_by_anchor(scope_key, int(row["id"])) or deleted_any + deleted_any = ( + self.store.delete_loop_by_anchor(scope_key, int(row["id"])) or deleted_any + ) else: - deleted_any = self.store.delete_turn_by_message_row(scope_key, int(row["id"])) or deleted_any + deleted_any = ( + self.store.delete_turn_by_message_row(scope_key, int(row["id"])) + or deleted_any + ) if deleted_any: self._bump_scope_generation(scope_key, HistoryMutation.DELETE) buf_deleted = ( diff --git a/src/quickquip/llm/service_parts/tools.py b/src/quickquip/llm/service_parts/tools.py index 6ca1aa5e..d9367d0f 100644 --- a/src/quickquip/llm/service_parts/tools.py +++ b/src/quickquip/llm/service_parts/tools.py @@ -74,7 +74,8 @@ def _register_builtin_tools(self) -> None: self.tool_registry.register( LLMToolSpec( name=TOOL_LIST_NAME, - description="列出工具组、工具名称或工具摘要,也可按精确工具名加载少量工具作为 tool_search 的兜底。", + description="列出工具组、工具名称或工具摘要," + "也可按精确工具名加载少量工具作为 tool_search 的兜底。", input_schema={ "type": "object", "properties": { @@ -227,7 +228,9 @@ def _register_builtin_tools(self) -> None: self.tool_registry.register( LLMToolSpec( name="get_health_status", - description="执行一次轻量内部健康检查,覆盖 LLM 配置、当前 provider/model、资料库、数据库、工具、MCP、搜索和生成配置。仅在用户明确要求诊断或自检时调用。", + description="执行一次轻量内部健康检查,覆盖 LLM 配置、当前 provider/model、" + "资料库、数据库、工具、MCP、搜索和生成配置。" + "仅在用户明确要求诊断或自检时调用。", input_schema={ "type": "object", "properties": { @@ -260,7 +263,9 @@ def _builtin_search_active(self, provider_id: str | None) -> bool: provider = self.config.providers.get(provider_id) return provider is not None and provider_builtin_search_active(provider) - def _get_enabled_tool_names(self, chat_type: str = "group", *, provider_id: str | None = None) -> list[str]: + def _get_enabled_tool_names( + self, chat_type: str = "group", *, provider_id: str | None = None + ) -> list[str]: configured = self.config.tools.enabled if not configured: names = [*DEFAULT_ENABLED_TOOLS, *sorted(self.mcp_tool_names)] @@ -277,15 +282,25 @@ def _get_enabled_tool_names(self, chat_type: str = "group", *, provider_id: str names = [name for name in names if name != SEARCH_TOOL_NAME] return [name for name in names if self.tool_registry.has_tool(name)] - def _get_always_loaded_tool_names(self, chat_type: str = "group", *, provider_id: str | None = None) -> list[str]: + def _get_always_loaded_tool_names( + self, chat_type: str = "group", *, provider_id: str | None = None + ) -> list[str]: configured = self.config.tools.always_loaded or DEFAULT_ALWAYS_LOADED_TOOLS enabled = set(self._get_enabled_tool_names(chat_type=chat_type, provider_id=provider_id)) - names = [name for name in configured if name in enabled and self.tool_registry.has_tool(name)] - if self._is_tool_discovery_enabled(chat_type, provider_id=provider_id) and TOOL_SEARCH_NAME in enabled and TOOL_SEARCH_NAME not in names: + names = [ + name for name in configured if name in enabled and self.tool_registry.has_tool(name) + ] + if ( + self._is_tool_discovery_enabled(chat_type, provider_id=provider_id) + and TOOL_SEARCH_NAME in enabled + and TOOL_SEARCH_NAME not in names + ): names.insert(0, TOOL_SEARCH_NAME) return names - def _is_tool_discovery_enabled(self, chat_type: str = "group", *, provider_id: str | None = None) -> bool: + def _is_tool_discovery_enabled( + self, chat_type: str = "group", *, provider_id: str | None = None + ) -> bool: mode = self.config.tools.discovery_mode if mode == "off": return False @@ -306,17 +321,31 @@ def _is_tool_discovery_enabled(self, chat_type: str = "group", *, provider_id: s deferred_count = len(enabled_set - always_names) return deferred_count > self.config.tools.discovery_min_tools - def _get_enabled_tool_specs(self, chat_type: str = "group", *, provider_id: str | None = None) -> list[LLMToolSpec]: + def _get_enabled_tool_specs( + self, chat_type: str = "group", *, provider_id: str | None = None + ) -> list[LLMToolSpec]: if self._is_tool_discovery_enabled(chat_type, provider_id=provider_id): - return self.tool_registry.get_specs(self._get_always_loaded_tool_names(chat_type=chat_type, provider_id=provider_id)) - return self.tool_registry.list_specs(self._get_enabled_tool_names(chat_type=chat_type, provider_id=provider_id)) + return self.tool_registry.get_specs( + self._get_always_loaded_tool_names( + chat_type=chat_type, provider_id=provider_id + ) + ) + return self.tool_registry.list_specs( + self._get_enabled_tool_names(chat_type=chat_type, provider_id=provider_id) + ) - def _get_deferred_tool_categories(self, chat_type: str = "group", *, provider_id: str | None = None) -> list[str]: + def _get_deferred_tool_categories( + self, chat_type: str = "group", *, provider_id: str | None = None + ) -> list[str]: if not self._is_tool_discovery_enabled(chat_type, provider_id=provider_id): return [] - loaded = set(self._get_always_loaded_tool_names(chat_type=chat_type, provider_id=provider_id)) + loaded = set( + self._get_always_loaded_tool_names(chat_type=chat_type, provider_id=provider_id) + ) categories: list[str] = [] - for entry in self.tool_registry.list_manifest(self._get_enabled_tool_names(chat_type=chat_type, provider_id=provider_id)): + for entry in self.tool_registry.list_manifest( + self._get_enabled_tool_names(chat_type=chat_type, provider_id=provider_id) + ): if entry.name in loaded: continue category = entry.category or entry.source @@ -347,7 +376,10 @@ def rebuild_image_preprocessor(self) -> None: vis_provider_cfg = self.config.providers.get(img_cfg.provider_id) if vis_provider_cfg is None: - logger.warning("image_preprocessing.provider_id %r not found in providers", img_cfg.provider_id) + logger.warning( + "image_preprocessing.provider_id %r not found in providers", + img_cfg.provider_id, + ) self.image_preprocessor = None return @@ -378,7 +410,9 @@ async def reload_runtime(self, *, background: bool = False) -> LLMConfig: await self.ensure_mcp_ready(force=True) return self.config - async def _tool_get_identity(self, arguments: dict[str, object], context: ToolExecutionContext) -> str: + async def _tool_get_identity( + self, arguments: dict[str, object], context: ToolExecutionContext + ) -> str: query = str(arguments.get("query", "")).strip() matches = self._resolve_identities(str(context.group_id)).search(query, limit=5) if not matches: @@ -394,7 +428,9 @@ async def _tool_get_identity(self, arguments: dict[str, object], context: ToolEx lines.append(f" 备注:{entry.note}") return "\n".join(lines) - async def _tool_search_tools(self, arguments: dict[str, object], context: ToolExecutionContext) -> str: + async def _tool_search_tools( + self, arguments: dict[str, object], context: ToolExecutionContext + ) -> str: query = str(arguments.get("query", "")).strip() category = str(arguments.get("category", "")).strip() raw_limit = arguments.get("limit", self.config.tools.discovery_search_limit) @@ -403,8 +439,14 @@ async def _tool_search_tools(self, arguments: dict[str, object], context: ToolEx except (TypeError, ValueError): limit = self.config.tools.discovery_search_limit limit = max(1, min(limit, self.config.tools.discovery_search_limit)) - current_enabled = self._get_enabled_tool_names(chat_type=context.chat_type, provider_id=context.provider_id) - loaded_names = set(self._get_always_loaded_tool_names(chat_type=context.chat_type, provider_id=context.provider_id)) + current_enabled = self._get_enabled_tool_names( + chat_type=context.chat_type, provider_id=context.provider_id + ) + loaded_names = set( + self._get_always_loaded_tool_names( + chat_type=context.chat_type, provider_id=context.provider_id + ) + ) matches = self.tool_registry.search_manifest( query, enabled_names=current_enabled, @@ -425,7 +467,9 @@ async def _tool_search_tools(self, arguments: dict[str, object], context: ToolEx lines.append("如需使用其中某个工具,请在下一轮直接调用对应工具名。") return "\n".join(lines) - async def _tool_list_tools(self, arguments: dict[str, object], context: ToolExecutionContext) -> str: + async def _tool_list_tools( + self, arguments: dict[str, object], context: ToolExecutionContext + ) -> str: mode = str(arguments.get("mode", "")).strip().lower() group = str(arguments.get("group", "")).strip() try: @@ -438,7 +482,9 @@ async def _tool_list_tools(self, arguments: dict[str, object], context: ToolExec limit = 20 page = max(1, page) limit = max(1, min(limit, 50)) - enabled_names = self._get_enabled_tool_names(chat_type=context.chat_type, provider_id=context.provider_id) + enabled_names = self._get_enabled_tool_names( + chat_type=context.chat_type, provider_id=context.provider_id + ) if mode == "groups": groups = self.tool_registry.list_groups(enabled_names) @@ -468,7 +514,11 @@ async def _tool_list_tools(self, arguments: dict[str, object], context: ToolExec lines = [f"{header_mode}{group_part}:第 {page} 页,{start}-{end}/{total}"] for item in entries: if mode in {"summaries", "group"}: - args = f";参数:{', '.join(item.argument_names)}" if item.argument_names else "" + args = ( + f";参数:{', '.join(item.argument_names)}" + if item.argument_names + else "" + ) category = f";组:{item.category}" if item.category else "" lines.append(f"- {item.name}{category}{args}:{item.description}") else: @@ -502,10 +552,15 @@ async def _tool_list_tools(self, arguments: dict[str, object], context: ToolExec return "未知 mode。可用 mode:groups、names、summaries、group、load。" - async def _tool_list_memories(self, arguments: dict[str, object], context: ToolExecutionContext) -> str: + async def _tool_list_memories( + self, arguments: dict[str, object], context: ToolExecutionContext + ) -> str: keyword = str(arguments.get("keyword", "")).strip() or None items = self.store.search_memories( - self._context_scope_key(context), user_id=context.user_id, query=keyword or "", limit=10, + self._context_scope_key(context), + user_id=context.user_id, + query=keyword or "", + limit=10, ) if not items: if keyword: @@ -517,7 +572,9 @@ async def _tool_list_memories(self, arguments: dict[str, object], context: ToolE lines.append(f"- #{item['id']} {display(item)}") return "\n".join(lines) - async def _tool_search_web(self, arguments: dict[str, object], context: ToolExecutionContext) -> str: + async def _tool_search_web( + self, arguments: dict[str, object], context: ToolExecutionContext + ) -> str: _ = context query = str(arguments.get("query", "")).strip() topic = str(arguments.get("topic", "general")).strip() or "general" @@ -542,13 +599,17 @@ async def _tool_get_group_stats( lines = ["当前群统计:", f"- 消息总数:{stats.total_messages}"] if stats.user_messages: - top_users = sorted(stats.user_messages.items(), key=lambda item: (-item[1], item[0]))[:top_n] + top_users = sorted( + stats.user_messages.items(), key=lambda item: (-item[1], item[0]) + )[:top_n] lines.append(f"- 活跃用户 Top {len(top_users)}:") for rank, (user_id, count) in enumerate(top_users, 1): display_name = stats.user_names.get(user_id, user_id) lines.append(f" {rank}. {display_name}(QQ {user_id})— {count} 条") if stats.rule_triggers: - top_rules = sorted(stats.rule_triggers.items(), key=lambda item: (-item[1], item[0]))[:top_n] + top_rules = sorted( + stats.rule_triggers.items(), key=lambda item: (-item[1], item[0]) + )[:top_n] lines.append(f"- 规则触发 Top {len(top_rules)}:") for rank, (rule_name, count) in enumerate(top_rules, 1): lines.append(f" {rank}. {rule_name} — {count} 次") @@ -669,7 +730,10 @@ async def _tool_get_current_model( lines.append(f"- Provider:{settings.provider_id}") lines.append(f"- Model:{settings.model}") lines.append(f"- Persona:{settings.persona_id}") - lines.append(f"- 前缀触发:{'ON' if settings.allow_prefix else 'OFF'} ({settings.trigger_prefix})") + lines.append( + f"- 前缀触发:{'ON' if settings.allow_prefix else 'OFF'} " + f"({settings.trigger_prefix})" + ) if context.chat_type == "private": lines.append("- 艾特触发:OFF(私聊不适用)") else: @@ -683,4 +747,6 @@ async def _tool_get_health_status( context: ToolExecutionContext, ) -> str: verbose = bool(arguments.get("verbose", False)) - return await self.format_health(context.group_id, chat_type=context.chat_type, verbose=verbose) + return await self.format_health( + context.group_id, chat_type=context.chat_type, verbose=verbose + ) diff --git a/src/quickquip/llm/single_shot.py b/src/quickquip/llm/single_shot.py index 7a2a779a..75c3cbc3 100644 --- a/src/quickquip/llm/single_shot.py +++ b/src/quickquip/llm/single_shot.py @@ -204,7 +204,9 @@ async def run_command_single_shot( try: with usage_scope(spec.usage_scope_name, group_id=str(chat_id)): - response = await client_builder(replace(provider, stream_enabled=False)).complete(request) + response = await client_builder( + replace(provider, stream_enabled=False) + ).complete(request) except LLMProviderError as exc: if spec.log_label is not None: logger.warning("%s LLM call failed: %s", spec.log_label, exc) @@ -273,7 +275,9 @@ async def run_card_le_nearest( ) try: with usage_scope("card_le_nearest", group_id=str(chat_id)): - response = await client_builder(replace(provider, stream_enabled=False)).complete(request) + response = await client_builder( + replace(provider, stream_enabled=False) + ).complete(request) except Exception: logger.exception("STS card_le nearest LLM call failed for %r", captured) return None diff --git a/src/quickquip/llm/store_parts/agent_records.py b/src/quickquip/llm/store_parts/agent_records.py index 6a3c8db7..06862173 100644 --- a/src/quickquip/llm/store_parts/agent_records.py +++ b/src/quickquip/llm/store_parts/agent_records.py @@ -439,7 +439,8 @@ def _ensure_agent_schema(self) -> None: self._add_agent_conversation_columns(conn) self._backfill_legacy_loops(conn) conn.execute( - "INSERT OR REPLACE INTO agent_schema_migrations (version, applied_at) VALUES (?, ?)", + "INSERT OR REPLACE INTO agent_schema_migrations (version, applied_at) " + "VALUES (?, ?)", (_AGENT_SCHEMA_VERSION, _utc_now()), ) self._verify_agent_schema(conn) @@ -558,7 +559,9 @@ def _backfill_one_legacy_group( parts = _dumps( { "version": AGENT_RECORD_VERSION, - "parts": [{"type": "text_ref", "start": 0, "end": len(text), "origin": "model"}], + "parts": [ + {"type": "text_ref", "start": 0, "end": len(text), "origin": "model"} + ], } ) conn.execute( @@ -575,7 +578,8 @@ def _backfill_one_legacy_group( ), ) conn.execute( - "UPDATE conversation_messages SET agent_loop_id = ?, agent_turn_id = ? WHERE id = ?", + "UPDATE conversation_messages SET agent_loop_id = ?, " + "agent_turn_id = ? WHERE id = ?", (loop_id, turn_id, int(row["id"])), ) qq_id = row["message_id"] @@ -596,11 +600,13 @@ def _backfill_one_legacy_group( delivery_index += 1 if qq_id: conn.execute( - """ - INSERT INTO agent_delivery_attempts (attempt_id, delivery_id, attempt_index, - status, started_at, finished_at, qq_message_id) - VALUES (?, ?, 0, ?, ?, ?, ?) - """, + "\n" + " INSERT INTO agent_delivery_attempts " + "(attempt_id, delivery_id, attempt_index,\n" + " " + "status, started_at, finished_at, qq_message_id)\n" + " VALUES (?, ?, 0, ?, ?, ?, ?)\n" + " ", ( f"legacy_attempt_{int(row['id'])}", delivery_id, DeliveryStatus.SENT, row["created_at"], row["created_at"], str(qq_id), @@ -612,7 +618,9 @@ def _verify_agent_schema(conn: sqlite3.Connection) -> None: """迁移后完整性检查(§4.3.7):FK、唯一约束抽查与侧表孤儿。""" violations = conn.execute("PRAGMA foreign_key_check").fetchall() if violations: - raise sqlite3.IntegrityError(f"agent schema 迁移后 foreign_key_check 失败:{violations[:3]}") + raise sqlite3.IntegrityError( + f"agent schema 迁移后 foreign_key_check 失败:{violations[:3]}" + ) orphans = conn.execute( """ SELECT COUNT(*) AS c FROM agent_turns t @@ -712,7 +720,8 @@ def begin_loop( if generation != expected_generation: conn.rollback() raise ScopeGenerationMismatch( - f"scope={scope_key} loop 创建被拒:generation {expected_generation} -> {generation}" + f"scope={scope_key} loop 创建被拒:" + f"generation {expected_generation} -> {generation}" ) open_loop = conn.execute( "SELECT loop_id FROM agent_loops WHERE scope_key = ? AND closed_at IS NULL", @@ -836,7 +845,8 @@ def commit_turn( native_json = self._bounded_native_json(response) index_row = conn.execute( - "SELECT COALESCE(MAX(turn_index), -1) + 1 AS next FROM agent_turns WHERE loop_id = ?", + "SELECT COALESCE(MAX(turn_index), -1) + 1 AS next " + "FROM agent_turns WHERE loop_id = ?", (handle.loop_id,), ).fetchone() turn_index = int(index_row["next"]) @@ -861,7 +871,9 @@ def commit_turn( ( turn_id, handle.loop_id, turn_index, message_row_id, parts_payload, native_json, - None if response.native_omission_reason is None else str(response.native_omission_reason), + None + if response.native_omission_reason is None + else str(response.native_omission_reason), _dumps(response.owner) if response.owner is not None else None, response.finish_reason, _utc_now(), delivery_policy, response.text_policy, response.output_status, @@ -926,9 +938,15 @@ def _validated_parts( for part in parts: kind = part.get("type") if kind == "text_ref": - start, end, origin = int(part["start"]), int(part["end"]), part.get("origin", "model") + start, end, origin = ( + int(part["start"]), + int(part["end"]), + part.get("origin", "model"), + ) if not (0 <= start <= end <= len(text)): - raise AgentStoreError(f"text_ref 范围 [{start},{end}) 超出已存正文长度 {len(text)}") + raise AgentStoreError( + f"text_ref 范围 [{start},{end}) 超出已存正文长度 {len(text)}" + ) if origin not in _TEXT_PART_ORIGINS: raise AgentStoreError(f"text_ref origin 非法:{origin}") validated.append({"type": "text_ref", "start": start, "end": end, "origin": origin}) @@ -969,7 +987,10 @@ def _insert_tool_declaration( ) -> None: arguments_json = declaration.arguments_json omission = declaration.arguments_omission_reason - if arguments_json is not None and _utf8_bytes(arguments_json) > MAX_PERSISTED_TOOL_ARGUMENT_BYTES: + if ( + arguments_json is not None + and _utf8_bytes(arguments_json) > MAX_PERSISTED_TOOL_ARGUMENT_BYTES + ): arguments_json = None omission = "size_limit" conn.execute( @@ -997,7 +1018,8 @@ def _insert_delivery_plan( turn_text: str, ) -> list[str]: index_row = conn.execute( - "SELECT COALESCE(MAX(delivery_index), -1) + 1 AS next FROM agent_deliveries WHERE loop_id = ?", + "SELECT COALESCE(MAX(delivery_index), -1) + 1 AS next " + "FROM agent_deliveries WHERE loop_id = ?", (handle.loop_id,), ).fetchone() delivery_index = int(index_row["next"]) @@ -1034,8 +1056,19 @@ def _insert_delivery_plan( turn_id if item.kind != DeliveryKind.HOST_NOTICE else item.turn_id, item.tool_execution_id, item.kind, delivery_index, item.chunk_index, item.source_start, item.source_end, - _dumps({"version": AGENT_RECORD_VERSION, "prefix": item.wrappers[0], "suffix": item.wrappers[1]}), - _dumps({"version": AGENT_RECORD_VERSION, "refs": [list(ref) for ref in item.attachment_refs]}), + _dumps( + { + "version": AGENT_RECORD_VERSION, + "prefix": item.wrappers[0], + "suffix": item.wrappers[1], + } + ), + _dumps( + { + "version": AGENT_RECORD_VERSION, + "refs": [list(ref) for ref in item.attachment_refs], + } + ), item.notice_text, DeliveryStatus.PLANNED, _utc_now(), ), ) @@ -1057,7 +1090,8 @@ def mark_tool_started(self, handle: LoopHandle, execution_id: str) -> None: if status != ToolExecutionStatus.DECLARED: raise LoopNotWritable(f"execution={execution_id} 状态 {status} 不允许开始执行") conn.execute( - "UPDATE agent_tool_executions SET status = ?, started_at = ? WHERE execution_id = ?", + "UPDATE agent_tool_executions SET status = ?, started_at = ? " + "WHERE execution_id = ?", (ToolExecutionStatus.RUNNING, _utc_now(), execution_id), ) conn.commit() @@ -1139,7 +1173,8 @@ def finish_tool( ) if closed: conn.execute( - "UPDATE agent_loops SET replay_revision = replay_revision + 1 WHERE loop_id = ?", + "UPDATE agent_loops SET replay_revision = replay_revision + 1 " + "WHERE loop_id = ?", (handle.loop_id,), ) else: @@ -1249,7 +1284,8 @@ def start_delivery(self, handle: LoopHandle, delivery_id: str) -> AttemptHandle: if row["status"] != DeliveryStatus.PLANNED: raise LoopNotWritable(f"delivery={delivery_id} 状态 {row['status']} 不允许开始发送") index_row = conn.execute( - "SELECT COALESCE(MAX(attempt_index), -1) + 1 AS next FROM agent_delivery_attempts WHERE delivery_id = ?", + "SELECT COALESCE(MAX(attempt_index), -1) + 1 AS next " + "FROM agent_delivery_attempts WHERE delivery_id = ?", (delivery_id,), ).fetchone() attempt_index = int(index_row["next"]) @@ -1288,7 +1324,8 @@ def finish_delivery(self, attempt: AttemptHandle, receipt: DeliveryReceipt) -> N try: conn.execute("BEGIN IMMEDIATE") attempt_row = conn.execute( - "SELECT status, finished_at, delivery_id FROM agent_delivery_attempts WHERE attempt_id = ?", + "SELECT status, finished_at, delivery_id " + "FROM agent_delivery_attempts WHERE attempt_id = ?", (attempt.attempt_id,), ).fetchone() if attempt_row is None: @@ -1340,7 +1377,8 @@ def finish_delivery(self, attempt: AttemptHandle, receipt: DeliveryReceipt) -> N ).fetchone() if loop_row is not None and loop_row["closed_at"] is not None: conn.execute( - "UPDATE agent_loops SET replay_revision = replay_revision + 1 WHERE loop_id = ?", + "UPDATE agent_loops SET replay_revision = replay_revision + 1 " + "WHERE loop_id = ?", (delivery_row["loop_id"],), ) conn.execute( @@ -1384,7 +1422,9 @@ def close_loop( """, ( ToolExecutionStatus.NOT_EXECUTED, - self._bounded_result_json(None, ResultRetention.BOUNDED, ToolSkipReason.RECOVERY), + self._bounded_result_json( + None, ResultRetention.BOUNDED, ToolSkipReason.RECOVERY + ), _utc_now(), ToolExecutionStatus.DECLARED, handle.loop_id, ), ) @@ -1395,7 +1435,12 @@ def close_loop( WHERE status = ? AND turn_id IN (SELECT turn_id FROM agent_turns WHERE loop_id = ?) """, - (ToolExecutionStatus.INDETERMINATE, _utc_now(), ToolExecutionStatus.RUNNING, handle.loop_id), + ( + ToolExecutionStatus.INDETERMINATE, + _utc_now(), + ToolExecutionStatus.RUNNING, + handle.loop_id, + ), ) conn.execute( """ @@ -1405,11 +1450,13 @@ def close_loop( (DeliveryStatus.SKIPPED, handle.loop_id, DeliveryStatus.PLANNED), ) conn.execute( - """ - UPDATE agent_delivery_attempts SET status = ?, finished_at = COALESCE(finished_at, ?) - WHERE status = ? - AND delivery_id IN (SELECT delivery_id FROM agent_deliveries WHERE loop_id = ?) - """, + "\n" + " UPDATE agent_delivery_attempts SET status = ?, " + "finished_at = COALESCE(finished_at, ?)\n" + " WHERE status = ?\n" + " AND delivery_id IN " + "(SELECT delivery_id FROM agent_deliveries WHERE loop_id = ?)\n" + " ", (DeliveryStatus.UNKNOWN, _utc_now(), DeliveryStatus.SENDING, handle.loop_id), ) conn.execute( @@ -1420,7 +1467,8 @@ def close_loop( (DeliveryStatus.UNKNOWN, handle.loop_id, DeliveryStatus.SENDING), ) conn.execute( - "UPDATE agent_loops SET closed_at = ?, status = ?, terminal_reason = ? WHERE loop_id = ?", + "UPDATE agent_loops SET closed_at = ?, status = ?, terminal_reason = ? " + "WHERE loop_id = ?", (_utc_now(), status, reason, handle.loop_id), ) conn.commit() @@ -1559,7 +1607,8 @@ def _load_one_delivery(conn: sqlite3.Connection, row: sqlite3.Row) -> LoadedDeli qq_ids = [ r["qq_message_id"] for r in conn.execute( - "SELECT qq_message_id FROM agent_delivery_attempts WHERE delivery_id = ? AND qq_message_id IS NOT NULL", + "SELECT qq_message_id FROM agent_delivery_attempts " + "WHERE delivery_id = ? AND qq_message_id IS NOT NULL", (row["delivery_id"],), ) ] @@ -1610,7 +1659,8 @@ def recover_unfinished_loops(self) -> RecoveryReport: deliveries_unknown: list[str] = [] with self._connect() as conn: loops = conn.execute( - "SELECT loop_id, scope_key, scope_generation, trigger_kind FROM agent_loops WHERE closed_at IS NULL" + "SELECT loop_id, scope_key, scope_generation, trigger_kind " + "FROM agent_loops WHERE closed_at IS NULL" ).fetchall() for loop in loops: handle = LoopHandle( @@ -1653,14 +1703,17 @@ def _recover_one_loop( ).fetchall() for row in declared: conn.execute( - """ - UPDATE agent_tool_executions - SET status = ?, result_json = COALESCE(result_json, ?), finished_at = COALESCE(finished_at, ?) - WHERE execution_id = ? - """, + "\n" + " UPDATE agent_tool_executions\n" + " SET status = ?, result_json = COALESCE(result_json, ?), " + "finished_at = COALESCE(finished_at, ?)\n" + " WHERE execution_id = ?\n" + " ", ( ToolExecutionStatus.NOT_EXECUTED, - self._bounded_result_json(None, ResultRetention.BOUNDED, ToolSkipReason.RECOVERY), + self._bounded_result_json( + None, ResultRetention.BOUNDED, ToolSkipReason.RECOVERY + ), _utc_now(), row["execution_id"], ), ) @@ -1675,7 +1728,9 @@ def _recover_one_loop( ).fetchall() for row in running: conn.execute( - "UPDATE agent_tool_executions SET status = ?, finished_at = COALESCE(finished_at, ?) WHERE execution_id = ?", + "UPDATE agent_tool_executions SET status = ?, " + "finished_at = COALESCE(finished_at, ?) " + "WHERE execution_id = ?", (ToolExecutionStatus.INDETERMINATE, _utc_now(), row["execution_id"]), ) indeterminate.append(row["execution_id"]) @@ -1780,7 +1835,8 @@ def suppress_delivery(self, handle: LoopHandle, delivery_id: str) -> None: raise RuntimeError("LLM存储 数据库不可用") with self._connect() as conn: conn.execute( - "UPDATE agent_deliveries SET status = ? WHERE delivery_id = ? AND loop_id = ? AND status = ?", + "UPDATE agent_deliveries SET status = ? " + "WHERE delivery_id = ? AND loop_id = ? AND status = ?", (DeliveryStatus.SUPPRESSED, delivery_id, handle.loop_id, DeliveryStatus.PLANNED), ) @@ -1790,7 +1846,8 @@ def set_first_chunk_message_id(self, message_row_id: int, qq_message_id: str) -> raise RuntimeError("LLM存储 数据库不可用") with self._connect() as conn: conn.execute( - "UPDATE conversation_messages SET message_id = ? WHERE id = ? AND message_id IS NULL", + "UPDATE conversation_messages SET message_id = ? " + "WHERE id = ? AND message_id IS NULL", (str(qq_message_id), int(message_row_id)), ) @@ -1945,7 +2002,10 @@ def recall_delivery_chunk(self, scope_key: str, delivery_id: str) -> bool: ).fetchone() if message is not None: text = message["content"] or "" - start, end = int(delivery["source_start"] or 0), int(delivery["source_end"] or 0) + start, end = ( + int(delivery["source_start"] or 0), + int(delivery["source_end"] or 0), + ) # 等 code point 数遮蔽:保留坐标供后续撤回其他 Chunk。 if 0 <= start <= end <= len(text): masked = text[:start] + "▇" * (end - start) + text[end:] @@ -2041,7 +2101,9 @@ def loops_with_tools(self, scope_key: str, loop_ids: Collection[str]) -> set[str ).fetchall() return {row["loop_id"] for row in rows} - def load_closed_loops_by_ids(self, scope_key: str, loop_ids: Collection[str]) -> list[LoadedLoop]: + def load_closed_loops_by_ids( + self, scope_key: str, loop_ids: Collection[str] + ) -> list[LoadedLoop]: """按 ID 读取完整已关闭 Loop(历史投影输入;顺序按 anchor ASC)。""" if self._unavailable: raise RuntimeError("LLM存储 数据库不可用") diff --git a/src/quickquip/llm/store_parts/conversation.py b/src/quickquip/llm/store_parts/conversation.py index 8404d25a..31627848 100644 --- a/src/quickquip/llm/store_parts/conversation.py +++ b/src/quickquip/llm/store_parts/conversation.py @@ -36,10 +36,11 @@ def append_conversation_message( raise RuntimeError("LLM存储 数据库不可用") with self._connect() as conn: conn.execute( - """ - INSERT INTO conversation_messages (group_id, user_id, sender_name, canonical_name, role, content, message_id, raw_content, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, + "\n" + " INSERT INTO conversation_messages (group_id, user_id, " + "sender_name, canonical_name, role, content, message_id, raw_content, created_at)\n" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n" + " ", ( str(group_id), None if user_id is None else str(user_id), @@ -89,13 +90,14 @@ def list_conversation_messages_since( raise RuntimeError("LLM存储 数据库不可用") with self._connect() as conn: rows = conn.execute( - """ - SELECT id, user_id, sender_name, canonical_name, role, content, message_id, raw_content, agent_loop_id - FROM conversation_messages - WHERE group_id = ? AND id >= ? - ORDER BY id ASC - LIMIT ? - """, + "\n" + " SELECT id, user_id, sender_name, canonical_name, role, content, " + "message_id, raw_content, agent_loop_id\n" + " FROM conversation_messages\n" + " WHERE group_id = ? AND id >= ?\n" + " ORDER BY id ASC\n" + " LIMIT ?\n" + " ", (str(group_id), int(anchor_id), int(limit)), ).fetchall() return [ @@ -204,7 +206,9 @@ def clear_conversation_messages(self, group_id: int | str) -> int: ) return int(cursor.rowcount) - def delete_conversation_message_by_message_id(self, group_id: int | str, message_id: str) -> int: + def delete_conversation_message_by_message_id( + self, group_id: int | str, message_id: str + ) -> int: if self._unavailable: raise RuntimeError("LLM存储 数据库不可用") with self._connect() as conn: @@ -217,7 +221,9 @@ def delete_conversation_message_by_message_id(self, group_id: int | str, message ) return int(cursor.rowcount) - def conversation_rows_by_message_id(self, group_id: int | str, message_id: str) -> list[dict[str, object]]: + def conversation_rows_by_message_id( + self, group_id: int | str, message_id: str + ) -> list[dict[str, object]]: """按平台 message_id 查询主表行(撤回回退路径;不删除)。""" if self._unavailable: raise RuntimeError("LLM存储 数据库不可用") diff --git a/src/quickquip/llm/store_parts/group_settings.py b/src/quickquip/llm/store_parts/group_settings.py index 68a15c39..63f377c7 100644 --- a/src/quickquip/llm/store_parts/group_settings.py +++ b/src/quickquip/llm/store_parts/group_settings.py @@ -13,11 +13,13 @@ def get_group_settings(self, group_id: int | str) -> GroupSettingsOverride: raise RuntimeError("LLM存储 数据库不可用") with self._connect() as conn: row = conn.execute( - """ - SELECT enabled, memory_enabled, auto_memory_enabled, agent_delivery_intermediate_enabled, agent_delivery_final_enabled, provider_id, model, persona_id, trigger_prefix, allow_prefix, allow_at, history_limit - FROM group_settings - WHERE group_id = ? - """, + "\n" + " SELECT enabled, memory_enabled, auto_memory_enabled, " + "agent_delivery_intermediate_enabled, agent_delivery_final_enabled, provider_id, " + "model, persona_id, trigger_prefix, allow_prefix, allow_at, history_limit\n" + " FROM group_settings\n" + " WHERE group_id = ?\n" + " ", (str(group_id),), ).fetchone() if row is None: @@ -25,9 +27,21 @@ def get_group_settings(self, group_id: int | str) -> GroupSettingsOverride: return GroupSettingsOverride( enabled=None if row["enabled"] is None else bool(row["enabled"]), memory_enabled=None if row["memory_enabled"] is None else bool(row["memory_enabled"]), - auto_memory_enabled=None if row["auto_memory_enabled"] is None else bool(row["auto_memory_enabled"]), - agent_delivery_intermediate_enabled=None if row["agent_delivery_intermediate_enabled"] is None else bool(row["agent_delivery_intermediate_enabled"]), - agent_delivery_final_enabled=None if row["agent_delivery_final_enabled"] is None else bool(row["agent_delivery_final_enabled"]), + auto_memory_enabled=( + None + if row["auto_memory_enabled"] is None + else bool(row["auto_memory_enabled"]) + ), + agent_delivery_intermediate_enabled=( + None + if row["agent_delivery_intermediate_enabled"] is None + else bool(row["agent_delivery_intermediate_enabled"]) + ), + agent_delivery_final_enabled=( + None + if row["agent_delivery_final_enabled"] is None + else bool(row["agent_delivery_final_enabled"]) + ), provider_id=row["provider_id"], model=row["model"], persona_id=row["persona_id"], diff --git a/src/quickquip/llm/store_parts/memory.py b/src/quickquip/llm/store_parts/memory.py index 7ad76d5d..dd1a3cc3 100644 --- a/src/quickquip/llm/store_parts/memory.py +++ b/src/quickquip/llm/store_parts/memory.py @@ -33,10 +33,11 @@ def add_memory( content = render(body) with self._connect() as conn: cursor = conn.execute( - """ - INSERT INTO memories (group_id, user_id, scope, content, tags_json, source, confidence, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, + "\n" + " INSERT INTO memories (group_id, user_id, scope, content, " + "tags_json, source, confidence, created_at, updated_at)\n" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n" + " ", ( str(group_id), None if user_id is None else str(user_id), @@ -90,9 +91,14 @@ def search_memories(self, group_id, *, user_id, query, limit, scope=None): tokens = _build_query_tokens(query) matchers = [RecordQuery(value, snapshot) for value in dict.fromkeys([query, *tokens])] with self._connect() as conn: - clause = "scope='user' AND user_id=?" if scope == "user" else "scope='group' OR (scope='user' AND user_id=?)" + clause = ( + "scope='user' AND user_id=?" + if scope == "user" + else "scope='group' OR (scope='user' AND user_id=?)" + ) rows = conn.execute( - f"SELECT * FROM memories WHERE group_id=? AND ({clause}) ORDER BY confidence DESC, id DESC", + f"SELECT * FROM memories WHERE group_id=? AND ({clause}) " + f"ORDER BY confidence DESC, id DESC", (str(group_id), None if user_id is None else str(user_id)), ) result = [] @@ -113,7 +119,10 @@ def delete_memories(self, group_id, keyword): raise ValueError(f"成员存在歧义:{choices}。请使用 QQ 或 #编号") with self._connect() as conn: if keyword.startswith("#") and keyword[1:].isdigit(): - return conn.execute("DELETE FROM memories WHERE group_id=? AND id=?", (str(group_id), int(keyword[1:]))).rowcount + return conn.execute( + "DELETE FROM memories WHERE group_id=? AND id=?", + (str(group_id), int(keyword[1:])), + ).rowcount matcher = RecordQuery(keyword, snapshot) rows = conn.execute("SELECT * FROM memories WHERE group_id=?", (str(group_id),)) ids = [row["id"] for row in rows if matcher.matches(dict(row), True)] diff --git a/src/quickquip/llm/store_parts/session_archive.py b/src/quickquip/llm/store_parts/session_archive.py index 9b01e956..ad66e1c7 100644 --- a/src/quickquip/llm/store_parts/session_archive.py +++ b/src/quickquip/llm/store_parts/session_archive.py @@ -37,10 +37,11 @@ def create_session_archive( raise RuntimeError("LLM存储 数据库不可用") with self._connect() as conn: cursor = conn.execute( - """ - INSERT INTO session_archives (user_id, archive_number, persona_id, preset, message_count, created_at, ended_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, + "\n" + " INSERT INTO session_archives (user_id, archive_number, " + "persona_id, preset, message_count, created_at, ended_at)\n" + " VALUES (?, ?, ?, ?, ?, ?, ?)\n" + " ", ( user_id, archive_number, @@ -82,11 +83,12 @@ def get_session_archive(self, user_id: str, archive_number: int) -> dict | None: raise RuntimeError("LLM存储 数据库不可用") with self._connect() as conn: row = conn.execute( - """ - SELECT id, user_id, archive_number, persona_id, preset, message_count, created_at, ended_at - FROM session_archives - WHERE user_id = ? AND archive_number = ? - """, + "\n" + " SELECT id, user_id, archive_number, persona_id, preset, " + "message_count, created_at, ended_at\n" + " FROM session_archives\n" + " WHERE user_id = ? AND archive_number = ?\n" + " ", (user_id, archive_number), ).fetchone() if row is None: @@ -98,13 +100,14 @@ def list_session_archives(self, user_id: str, *, limit: int = 20) -> list[dict]: raise RuntimeError("LLM存储 数据库不可用") with self._connect() as conn: rows = conn.execute( - """ - SELECT id, user_id, archive_number, persona_id, preset, message_count, created_at, ended_at - FROM session_archives - WHERE user_id = ? - ORDER BY archive_number DESC - LIMIT ? - """, + "\n" + " SELECT id, user_id, archive_number, persona_id, preset, " + "message_count, created_at, ended_at\n" + " FROM session_archives\n" + " WHERE user_id = ?\n" + " ORDER BY archive_number DESC\n" + " LIMIT ?\n" + " ", (user_id, limit), ).fetchall() return [{k: row[k] for k in row.keys()} for row in rows] diff --git a/src/quickquip/llm/summarize.py b/src/quickquip/llm/summarize.py index af1ad254..d3f4efa8 100644 --- a/src/quickquip/llm/summarize.py +++ b/src/quickquip/llm/summarize.py @@ -142,7 +142,9 @@ def _resolve_cascade( provider_id, model = parts provider_config = llm_config.providers.get(provider_id) if provider_config is None: - logger.warning("summary cascade: provider %r not found in config, skipping", provider_id) + logger.warning( + "summary cascade: provider %r not found in config, skipping", provider_id + ) continue if not provider_config.enabled: logger.info("summary cascade: provider %r disabled, skipping", provider_id) @@ -283,7 +285,9 @@ async def generate_daily_summary( Returns (summary_text, model_used_label). Raises RuntimeError if all models in the cascade fail. """ - set_usage_scope("summary", group_id=str(group_id), persona_id=persona.id, run_id=new_usage_run_id()) + set_usage_scope( + "summary", group_id=str(group_id), persona_id=persona.id, run_id=new_usage_run_id() + ) system_prompt = _build_system_prompt( persona, date_label, name_table, summary_config.summary_length_hint ) @@ -314,7 +318,8 @@ def build_user_content(chat_log: str, was_truncated: bool) -> str: "\n(注:由于消息量较大,上方记录已截取最近部分。)\n" if was_truncated else "" ) return ( - f"以下是{date_label}的群聊记录(共 {ser_stats.messages_in - ser_stats.messages_skipped} 条消息):\n" + f"以下是{date_label}的群聊记录" + f"(共 {ser_stats.messages_in - ser_stats.messages_skipped} 条消息):\n" f"{truncation_note}" "=== 聊天记录开始 ===\n" f"{chat_log}\n" @@ -394,7 +399,12 @@ async def generate_period_report( Returns (report_text, model_used_label). Raises RuntimeError if all models in the cascade fail. """ - set_usage_scope("period_report", group_id=str(group_id), persona_id=persona.id, run_id=new_usage_run_id()) + set_usage_scope( + "period_report", + group_id=str(group_id), + persona_id=persona.id, + run_id=new_usage_run_id(), + ) system_prompt = _build_period_system_prompt( persona, period_label, period_kind, name_table, length_hint ) @@ -448,6 +458,12 @@ def build_user_content(chat_log: str, was_truncated: bool) -> str: ) return await _run_summary_cascade( - f"period_report[{period_kind}]", group_id, resolved, system_prompt, raw_log, build_user_content, - temperature=_PERIOD_REPORT_TEMPERATURE, max_output_tokens=_PERIOD_REPORT_MAX_OUTPUT_TOKENS, + f"period_report[{period_kind}]", + group_id, + resolved, + system_prompt, + raw_log, + build_user_content, + temperature=_PERIOD_REPORT_TEMPERATURE, + max_output_tokens=_PERIOD_REPORT_MAX_OUTPUT_TOKENS, ) diff --git a/src/quickquip/llm/tool_loop.py b/src/quickquip/llm/tool_loop.py index 34c11885..74e7386f 100644 --- a/src/quickquip/llm/tool_loop.py +++ b/src/quickquip/llm/tool_loop.py @@ -37,7 +37,9 @@ async def run_tool_call_loop( client = build_provider_client(provider) max_rounds = max(0, min(runtime_config.tool_max_rounds, 16)) max_calls = max(1, min(runtime_config.tool_max_calls_per_round, 32)) - effective_search_max_calls = max(1, min(search_max_calls_per_round, search_failsafe_max_calls_per_round)) + effective_search_max_calls = max( + 1, min(search_max_calls_per_round, search_failsafe_max_calls_per_round) + ) current_request = request counted_rounds = 0 discovery = ToolDiscovery( @@ -89,7 +91,12 @@ async def run_tool_call_loop( ) if not response.tool_calls or not current_request.allow_tool_calls: if turn_recorder is not None: - turn_recorder.on_turn(response, declared_calls=[], executable_calls=[], has_more_rounds=False) + turn_recorder.on_turn( + response, + declared_calls=[], + executable_calls=[], + has_more_rounds=False, + ) await turn_recorder.deliver_turn() return response @@ -124,7 +131,8 @@ async def run_tool_call_loop( if provider.protocol == "gemini": if len(limited_calls) != len(response.tool_calls): logger.warning( - "Gemini tool batch rejected (fail-closed): provider=%s model=%s requested=%d kept=0", + "Gemini tool batch rejected (fail-closed): " + "provider=%s model=%s requested=%d kept=0", provider.id, response.model, len(response.tool_calls), @@ -158,7 +166,12 @@ async def run_tool_call_loop( if not selected_calls: response.text = response.text or "工具调用请求为空,未能完成最终回答。" if turn_recorder is not None: - turn_recorder.on_turn(response, declared_calls=[], executable_calls=[], has_more_rounds=False) + turn_recorder.on_turn( + response, + declared_calls=[], + executable_calls=[], + has_more_rounds=False, + ) await turn_recorder.deliver_turn() return response @@ -223,7 +236,10 @@ async def run_tool_call_loop( result = LLMToolResult( call_id=call.id, name=call.name, - content=f"工具 {call.name} 尚未加载,请先调用 {tool_search_name} 搜索并加载相关工具。", + content=( + f"工具 {call.name} 尚未加载," + f"请先调用 {tool_search_name} 搜索并加载相关工具。" + ), is_error=True, ) else: diff --git a/src/quickquip/llm/usage.py b/src/quickquip/llm/usage.py index d1c2c129..1d3cc7bd 100644 --- a/src/quickquip/llm/usage.py +++ b/src/quickquip/llm/usage.py @@ -224,7 +224,11 @@ async def _record_usage( "exclusive" if client.config.protocol == "claude" else "inclusive" ) if rates is not None: - pricing_model = f"{client.config.id}/{model}" if f"{client.config.id}/{model}" in configured else model + pricing_model = ( + f"{client.config.id}/{model}" + if f"{client.config.id}/{model}" in configured + else model + ) pricing_source = rates.source pricing_confidence = rates.confidence @@ -306,7 +310,9 @@ def _schedule_usage_record( """ finished_at = datetime.now(timezone.utc).isoformat() task = asyncio.create_task( - _record_usage(client, request, response, started, stream_used, state, error_msg, finished_at) + _record_usage( + client, request, response, started, stream_used, state, error_msg, finished_at + ) ) _USAGE_TASKS.add(task) task.add_done_callback(_USAGE_TASKS.discard) diff --git a/src/quickquip/llm/usage_store.py b/src/quickquip/llm/usage_store.py index aaccec57..0258489e 100644 --- a/src/quickquip/llm/usage_store.py +++ b/src/quickquip/llm/usage_store.py @@ -204,15 +204,22 @@ def _ensure_schema(self) -> None: if "duplicate column name" not in str(error): raise conn.executescript( - """ - CREATE INDEX IF NOT EXISTS idx_usage_ts ON llm_usage_events(ts DESC, id DESC); - CREATE INDEX IF NOT EXISTS idx_usage_provider ON llm_usage_events(provider_id, ts DESC); - CREATE INDEX IF NOT EXISTS idx_usage_feature ON llm_usage_events(feature, ts DESC); - CREATE INDEX IF NOT EXISTS idx_usage_group ON llm_usage_events(group_id, ts DESC); - CREATE INDEX IF NOT EXISTS idx_usage_model ON llm_usage_events(model, ts DESC); - CREATE INDEX IF NOT EXISTS idx_usage_persona ON llm_usage_events(persona_id, ts DESC); - CREATE INDEX IF NOT EXISTS idx_usage_run_id ON llm_usage_events(run_id); - """ + "\n" + " CREATE INDEX IF NOT EXISTS idx_usage_ts " + "ON llm_usage_events(ts DESC, id DESC);\n" + " CREATE INDEX IF NOT EXISTS idx_usage_provider " + "ON llm_usage_events(provider_id, ts DESC);\n" + " CREATE INDEX IF NOT EXISTS idx_usage_feature " + "ON llm_usage_events(feature, ts DESC);\n" + " CREATE INDEX IF NOT EXISTS idx_usage_group " + "ON llm_usage_events(group_id, ts DESC);\n" + " CREATE INDEX IF NOT EXISTS idx_usage_model " + "ON llm_usage_events(model, ts DESC);\n" + " CREATE INDEX IF NOT EXISTS idx_usage_persona " + "ON llm_usage_events(persona_id, ts DESC);\n" + " CREATE INDEX IF NOT EXISTS idx_usage_run_id " + "ON llm_usage_events(run_id);\n" + " " ) # 历史 claude 行标签 backfill(issue #202):input_tokens 列自始存 # exclusive 原始值,落库标签却恒写 inclusive。UPDATE 天然幂等, @@ -278,22 +285,35 @@ def summary(self, cutoff: str, **filters: str | None) -> dict: where, params = self._where(cutoff, filters) with self.connect() as conn: total = conn.execute( - f"SELECT COALESCE(SUM(CASE WHEN state = 'ok' THEN cost_usd ELSE 0 END), 0) AS cost, " - f"COALESCE(SUM(CASE WHEN state = 'ok' THEN {self._total_tokens_expr()} ELSE 0 END), 0) AS tokens, " - f"COALESCE(SUM(CASE WHEN state = 'ok' THEN {self._fresh_input_expr()} ELSE 0 END), 0) AS fresh_input, " - f"COALESCE(SUM(CASE WHEN state = 'ok' THEN output_tokens ELSE 0 END), 0) AS output, " - f"COALESCE(SUM(CASE WHEN state = 'ok' THEN cache_read_tokens ELSE 0 END), 0) AS cache_read, " - f"COALESCE(SUM(CASE WHEN state = 'ok' THEN cache_creation_tokens ELSE 0 END), 0) AS cache_creation, " - f"COUNT(*) AS calls, COALESCE(SUM(CASE WHEN state = 'ok' THEN 1 ELSE 0 END), 0) AS successes, " + f"SELECT COALESCE(SUM(CASE WHEN state = 'ok' THEN cost_usd ELSE 0 END), 0) " + f"AS cost, " + f"COALESCE(SUM(CASE WHEN state = 'ok' THEN {self._total_tokens_expr()} " + f"ELSE 0 END), 0) AS tokens, " + f"COALESCE(SUM(CASE WHEN state = 'ok' THEN {self._fresh_input_expr()} " + f"ELSE 0 END), 0) AS fresh_input, " + f"COALESCE(SUM(CASE WHEN state = 'ok' THEN output_tokens ELSE 0 END), 0) " + f"AS output, " + f"COALESCE(SUM(CASE WHEN state = 'ok' THEN cache_read_tokens " + f"ELSE 0 END), 0) AS cache_read, " + f"COALESCE(SUM(CASE WHEN state = 'ok' THEN cache_creation_tokens " + f"ELSE 0 END), 0) AS cache_creation, " + f"COUNT(*) AS calls, " + f"COALESCE(SUM(CASE WHEN state = 'ok' THEN 1 ELSE 0 END), 0) AS successes, " f"COALESCE(AVG(duration_ms), 0) AS avg_duration, " f"AVG(CASE WHEN state = 'ok' THEN envelope_tokens END) AS avg_envelope, " - f"COALESCE(SUM(CASE WHEN state = 'ok' AND envelope_tokens IS NOT NULL THEN 1 ELSE 0 END), 0) AS envelope_tracked, " - f"AVG(CASE WHEN state = 'ok' THEN epoch_history_tokens END) AS avg_epoch_history, " - f"COALESCE(SUM(CASE WHEN state = 'ok' AND epoch_history_tokens IS NOT NULL THEN 1 ELSE 0 END), 0) AS epoch_tracked, " - f"AVG(CASE WHEN state = 'ok' THEN media_image_count END) AS avg_media_images, " - f"COALESCE(SUM(CASE WHEN state = 'ok' AND media_image_count IS NOT NULL THEN 1 ELSE 0 END), 0) AS media_tracked, " + f"COALESCE(SUM(CASE WHEN state = 'ok' AND envelope_tokens IS NOT NULL " + f"THEN 1 ELSE 0 END), 0) AS envelope_tracked, " + f"AVG(CASE WHEN state = 'ok' THEN epoch_history_tokens END) " + f"AS avg_epoch_history, " + f"COALESCE(SUM(CASE WHEN state = 'ok' AND epoch_history_tokens " + f"IS NOT NULL THEN 1 ELSE 0 END), 0) AS epoch_tracked, " + f"AVG(CASE WHEN state = 'ok' THEN media_image_count END) " + f"AS avg_media_images, " + f"COALESCE(SUM(CASE WHEN state = 'ok' AND media_image_count " + f"IS NOT NULL THEN 1 ELSE 0 END), 0) AS media_tracked, " f"AVG(CASE WHEN state = 'ok' THEN patch_tokens END) AS avg_patch, " - f"COALESCE(SUM(CASE WHEN state = 'ok' AND patch_tokens IS NOT NULL THEN 1 ELSE 0 END), 0) AS patch_tracked " + f"COALESCE(SUM(CASE WHEN state = 'ok' AND patch_tokens IS NOT NULL " + f"THEN 1 ELSE 0 END), 0) AS patch_tracked " f"FROM llm_usage_events WHERE {where}", params, ).fetchone() @@ -318,25 +338,47 @@ def summary(self, cutoff: str, **filters: str | None) -> dict: "request_count": total["calls"], "success_count": total["successes"], "total_calls": total["successes"], - "success_rate": round((total["successes"] or 0) / total["calls"], 4) if total["calls"] else 0.0, + "success_rate": round((total["successes"] or 0) / total["calls"], 4) + if total["calls"] + else 0.0, "average_duration_ms": round(total["avg_duration"], 2), - "cache_hit_rate": round(total["cache_read"] / input_total, 4) if input_total else 0.0, + "cache_hit_rate": round(total["cache_read"] / input_total, 4) + if input_total + else 0.0, # 第四张账本【信封】:Agent Loop 内每行同值,只可按 AVG 解读为 # 每轮成本,禁止 SUM;coverage = 有估算行的成功调用占比 - "avg_envelope_tokens": round(total["avg_envelope"], 1) if total["avg_envelope"] is not None else 0.0, - "envelope_coverage": round(total["envelope_tracked"] / total["successes"], 4) if total["successes"] else 0.0, + "avg_envelope_tokens": round(total["avg_envelope"], 1) + if total["avg_envelope"] is not None + else 0.0, + "envelope_coverage": round( + total["envelope_tracked"] / total["successes"], 4 + ) + if total["successes"] + else 0.0, # 第五张账本【纪元】:[anchor, head) history 段 token 估算;同信封口径 # 只可按 AVG 解读(验收口径 ≈4.2k),coverage 语义同上 - "avg_epoch_history_tokens": round(total["avg_epoch_history"], 1) if total["avg_epoch_history"] is not None else 0.0, - "epoch_coverage": round(total["epoch_tracked"] / total["successes"], 4) if total["successes"] else 0.0, + "avg_epoch_history_tokens": round(total["avg_epoch_history"], 1) + if total["avg_epoch_history"] is not None + else 0.0, + "epoch_coverage": round(total["epoch_tracked"] / total["successes"], 4) + if total["successes"] + else 0.0, # 第六张账本【媒体】:当轮实际随请求附带的图片数;同信封口径 # 只可按 AVG 解读,coverage 语义同上 - "avg_media_image_count": round(total["avg_media_images"], 1) if total["avg_media_images"] is not None else 0.0, - "media_coverage": round(total["media_tracked"] / total["successes"], 4) if total["successes"] else 0.0, + "avg_media_image_count": round(total["avg_media_images"], 1) + if total["avg_media_images"] is not None + else 0.0, + "media_coverage": round(total["media_tracked"] / total["successes"], 4) + if total["successes"] + else 0.0, # 第七张账本【现场补丁】:【现场】块 token 估算(与预算同单位, # AVG 直接读作预算利用率);尾巴段每轮全价,不计入纪元 CTX 预算 - "avg_patch_tokens": round(total["avg_patch"], 1) if total["avg_patch"] is not None else 0.0, - "patch_coverage": round(total["patch_tracked"] / total["successes"], 4) if total["successes"] else 0.0, + "avg_patch_tokens": round(total["avg_patch"], 1) + if total["avg_patch"] is not None + else 0.0, + "patch_coverage": round(total["patch_tracked"] / total["successes"], 4) + if total["successes"] + else 0.0, "by_provider": self._group_by(conn, "provider_id", where, params), "by_feature": self._group_by(conn, "feature", where, params), "by_model": self._group_by(conn, "model", where, params), @@ -382,8 +424,10 @@ def timeline( rows = conn.execute( f"SELECT {bucket_expr} AS d, " f"COALESCE(SUM(CASE WHEN state = 'ok' THEN cost_usd ELSE 0 END), 0) AS cost, " - f"COALESCE(SUM(CASE WHEN state = 'ok' THEN {self._total_tokens_expr()} ELSE 0 END), 0) AS tokens, " - f"COUNT(*) AS requests, SUM(CASE WHEN state = 'error' THEN 1 ELSE 0 END) AS errors, " + f"COALESCE(SUM(CASE WHEN state = 'ok' THEN {self._total_tokens_expr()} " + f"ELSE 0 END), 0) AS tokens, " + f"COUNT(*) AS requests, " + f"SUM(CASE WHEN state = 'error' THEN 1 ELSE 0 END) AS errors, " f"COALESCE(AVG(duration_ms), 0) AS duration " f"FROM llm_usage_events WHERE {where} GROUP BY d ORDER BY d", params, @@ -400,7 +444,8 @@ def timeline( if not fill_buckets: return [ {"date": r["d"], "cost": round(r["cost"], 6), "tokens": r["tokens"], - "requests": r["requests"], "errors": r["errors"], "duration": round(r["duration"], 2), + "requests": r["requests"], "errors": r["errors"], + "duration": round(r["duration"], 2), "value": self._timeline_value(r, metric)} for r in rows ] @@ -423,25 +468,46 @@ def timeline( def _group_by(conn, col: str, where: str, params: list[object]) -> list[dict]: """按某列聚合 cost/calls(仅 state='ok')。col 受控(非用户输入)。""" rows = conn.execute( - f"SELECT {col} AS k, COALESCE(SUM(CASE WHEN state = 'ok' THEN cost_usd ELSE 0 END), 0) AS cost, " - f"COUNT(*) AS calls, COALESCE(SUM(CASE WHEN state = 'ok' THEN {LLMUsageStore._total_tokens_expr()} ELSE 0 END), 0) AS tokens, " + f"SELECT {col} AS k, COALESCE(SUM(CASE WHEN state = 'ok' THEN cost_usd " + f"ELSE 0 END), 0) AS cost, " + f"COUNT(*) AS calls, " + f"COALESCE(SUM(CASE WHEN state = 'ok' THEN " + f"{LLMUsageStore._total_tokens_expr()} " + f"ELSE 0 END), 0) AS tokens, " f"SUM(CASE WHEN state = 'error' THEN 1 ELSE 0 END) AS errors " f"FROM llm_usage_events WHERE {where} GROUP BY {col} " f"ORDER BY cost DESC", params, ).fetchall() return [ - {"key": r["k"] if r["k"] is not None else UNATTRIBUTED_LABEL, "cost": round(r["cost"], 6), "calls": r["calls"], "tokens": r["tokens"], "errors": r["errors"]} + { + "key": r["k"] if r["k"] is not None else UNATTRIBUTED_LABEL, + "cost": round(r["cost"], 6), + "calls": r["calls"], + "tokens": r["tokens"], + "errors": r["errors"], + } for r in rows ] @staticmethod def _total_tokens_expr() -> str: - return "COALESCE(total_tokens, CASE WHEN input_token_semantics = 'exclusive' OR (input_token_semantics IS NULL AND protocol = 'claude') THEN COALESCE(input_tokens, 0) + COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0) ELSE COALESCE(input_tokens, 0) END + COALESCE(output_tokens, 0))" + return ( + "COALESCE(total_tokens, CASE WHEN input_token_semantics = 'exclusive' " + "OR (input_token_semantics IS NULL AND protocol = 'claude') " + "THEN COALESCE(input_tokens, 0) + COALESCE(cache_read_tokens, 0) " + "+ COALESCE(cache_creation_tokens, 0) ELSE COALESCE(input_tokens, 0) END " + "+ COALESCE(output_tokens, 0))" + ) @staticmethod def _fresh_input_expr() -> str: - return "COALESCE(fresh_input_tokens, CASE WHEN input_token_semantics = 'exclusive' OR (input_token_semantics IS NULL AND protocol = 'claude') THEN COALESCE(input_tokens, 0) ELSE MAX(0, COALESCE(input_tokens, 0) - COALESCE(cache_read_tokens, 0) - COALESCE(cache_creation_tokens, 0)) END)" + return ( + "COALESCE(fresh_input_tokens, CASE WHEN input_token_semantics = 'exclusive' " + "OR (input_token_semantics IS NULL AND protocol = 'claude') " + "THEN COALESCE(input_tokens, 0) ELSE MAX(0, COALESCE(input_tokens, 0) " + "- COALESCE(cache_read_tokens, 0) - COALESCE(cache_creation_tokens, 0)) END)" + ) @staticmethod def _timeline_value(row: sqlite3.Row | None, metric: str) -> float | int: @@ -510,12 +576,17 @@ def events( ).fetchall() has_more = len(rows) > limit rows = rows[:limit] - return {"items": [dict(row) for row in rows], "next_cursor": str(rows[-1]["id"]) if has_more and rows else None} + return { + "items": [dict(row) for row in rows], + "next_cursor": str(rows[-1]["id"]) if has_more and rows else None, + } def event(self, event_id: int) -> dict | None: self._ensure_schema() with self.connect() as conn: - row = conn.execute("SELECT * FROM llm_usage_events WHERE id = ?", (event_id,)).fetchone() + row = conn.execute( + "SELECT * FROM llm_usage_events WHERE id = ?", (event_id,) + ).fetchone() return dict(row) if row else None def _cleanup_if_due(self) -> None: diff --git a/src/quickquip/llm/vocab.py b/src/quickquip/llm/vocab.py index 279027e8..87a74bfa 100644 --- a/src/quickquip/llm/vocab.py +++ b/src/quickquip/llm/vocab.py @@ -114,7 +114,9 @@ def find_glossary(self, text: str, limit: int = 3) -> list[tuple[str, str]]: return [] matches: list[tuple[str, str]] = [] - for term, meaning in sorted(self.glossary.items(), key=lambda item: len(item[0]), reverse=True): + for term, meaning in sorted( + self.glossary.items(), key=lambda item: len(item[0]), reverse=True + ): if term not in normalized: continue matches.append((term, meaning)) diff --git a/src/quickquip/sts/formulas/defectify/prompting.py b/src/quickquip/sts/formulas/defectify/prompting.py index b32c0be0..049b96c9 100644 --- a/src/quickquip/sts/formulas/defectify/prompting.py +++ b/src/quickquip/sts/formulas/defectify/prompting.py @@ -23,46 +23,49 @@ def build_defectify_prompt( normalized_quoted_text = quoted_text.strip() normalized_quoted_image_urls = [url.strip() for url in (quoted_image_urls or []) if url.strip()] - system_prompt = """ -你执行"故障化"任务:把任意输入内容转写为五个汉字,读音依次贴近「故·障·机·器·人」的五个音,同时每个字须从输入里取得语义落点。 - -五个音槽及候选字(以下仅列常用字,不必局限于此): -- 槽1 [gu]:故 固 顾 孤 蛊 骨 鼓 估 菇 … -- 槽2 [zhang]:障 账 涨 胀 仗 章 掌 张 脏 … -- 槽3 [ji]:机 鸡 迹 计 记 寄 积 急 击 疾 籍 … -- 槽4 [qi]:器 气 弃 骑 欺 乞 泣 期 齐 戚 … -- 槽5 [ren]:人 忍 认 刃 任 韧 润 仁 仍 … - -语音匹配原则(宽松):声调不限;声母 n/l 可互换;韵母前鼻(an/en/in)与后鼻(ang/eng/ing)可互换;总体形近音近即可。 - -选字步骤: -1. 先从输入里提炼 5 个有梗的点(人物/动作/情绪/结果/场景/物品/评价等); -2. 把 5 个点逐一分配给 5 个音槽; -3. 在该槽候选字里选语义最贴合的字;候选字均不合适时可另选近音字。 - -输出格式(仅输出以下两行,不要其他内容): -[五字] -笑点解析:[一句自然语言,串联五字如何命中输入,不超过 80 字] - -示例1 -素材:小偷 -孤赃极乞润 -笑点解析:孤身作案,一路攒赃,极品乞讨路线的终极实践,案发后润走——五字走完了一趟完整的偷窃职业规划。 - -示例2 -素材:真菌兽(蘑菇) -菇仗寄气人 -笑点解析:菇字本尊亲自下场,仗着腐木寄生,浑身散发菌气,真菌兽就这么被收编进了人字结尾的五字组合。 - -示例3 -素材:刚被邻居在电梯里认出来,就是昨晚打游戏吵到凌晨三点的那个 -孤张迹气认 -笑点解析:孤身进电梯,那张昨夜吵到凌晨的脸就这么被认出来了,行迹当场败露,气氛凝固,只剩一个认字和漫长的七楼。 - -约束: -- 每个字的解释必须来自输入内容,禁止以"与原字同音/近音"为语义理由; -- 禁止输出 JSON、代码块、多余前言或思考过程。 -""".strip() + system_prompt = ( + "\n" + '你执行"故障化"任务:把任意输入内容转写为五个汉字,读音依次贴近「故·障·机·器·人」的五个音,同时每个字须从输入里取得语义落点。\n' + "\n" + "五个音槽及候选字(以下仅列常用字,不必局限于此):\n" + "- 槽1 [gu]:故 固 顾 孤 蛊 骨 鼓 估 菇 …\n" + "- 槽2 [zhang]:障 账 涨 胀 仗 章 掌 张 脏 …\n" + "- 槽3 [ji]:机 鸡 迹 计 记 寄 积 急 击 疾 籍 …\n" + "- 槽4 [qi]:器 气 弃 骑 欺 乞 泣 期 齐 戚 …\n" + "- 槽5 [ren]:人 忍 认 刃 任 韧 润 仁 仍 …\n" + "\n" + "语音匹配原则(宽松):声调不限;声母 n/l 可互换" + ";韵母前鼻(an/en/in)与后鼻(ang/eng/ing)可互换;总体形近音近即可。\n" + "\n" + "选字步骤:\n" + "1. 先从输入里提炼 5 个有梗的点(人物/动作/情绪/结果/场景/物品/评价等);\n" + "2. 把 5 个点逐一分配给 5 个音槽;\n" + "3. 在该槽候选字里选语义最贴合的字;候选字均不合适时可另选近音字。\n" + "\n" + "输出格式(仅输出以下两行,不要其他内容):\n" + "[五字]\n" + "笑点解析:[一句自然语言,串联五字如何命中输入,不超过 80 字]\n" + "\n" + "示例1\n" + "素材:小偷\n" + "孤赃极乞润\n" + "笑点解析:孤身作案,一路攒赃,极品乞讨路线的终极实践,案发后润走——五字走完了一趟完整的偷窃职业规划。\n" + "\n" + "示例2\n" + "素材:真菌兽(蘑菇)\n" + "菇仗寄气人\n" + "笑点解析:菇字本尊亲自下场,仗着腐木寄生,浑身散发菌气,真菌兽就这么被收编进了人字结尾的五字组合。\n" + "\n" + "示例3\n" + "素材:刚被邻居在电梯里认出来,就是昨晚打游戏吵到凌晨三点的那个\n" + "孤张迹气认\n" + "笑点解析:孤身进电梯,那张昨夜吵到凌晨的脸就这么被认出来了,行迹当场败露,气氛凝固,只剩一个认字和漫长的七楼。\n" + "\n" + "约束:\n" + '- 每个字的解释必须来自输入内容,禁止以"与原字同音/近音"为语义理由;\n' + "- 禁止输出 JSON、代码块、多余前言或思考过程。\n" + "" +).strip() lines = ["素材如下,请按格式输出,不要输出思考过程。"] if normalized_prompt: diff --git a/src/quickquip/tieba/config.py b/src/quickquip/tieba/config.py index 2aea5975..6fa3d30f 100644 --- a/src/quickquip/tieba/config.py +++ b/src/quickquip/tieba/config.py @@ -142,7 +142,13 @@ def load_tieba_config() -> TiebaConfig: forum_keywords=forum_keywords, sync_interval_seconds=max( 60, - int(os.getenv("TIEBA_SYNC_INTERVAL_SECONDS", DEFAULT_SYNC_INTERVAL_SECONDS) or DEFAULT_SYNC_INTERVAL_SECONDS), + int( + os.getenv( + "TIEBA_SYNC_INTERVAL_SECONDS", + DEFAULT_SYNC_INTERVAL_SECONDS, + ) + or DEFAULT_SYNC_INTERVAL_SECONDS + ), ), max_pool_size=max( 20, @@ -150,15 +156,24 @@ def load_tieba_config() -> TiebaConfig: ), recent_sent_limit=max( 1, - int(os.getenv("TIEBA_RECENT_SENT_LIMIT", DEFAULT_RECENT_SENT_LIMIT) or DEFAULT_RECENT_SENT_LIMIT), + int( + os.getenv("TIEBA_RECENT_SENT_LIMIT", DEFAULT_RECENT_SENT_LIMIT) + or DEFAULT_RECENT_SENT_LIMIT + ), ), detail_fetch_limit=max( 1, - int(os.getenv("TIEBA_DETAIL_FETCH_LIMIT", DEFAULT_DETAIL_FETCH_LIMIT) or DEFAULT_DETAIL_FETCH_LIMIT), + int( + os.getenv("TIEBA_DETAIL_FETCH_LIMIT", DEFAULT_DETAIL_FETCH_LIMIT) + or DEFAULT_DETAIL_FETCH_LIMIT + ), ), random_avoid_recent=max( 0, - int(os.getenv("TIEBA_RANDOM_AVOID_RECENT", DEFAULT_RANDOM_AVOID_RECENT) or DEFAULT_RANDOM_AVOID_RECENT), + int( + os.getenv("TIEBA_RANDOM_AVOID_RECENT", DEFAULT_RANDOM_AVOID_RECENT) + or DEFAULT_RANDOM_AVOID_RECENT + ), ), prefer_image_threads=env_bool("TIEBA_PREFER_IMAGE_THREADS", True), browser_headless=env_bool("TIEBA_BROWSER_HEADLESS", True), diff --git a/src/quickquip/tieba/crawler.py b/src/quickquip/tieba/crawler.py index 27fb8f88..798748c2 100644 --- a/src/quickquip/tieba/crawler.py +++ b/src/quickquip/tieba/crawler.py @@ -91,11 +91,14 @@ async def load_forum_feed_data(self, page: Page, forum_keyword: str) -> dict[str content = clean_text(await page.content(), limit=10_000) current_url = clean_text(page.url) if self.is_challenge_page(title, content, current_url): - raise TiebaLoginRequiredError(f"{forum_keyword} 吧主页命中百度安全验证,需要人工续签登录态") + raise TiebaLoginRequiredError( + f"{forum_keyword} 吧主页命中百度安全验证,需要人工续签登录态" + ) if int(data.get("error_code", 0) or 0) != 0: raise TiebaServiceError( - f"贴吧首页接口返回异常:error_code={data.get('error_code')} {data.get('error_msg', '')}" + f"贴吧首页接口返回异常:error_code={data.get('error_code')} " + f"{data.get('error_msg', '')}" ) return data @@ -162,7 +165,9 @@ async def load_thread_data(self, page: Page, url: str) -> dict[str, object]: ) return data - def extract_urls_from_content(self, content_items: list[dict[str, object]]) -> tuple[str, list[str]]: + def extract_urls_from_content( + self, content_items: list[dict[str, object]] + ) -> tuple[str, list[str]]: text_parts: list[str] = [] image_urls: list[str] = [] seen_images: set[str] = set() @@ -184,11 +189,16 @@ def extract_urls_from_content(self, content_items: list[dict[str, object]]) -> t if not candidate or not candidate.startswith(("http://", "https://")): continue lowered = candidate.lower() - if any(marker in lowered for marker in ["portrait", "icon", "avatar", "emoticon", "ares.cdn.bcebos.com"]): + if any( + marker in lowered + for marker in ["portrait", "icon", "avatar", "emoticon", "ares.cdn.bcebos.com"] + ): continue if candidate in seen_images: continue - if item_type in {3, 5} or any(ext in lowered for ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]): + if item_type in {3, 5} or any( + ext in lowered for ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"] + ): seen_images.add(candidate) image_urls.append(candidate) @@ -307,9 +317,14 @@ async def collect_threads( forum_feed_data = await self.load_forum_feed_data(page, forum_keyword) links = self.extract_forum_links(forum_feed_data) if not links: - raise TiebaServiceError("未在贴吧首页提取到帖子链接,请先完成登录并确认页面可正常打开") - - selected_links = links[: limit if limit is not None else self.config.detail_fetch_limit] + raise TiebaServiceError( + "未在贴吧首页提取到帖子链接," + "请先完成登录并确认页面可正常打开" + ) + + selected_links = links[ + : limit if limit is not None else self.config.detail_fetch_limit + ] threads: list[TiebaThread] = [] for item in selected_links: try: @@ -323,12 +338,21 @@ async def collect_threads( continue if not detail.cover_image_url: detail.cover_image_url = item.get("cover_image_url", "") - if detail.cover_image_url and detail.cover_image_url not in detail.image_urls: + if ( + detail.cover_image_url + and detail.cover_image_url not in detail.image_urls + ): detail.image_urls.insert(0, detail.cover_image_url) - detail.fetched_at = datetime.now(tz=ZoneInfo(BEIJING_TIMEZONE)).timestamp() + detail.fetched_at = datetime.now( + tz=ZoneInfo(BEIJING_TIMEZONE) + ).timestamp() threads.append(detail) if on_progress: - img_hint = f" [{len(detail.image_urls)}图]" if detail.image_urls else "" + img_hint = ( + f" [{len(detail.image_urls)}图]" + if detail.image_urls + else "" + ) on_progress(f"✓ {detail.title[:30]}{img_hint}") except TiebaLoginRequiredError: raise # login expiry aborts the entire forum @@ -344,7 +368,9 @@ async def collect_threads( async def interactive_login(self, forum_keyword: str) -> None: if not forum_keyword: - raise TiebaServiceError("请先在 .env 中设置 TIEBA_FORUM_KEYWORD 或 TIEBA_FORUM_KEYWORDS") + raise TiebaServiceError( + "请先在 .env 中设置 TIEBA_FORUM_KEYWORD 或 TIEBA_FORUM_KEYWORDS" + ) if not self.playwright_ready(): raise TiebaServiceError("未安装 Playwright,请先执行 pip install -r requirements.txt") diff --git a/src/quickquip/tieba/formatting.py b/src/quickquip/tieba/formatting.py index 96a82be7..6cb51bc1 100644 --- a/src/quickquip/tieba/formatting.py +++ b/src/quickquip/tieba/formatting.py @@ -31,10 +31,16 @@ def format_status( for forum_keyword, state in forum_states: lines.append(f"来源:{forum_keyword}吧") lines.append(f" 缓存帖子:{len(state.threads) if state else 0}") - lines.append(f" 上次开始:{format_timestamp(state.last_sync_started_at) if state else '未记录'}") - lines.append(f" 上次完成:{format_timestamp(state.last_sync_completed_at) if state else '未记录'}") + lines.append( + f" 上次开始:{format_timestamp(state.last_sync_started_at) if state else '未记录'}" + ) + lines.append( + f" 上次完成:{format_timestamp(state.last_sync_completed_at) if state else '未记录'}" + ) lines.append(f" 上次状态:{state.last_sync_status if state else 'idle'}") - lines.append(f" 登录态:{'需要人工续签' if state and state.login_required else '正常或未判定'}") + lines.append( + f" 登录态:{'需要人工续签' if state and state.login_required else '正常或未判定'}" + ) if state and state.last_error: lines.append(f" 最近错误:{state.last_error}") @@ -65,7 +71,9 @@ def format_sources( count = len(state.threads) if state else 0 status = state.last_sync_status if state else "idle" login_status = "需要续签" if state and state.login_required else "正常或未判定" - lines.append(f"- {forum_keyword}吧 | 缓存 {count} 条 | 状态 {status} | 登录态 {login_status}") + lines.append( + f"- {forum_keyword}吧 | 缓存 {count} 条 | 状态 {status} | 登录态 {login_status}" + ) if show_usage_hint: lines.append("可用:/tieba <贴吧名>、/tieba text <贴吧名>、/tieba status <贴吧名>") diff --git a/src/quickquip/tieba/service.py b/src/quickquip/tieba/service.py index dfee315f..814f8a96 100644 --- a/src/quickquip/tieba/service.py +++ b/src/quickquip/tieba/service.py @@ -75,7 +75,9 @@ def resolve_forum_keywords( if not self.config.forum_keywords: if not require_enabled: return () - raise TiebaServiceError("未配置 TIEBA_FORUM_KEYWORD 或 TIEBA_FORUM_KEYWORDS,无法同步贴吧") + raise TiebaServiceError( + "未配置 TIEBA_FORUM_KEYWORD 或 TIEBA_FORUM_KEYWORDS,无法同步贴吧" + ) if forum_keyword is None: return self.config.forum_keywords @@ -87,7 +89,9 @@ def resolve_forum_keywords( raise TiebaServiceError(f"未配置贴吧来源:{normalized}吧") return (normalized,) - def _build_sync_message(self, results: list[dict[str, object]], selected_forums: tuple[str, ...]) -> str: + def _build_sync_message( + self, results: list[dict[str, object]], selected_forums: tuple[str, ...] + ) -> str: if not results: return "未执行任何贴吧同步" @@ -104,7 +108,8 @@ def _build_sync_message(self, results: list[dict[str, object]], selected_forums: success_count = sum(1 for item in results if item["status"] == "ok") lines = [ - f"贴吧缓存同步完成:{success_count}/{len(results)} 个来源成功,总缓存 {self.store.count(selected_forums)} 条" + f"贴吧缓存同步完成:{success_count}/{len(results)} 个来源成功," + f"总缓存 {self.store.count(selected_forums)} 条" ] for item in results: forum_keyword = str(item["forum_keyword"]) @@ -161,10 +166,14 @@ async def sync_now( if on_progress: on_progress(f"▶ 开始同步 {selected_forum}吧") try: - threads = await self.crawler.collect_threads(selected_forum, on_progress=on_progress) + threads = await self.crawler.collect_threads( + selected_forum, on_progress=on_progress + ) except Exception as exc: message, status, login_required, wrap = self._classify_sync_error(exc) - self.store.record_sync_failure(selected_forum, message, login_required=login_required) + self.store.record_sync_failure( + selected_forum, message, login_required=login_required + ) if on_progress: detail = f"需要重新登录:{exc}" if login_required else message on_progress(f"✗ {selected_forum}吧 {detail}") @@ -185,7 +194,10 @@ async def sync_now( updated = self.store.record_sync_success(selected_forum, threads) if on_progress: - on_progress(f"✓ {selected_forum}吧 同步完成,新增/更新 {updated} 条,共 {self.store.count((selected_forum,))} 条") + on_progress( + f"✓ {selected_forum}吧 同步完成,新增/更新 {updated} 条," + f"共 {self.store.count((selected_forum,))} 条" + ) results.append( { "forum_keyword": selected_forum, @@ -210,7 +222,9 @@ async def startup(self) -> None: return if self._background_task is not None and not self._background_task.done(): return - self._background_task = asyncio.create_task(self._run_background_loop(), name="quickquip-tieba-sync") + self._background_task = asyncio.create_task( + self._run_background_loop(), name="quickquip-tieba-sync" + ) async def shutdown(self) -> None: if self._background_task is None: @@ -263,7 +277,9 @@ def mark_sent(self, thread: TiebaThread) -> None: async def interactive_login(self, forum_keyword: str | None = None) -> None: selected_forums = self.resolve_forum_keywords(forum_keyword, require_enabled=False) if not selected_forums: - raise TiebaServiceError("请先在 .env 中设置 TIEBA_FORUM_KEYWORD 或 TIEBA_FORUM_KEYWORDS") + raise TiebaServiceError( + "请先在 .env 中设置 TIEBA_FORUM_KEYWORD 或 TIEBA_FORUM_KEYWORDS" + ) login_forum = selected_forums[0] await self.crawler.interactive_login(login_forum) for selected_forum in selected_forums: diff --git a/src/quickquip/tieba/store.py b/src/quickquip/tieba/store.py index f0d1d11a..0910b99f 100644 --- a/src/quickquip/tieba/store.py +++ b/src/quickquip/tieba/store.py @@ -50,7 +50,9 @@ def from_dict(cls, data: dict[str, object]) -> "TiebaThread": author_name=str(data.get("author_name", "")).strip(), main_post_text=str(data.get("main_post_text", "")).strip(), cover_image_url=str(data.get("cover_image_url", "")).strip(), - image_urls=[str(item).strip() for item in data.get("image_urls", []) if str(item).strip()], + image_urls=[ + str(item).strip() for item in data.get("image_urls", []) if str(item).strip() + ], fetched_at=float(data.get("fetched_at", 0.0) or 0.0), last_seen_at=float(data.get("last_seen_at", 0.0) or 0.0), is_deleted=bool(data.get("is_deleted", False)), @@ -280,7 +282,9 @@ def record_sync_failure( state.login_required = login_required self.save() - def _selected_states(self, forum_keywords: Iterable[str] | None = None) -> list[TiebaForumState]: + def _selected_states( + self, forum_keywords: Iterable[str] | None = None + ) -> list[TiebaForumState]: if forum_keywords is None: return list(self.forums.values()) @@ -333,7 +337,11 @@ def choose_random_thread( return None if prefer_images: - with_image = [thread for thread in available if thread.cover_image_url or thread.image_urls] + with_image = [ + thread + for thread in available + if thread.cover_image_url or thread.image_urls + ] if with_image: available = with_image diff --git a/tests/fixtures/agent_loop.py b/tests/fixtures/agent_loop.py index 2376cac3..f7ce901b 100644 --- a/tests/fixtures/agent_loop.py +++ b/tests/fixtures/agent_loop.py @@ -107,13 +107,15 @@ def _native_thinking_blocks(protocol: str, turn_index: int) -> list[dict]: label = f"turn{turn_index}" if protocol == "claude": return [ - {"type": "thinking", "thinking": f"先核对榜单再回答({label})。", "signature": f"sig-{label}"}, + {"type": "thinking", "thinking": f"先核对榜单再回答({label})。", + "signature": f"sig-{label}"}, {"type": "redacted_thinking", "data": f"redacted-{label}"}, ] if protocol == "gemini": # replay_required 形态:带 thoughtSignature 的 part 包成 gemini_part return [ - {"type": "gemini_part", "part": {"text": f"检索线索({label})", "thoughtSignature": f"ts-{label}"}}, + {"type": "gemini_part", "part": {"text": f"检索线索({label})", + "thoughtSignature": f"ts-{label}"}}, ] return [{"type": "reasoning", "reasoning_content": f"解题思路({label})。"}] @@ -125,8 +127,10 @@ def _native_thinking_blocks(protocol: str, turn_index: int) -> list[dict]: "content": FIVE_TURN_TEXTS[1], "reasoning_content": "解题思路(turn1)。", "tool_calls": [ - {"id": "call_1_0", "type": "function", "function": {"name": "get_identity", "arguments": '{"query":"4s"}'}}, - {"id": "call_1_1", "type": "function", "function": {"name": "get_identity", "arguments": '{"query":"哈基镜"}'}}, + {"id": "call_1_0", "type": "function", + "function": {"name": "get_identity", "arguments": '{"query":"4s"}'}}, + {"id": "call_1_1", "type": "function", + "function": {"name": "get_identity", "arguments": '{"query":"哈基镜"}'}}, ], } @@ -136,7 +140,8 @@ def _native_thinking_blocks(protocol: str, turn_index: int) -> list[dict]: {"type": "thinking", "thinking": "先核对榜单再回答(turn1)。", "signature": "sig-turn1"}, {"type": "text", "text": FIVE_TURN_TEXTS[1]}, {"type": "tool_use", "id": "call_1_0", "name": "get_identity", "input": {"query": "4s"}}, - {"type": "tool_use", "id": "call_1_1", "name": "get_identity", "input": {"query": "哈基镜"}}, + {"type": "tool_use", "id": "call_1_1", "name": "get_identity", + "input": {"query": "哈基镜"}}, ], } @@ -146,7 +151,8 @@ def _native_thinking_blocks(protocol: str, turn_index: int) -> list[dict]: {"text": "检索线索(turn1)。", "thoughtSignature": "ts-turn1", "thought": True}, {"text": FIVE_TURN_TEXTS[1]}, {"functionCall": {"id": "gemini_tool_1", "name": "get_identity", "args": {"query": "4s"}}}, - {"functionCall": {"id": "gemini_tool_2", "name": "get_identity", "args": {"query": "哈基镜"}}}, + {"functionCall": {"id": "gemini_tool_2", "name": "get_identity", + "args": {"query": "哈基镜"}}}, ], } @@ -210,7 +216,10 @@ def build_legacy_db(path: Path) -> None: conn.execute( """ INSERT INTO conversation_messages - (group_id, user_id, sender_name, canonical_name, role, content, message_id, raw_content, created_at) + ( + group_id, user_id, sender_name, canonical_name, role, + content, message_id, raw_content, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( diff --git a/tests/fixtures/record_identities.py b/tests/fixtures/record_identities.py index 130c00cd..3612d2b8 100644 --- a/tests/fixtures/record_identities.py +++ b/tests/fixtures/record_identities.py @@ -13,7 +13,10 @@ def index(*entries): @pytest.fixture def snapshot(monkeypatch): - snap = IdentitySnapshot(index(IdentityEntry("标准名", ["12345"], ["别名"], "")), {"12345": "名片", "23456": "未登记名片"}) + snap = IdentitySnapshot( + index(IdentityEntry("标准名", ["12345"], ["别名"], "")), + {"12345": "名片", "23456": "未登记名片"}, + ) monkeypatch.setattr(identities, "snapshot", lambda scope: snap) monkeypatch.setattr(web_identities, "snapshot", lambda scope: snap) return snap @@ -21,4 +24,6 @@ def snapshot(monkeypatch): def write_identity(path, name): path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(f'people:\n - canonical_name: "{name}"\n qq_ids: ["12345"]\n', encoding="utf-8") + path.write_text( + f'people:\n - canonical_name: "{name}"\n qq_ids: ["12345"]\n', encoding="utf-8" + ) diff --git a/tests/fixtures/scheduled_cron.py b/tests/fixtures/scheduled_cron.py index 8b049345..7bc70bb2 100644 --- a/tests/fixtures/scheduled_cron.py +++ b/tests/fixtures/scheduled_cron.py @@ -1,7 +1,9 @@ -"""一次性任务测试的钉死 cron 生成器:相对后端校验口径(Asia/Shanghai、按"今年对应时刻"判定)永不跨期。 +"""一次性任务测试的钉死 cron 生成器: + 相对后端校验口径(Asia/Shanghai、按"今年对应时刻"判定)永不跨期。 后端 ``validate_one_off_schedule`` 按北京时区取当前时间、用当前年份构造钉死月/日对应的时刻, -因此这里一律按北京时区取 now,且保证钉死的月/日落在今年——否则 12/31 与 1/1 会变成一年一度的测试炸弹。 +因此这里一律按北京时区取 now,且保证钉死的月/日落在今年—— + 否则 12/31 与 1/1 会变成一年一度的测试炸弹。 """ from __future__ import annotations diff --git a/tests/integration/test_agent_delivery_optin.py b/tests/integration/test_agent_delivery_optin.py index c7f287ce..acf775d6 100644 --- a/tests/integration/test_agent_delivery_optin.py +++ b/tests/integration/test_agent_delivery_optin.py @@ -33,7 +33,9 @@ async def _service(tmp_path: Path) -> LLMService: return service -async def _run(service: LLMService, patch_provider_builder, group_id: int) -> tuple[dict, CollectingSink]: +async def _run( + service: LLMService, patch_provider_builder, group_id: int +) -> tuple[dict, CollectingSink]: sink = CollectingSink() service.bind_delivery_sink(sink) client = FiveTurnScenarioClient(protocol="openai") diff --git a/tests/integration/test_agent_loop_baseline.py b/tests/integration/test_agent_loop_baseline.py index ce9a64a7..3242c7a7 100644 --- a/tests/integration/test_agent_loop_baseline.py +++ b/tests/integration/test_agent_loop_baseline.py @@ -111,7 +111,8 @@ async def test_final_only_mode_records_every_turn_and_sends_final( statuses = [ row["status"] for row in conn.execute( - "SELECT status FROM agent_deliveries WHERE kind='text_chunk' ORDER BY delivery_index" + "SELECT status FROM agent_deliveries " + "WHERE kind='text_chunk' ORDER BY delivery_index" ) ] assert statuses == ["suppressed", "suppressed", "suppressed", "suppressed"] @@ -219,7 +220,8 @@ async def complete(self, request: LLMRequest) -> LLMResponse: statuses = [ row["status"] for row in conn.execute( - "SELECT status FROM agent_deliveries WHERE kind='text_chunk' ORDER BY delivery_index" + "SELECT status FROM agent_deliveries " + "WHERE kind='text_chunk' ORDER BY delivery_index" ) ] assert statuses == ["sent", "suppressed", "sent"] diff --git a/tests/integration/test_agent_loop_delivery_failure.py b/tests/integration/test_agent_loop_delivery_failure.py index 2f41396e..5423ff91 100644 --- a/tests/integration/test_agent_loop_delivery_failure.py +++ b/tests/integration/test_agent_loop_delivery_failure.py @@ -65,7 +65,9 @@ async def test_first_chunk_failure_stops_tools_and_generation( # 零送达:中止必须可见(静默依据是「已有成功交付」) assert result["reply"] == "本次回复未确认送达,已停止后续生成。" with service.store._connect() as conn: - tool_status = [row["status"] for row in conn.execute("SELECT status FROM agent_tool_executions")] + tool_status = [ + row["status"] for row in conn.execute("SELECT status FROM agent_tool_executions") + ] loop_row = conn.execute( "SELECT status, terminal_reason FROM agent_loops" ).fetchone() @@ -146,7 +148,8 @@ async def test_middle_chunk_failure_keeps_earlier_facts(tmp_path: Path, patch_pr ).fetchone()["c"] # 首个成功 Chunk 的 qq id 回填兼容列。 row = conn.execute( - "SELECT message_id FROM conversation_messages WHERE role='assistant' ORDER BY id LIMIT 1" + "SELECT message_id FROM conversation_messages " + "WHERE role='assistant' ORDER BY id LIMIT 1" ).fetchone() assert sent == 2 assert failed == 1 @@ -282,7 +285,9 @@ def _budget(config, provider, request): assert result["reply"] == "本次回复未确认送达,已停止后续生成。" -async def test_intermediate_only_first_failure_surfaces_notice(tmp_path: Path, patch_provider_builder): +async def test_intermediate_only_first_failure_surfaces_notice( + tmp_path: Path, patch_provider_builder +): """组合 B(仅中间轮开):首个中间轮段失败 → 零送达,D3 终止并给出可见提示。""" from tests.fixtures.agent_loop import FiveTurnScenarioClient @@ -313,7 +318,9 @@ async def test_intermediate_only_first_failure_surfaces_notice(tmp_path: Path, p assert loop_row["terminal_reason"] == "delivery_failed" -async def test_final_only_failure_after_suppressed_intermediate(tmp_path: Path, patch_provider_builder): +async def test_final_only_failure_after_suppressed_intermediate( + tmp_path: Path, patch_provider_builder +): """组合 C(仅最终轮开):中间轮 suppressed 不外发;最终首段失败 → 终止。""" from tests.fixtures.agent_loop import FiveTurnScenarioClient diff --git a/tests/integration/test_agent_loop_history_maintenance.py b/tests/integration/test_agent_loop_history_maintenance.py index dcbbf459..b319fb33 100644 --- a/tests/integration/test_agent_loop_history_maintenance.py +++ b/tests/integration/test_agent_loop_history_maintenance.py @@ -69,7 +69,11 @@ def _seed_loop_with_turn(store, scope="1001", *, text="工具轮正文", qq_id=" from quickquip.llm.agent_records import DeliveryReceipt, DeliveryStatus store.finish_delivery(attempt, DeliveryReceipt(status=DeliveryStatus.SENT, message_id=qq_id)) - store.close_loop(handle, __import__("quickquip.llm.agent_records", fromlist=["LoopStatus"]).LoopStatus.COMPLETED, None) + store.close_loop( + handle, + __import__("quickquip.llm.agent_records", fromlist=["LoopStatus"]).LoopStatus.COMPLETED, + None, + ) return handle, record @@ -119,13 +123,15 @@ async def test_recall_by_qq_id_masks_chunk_and_clears_evidence(tmp_path: Path): store = service.store with store._connect() as conn: row = conn.execute( - "SELECT content FROM conversation_messages WHERE agent_loop_id IS NOT NULL AND role='assistant'" + "SELECT content FROM conversation_messages " + "WHERE agent_loop_id IS NOT NULL AND role='assistant'" ).fetchone() delivery = conn.execute( "SELECT recall_status FROM agent_deliveries WHERE delivery_id='dlv_seed_0'" ).fetchone() execution = conn.execute( - "SELECT result_json, result_omission_reason FROM agent_tool_executions WHERE execution_id='exec_seed_0'" + "SELECT result_json, result_omission_reason FROM agent_tool_executions " + "WHERE execution_id='exec_seed_0'" ).fetchone() assert "▇" in row["content"] # 等 code point 遮蔽,保留坐标 assert delivery["recall_status"] == "recalled" @@ -170,7 +176,8 @@ async def test_clear_context_purges_loops_and_bumps_generation(tmp_path: Path): assert generation == 1 with store._connect() as conn: orphans = conn.execute( - "SELECT COUNT(*) c FROM agent_turns t LEFT JOIN agent_loops l ON l.loop_id=t.loop_id WHERE l.loop_id IS NULL" + "SELECT COUNT(*) c FROM agent_turns t " + "LEFT JOIN agent_loops l ON l.loop_id=t.loop_id WHERE l.loop_id IS NULL" ).fetchone()["c"] assert orphans == 0 # 侧表无孤儿(阶段 B 验收面) diff --git a/tests/integration/test_llm_mcp.py b/tests/integration/test_llm_mcp.py index e6af6b3b..83efb7a1 100644 --- a/tests/integration/test_llm_mcp.py +++ b/tests/integration/test_llm_mcp.py @@ -229,7 +229,10 @@ async def test_mcp_image_result_reaches_vision_provider_as_inline_bytes( ): stub = StubMCPToolCallingProviderClient() patch_provider_builder(lambda provider: stub) - image = LLMInlineImage(data=b"valid image bytes", media_type="image/png", source_label="MCP/fake/echo_text image 1") + image = LLMInlineImage( + data=b"valid image bytes", media_type="image/png", + source_label="MCP/fake/echo_text image 1", + ) async def fake_execute(alias, arguments, context): _ = alias, arguments, context diff --git a/tests/integration/test_llm_private.py b/tests/integration/test_llm_private.py index e6ca6ef5..e6d7fc5b 100644 --- a/tests/integration/test_llm_private.py +++ b/tests/integration/test_llm_private.py @@ -33,7 +33,9 @@ def test_private_status_reflects_session_off(configured_service): def test_private_memory_isolated_from_group(configured_service): - mid = configured_service.remember_memory(3003, "阿桃在私聊里更愿意长篇回复。", chat_type="private") + mid = configured_service.remember_memory( + 3003, "阿桃在私聊里更愿意长篇回复。", chat_type="private" + ) assert mid >= 1 private_memories = configured_service.list_memories(3003, chat_type="private") assert private_memories[0]["content"] == "阿桃在私聊里更愿意长篇回复。" @@ -109,12 +111,16 @@ async def test_private_scope_uses_same_epoch_mechanism(configured_service, monke monkeypatch.setattr(llm_runtime_module, "build_provider_client", lambda provider: stub) configured_service.start_private_session(3003) - await configured_service.generate_private_reply(user_id=3003, sender_name="阿桃", prompt="第一句") + await configured_service.generate_private_reply( + user_id=3003, sender_name="阿桃", prompt="第一句" + ) key = EpochKey(scope_key="private:3003", provider_id="openai-main", model="gpt-test") # 私聊与群聊同一纪元机制:首轮即懒初始化锚点 assert configured_service._epochs.current_anchor(key) is not None - await configured_service.generate_private_reply(user_id=3003, sender_name="阿桃", prompt="第二句") + await configured_service.generate_private_reply( + user_id=3003, sender_name="阿桃", prompt="第二句" + ) # 第二轮请求带上第一轮 history(只追加窗口) assert len(stub.last_request.messages) > 1 diff --git a/tests/integration/test_llm_search.py b/tests/integration/test_llm_search.py index 1557495e..2b000f06 100644 --- a/tests/integration/test_llm_search.py +++ b/tests/integration/test_llm_search.py @@ -99,7 +99,12 @@ def _grounding_response_data() -> dict: "groundingMetadata": { "webSearchQueries": ["QuickQuip 是什么"], "groundingChunks": [ - {"web": {"uri": "https://example.test/quickquip", "title": "QuickQuip README"}}, + { + "web": { + "uri": "https://example.test/quickquip", + "title": "QuickQuip README", + } + }, ], }, } diff --git a/tests/integration/test_llm_service.py b/tests/integration/test_llm_service.py index 67b1cce8..a92353d6 100644 --- a/tests/integration/test_llm_service.py +++ b/tests/integration/test_llm_service.py @@ -140,7 +140,9 @@ async def test_generate_reply_envelope_carries_time_for_cron_like_trigger( req = stub.last_request assert req is not None - assert re.search(r"当前时间:\d{4}-\d{2}-\d{2} 星期. \d{2}:\d{2}(北京时间)", req.messages[-1].content) + assert re.search( + r"当前时间:\d{4}-\d{2}-\d{2} 星期. \d{2}:\d{2}(北京时间)", req.messages[-1].content + ) assert "当前北京时间" not in req.system_prompt @@ -496,7 +498,9 @@ async def test_memory_crud_basic(wired_service): memories = wired_service.list_group_memories(1001) assert memories[0]["content"] == "阿桃喜欢薄荷糖。" - matched = wired_service.store.search_memories(1001, user_id=2002, query="阿桃喜欢什么?", limit=3) + matched = wired_service.store.search_memories( + 1001, user_id=2002, query="阿桃喜欢什么?", limit=3 + ) assert matched assert matched[0]["content"] == "阿桃喜欢薄荷糖。" @@ -795,7 +799,9 @@ async def test_auto_memory_per_chat_override_beats_global_default( # ── image preprocessor integration tests ────────────────────────────── -async def test_image_preprocessor_called_for_non_vision_model(wired_service, patch_provider_builder): +async def test_image_preprocessor_called_for_non_vision_model( + wired_service, patch_provider_builder +): from tests.fixtures.provider_stubs import StubImagePreprocessor, StubProviderClient wired_service.config.providers["openai-main"].non_vision_models.append("gpt-alt") stub_preprocessor = StubImagePreprocessor() @@ -892,7 +898,9 @@ async def test_vision_model_keeps_images_in_request(wired_service, patch_provide assert stub_preprocessor.call_count == 0 -async def test_non_vision_strips_even_when_preprocessor_fails(wired_service, patch_provider_builder): +async def test_non_vision_strips_even_when_preprocessor_fails( + wired_service, patch_provider_builder +): from tests.fixtures.provider_stubs import StubProviderClient from quickquip.llm.image_preprocessor import ImageDescription @@ -1334,7 +1342,9 @@ def spy(key, **kwargs): assert calls[0] == EpochKey(scope_key="1001", provider_id="openai-main", model="gpt-alt") -async def test_non_vision_persists_image_captions_in_raw_content(wired_service, patch_provider_builder): +async def test_non_vision_persists_image_captions_in_raw_content( + wired_service, patch_provider_builder +): """非 VLM 路径:图注以文本身份落库([图片 N 张:…]),下一轮 history 字节复现。""" from tests.fixtures.provider_stubs import StubImagePreprocessor @@ -1387,7 +1397,9 @@ async def test_vision_path_keeps_v1_raw_content(wired_service, patch_provider_bu assert stub_preprocessor.call_count == 0 -async def test_forward_captions_persist_byte_stable_across_turns(wired_service, patch_provider_builder): +async def test_forward_captions_persist_byte_stable_across_turns( + wired_service, patch_provider_builder +): """转发图注并入 normalized_forward_text:当轮渲染与落库同源,下轮 history 字节复现。""" from tests.fixtures.provider_stubs import StubImagePreprocessor @@ -1448,14 +1460,19 @@ async def test_recent_context_image_captions_not_persisted(wired_service, patch_ include_recent_images=True, ) # 当轮渲染:近期图注以带标签的视觉转述行出现(正确归属) - assert "stub description of https://example.test/other.png" in stub.requests[0].messages[-1].content + assert ( + "stub description of https://example.test/other.png" + in stub.requests[0].messages[-1].content + ) # 落库:触发者的 raw_turn 不含他人图注 stored = wired_service.store.list_recent_conversation_messages(1001, 10) raw = [r["raw_content"] for r in stored if r["role"] == "user"][0] assert raw == "纯文字触发" -async def test_media_meter_wired_with_attached_image_count(wired_service, patch_provider_builder, monkeypatch): +async def test_media_meter_wired_with_attached_image_count( + wired_service, patch_provider_builder, monkeypatch +): """媒体账本 service 接线:VLM 带图轮计 1;非 VLM 剥离后计 0(0 是有效信号)。""" import quickquip.llm.service as svc from quickquip.llm.usage import media_meter as real_media_meter @@ -1528,7 +1545,9 @@ async def test_scene_patch_self_served_with_history_dedup(wired_service, patch_p assert content.count("触发问题") == 1 -async def test_scene_patch_explicit_empty_list_disables_self_serve(wired_service, patch_provider_builder): +async def test_scene_patch_explicit_empty_list_disables_self_serve( + wired_service, patch_provider_builder +): """recent_messages=[] 是显式空(测试注入口语义),不触发自取。""" stub = _RecordingStub() patch_provider_builder(lambda provider: stub) @@ -1540,7 +1559,9 @@ async def test_scene_patch_explicit_empty_list_disables_self_serve(wired_service assert "【现场】" not in stub.requests[-1].messages[-1].content -async def test_scene_patch_incremental_across_turns(wired_service, patch_provider_builder, monkeypatch): +async def test_scene_patch_incremental_across_turns( + wired_service, patch_provider_builder, monkeypatch +): """跨轮增量:已服役且超出滑动保底窗的消息不再进入下一轮补丁。""" buf = RecentMessageBuffer(max_messages_per_group=20, ttl_seconds=3600) wired_service.bind_recent_message_buffer(buf) @@ -1636,7 +1657,8 @@ def test_synthetic_user_id_excluded_from_participants(llm_service): user_id="boredom_timer", sender_name="系统", history=[ - {"role": "user", "user_id": "boredom_timer", "sender_name": "系统", "canonical_name": ""}, + {"role": "user", "user_id": "boredom_timer", + "sender_name": "系统", "canonical_name": ""}, {"role": "user", "user_id": "2002", "sender_name": "乙", "canonical_name": "镜子"}, {"role": "assistant", "content": "reply"}, ], @@ -1656,7 +1678,9 @@ def test_synthetic_user_id_excluded_from_participants(llm_service): assert "无名氏" in names # 空 id 的名字回退不受过滤影响 -async def test_patch_meter_wired_with_scene_patch_tokens(wired_service, patch_provider_builder, monkeypatch): +async def test_patch_meter_wired_with_scene_patch_tokens( + wired_service, patch_provider_builder, monkeypatch +): """补丁账本三态:自取有货=正值;自取/显式空=0(有效信号,计入 coverage); 私聊(未自取)=None。""" import quickquip.llm.service as svc diff --git a/tests/integration/test_mcp_http_wire.py b/tests/integration/test_mcp_http_wire.py index afa56a13..431392e3 100644 --- a/tests/integration/test_mcp_http_wire.py +++ b/tests/integration/test_mcp_http_wire.py @@ -87,7 +87,8 @@ async def test_legacy_initialize_sends_correct_request_and_stores_session(): try: result = await session.request( "initialize", - {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, + {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, ) # notifications/initialized follows initialize (mirrors MCPClient._initialize) await session.notify("notifications/initialized", {}) @@ -120,7 +121,8 @@ async def test_legacy_session_id_is_reused_on_subsequent_requests(): try: await session.request( "initialize", - {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, + {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, ) await session.notify("notifications/initialized", {}) # Session-id should now be stored on the transport @@ -143,7 +145,8 @@ async def test_legacy_tools_list_pagination(): try: await session.request( "initialize", - {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, + {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, ) # Collect all tools via the MCPClient-style pagination loop @@ -182,7 +185,8 @@ async def test_legacy_tools_call_returns_text_content(): try: await session.request( "initialize", - {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, + {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, ) result = await session.request( "tools/call", @@ -204,7 +208,8 @@ async def test_legacy_sse_response_mode(): try: result = await session.request( "initialize", - {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, + {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, ) # SSE response still delivers the same result as JSON assert result["serverInfo"]["name"] == "legacy-test-server" @@ -255,7 +260,8 @@ async def test_legacy_requests_carry_no_modern_headers(): try: await session.request( "initialize", - {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, + {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "QuickQuip", "version": "1.0"}}, ) init_headers = server.requests[0]["headers"] assert "mcp-protocol-version" not in init_headers @@ -531,8 +537,13 @@ async def composite_app(scope, receive, send): async def slow_read(): async with client.stream( "POST", "/mcp", - content=json.dumps({"jsonrpc": "2.0", "id": 5, "method": "ping", "params": {}}).encode(), - headers={"Content-Type": "application/json", "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": "ping"}, + content=json.dumps( + {"jsonrpc": "2.0", "id": 5, "method": "ping", "params": {}} + ).encode(), + headers={ + "Content-Type": "application/json", + "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": "ping", + }, ) as response: async for line in response.aiter_lines(): pass @@ -577,7 +588,9 @@ def _asgi_client(config: MCPServerConfig, server: Any) -> MCPClient: client = MCPClient(config) transport = _AsgiHttpTransport(config, app=server) client._transport = transport - client._session = JsonRpcSession(transport, server_id=config.id, timeout_seconds=config.timeout_seconds) + client._session = JsonRpcSession( + transport, server_id=config.id, timeout_seconds=config.timeout_seconds + ) return client @@ -640,7 +653,9 @@ async def test_transport_404_without_session_is_not_stale(): async def always_404(scope, receive, send): if scope["type"] != "http": return - await send({"type": "http.response.start", "status": 404, "headers": [(b"content-length", b"0")]}) + await send( + {"type": "http.response.start", "status": 404, "headers": [(b"content-length", b"0")]} + ) await send({"type": "http.response.body", "body": b""}) config = _http_config() diff --git a/tests/integration/test_message_pipeline.py b/tests/integration/test_message_pipeline.py index 7e32330e..01934e39 100644 --- a/tests/integration/test_message_pipeline.py +++ b/tests/integration/test_message_pipeline.py @@ -109,8 +109,12 @@ async def test_build_reply_returns_plain_text(frozen_now): async def test_resolve_reply_none_for_unrelated_message(frozen_now): - assert await resolve_reply("今天天气不错", user_id=1, sender_name="测试用户", now=frozen_now) is None - assert await build_reply("今天天气不错", user_id=1, sender_name="测试用户", now=frozen_now) is None + assert await resolve_reply( + "今天天气不错", user_id=1, sender_name="测试用户", now=frozen_now + ) is None + assert await build_reply( + "今天天气不错", user_id=1, sender_name="测试用户", now=frozen_now + ) is None async def test_repeat_fingerprint_never_becomes_reply_text(): @@ -166,7 +170,9 @@ async def test_capture_rules_only_echo_safe_projected_text(group_id, text, rule_ async def test_rule_switch_blocks_when_group_id_given(frozen_now): global_rule_switch.disable(6001, "divine_arrival") - blocked = await resolve_reply("神临", user_id=123, sender_name="n", group_id=6001, now=frozen_now) + blocked = await resolve_reply( + "神临", user_id=123, sender_name="n", group_id=6001, now=frozen_now + ) assert blocked is None or blocked.get("rule_name") != "divine_arrival" diff --git a/tests/integration/test_scene_patch_budget.py b/tests/integration/test_scene_patch_budget.py index 55b68565..b61222dd 100644 --- a/tests/integration/test_scene_patch_budget.py +++ b/tests/integration/test_scene_patch_budget.py @@ -50,7 +50,9 @@ async def test_budget_rebuild_preserves_scene(scene_service, monkeypatch, budget service.store.append_conversation_message(1001, "3003", "user", f"old{index} " * 500) service.store.append_conversation_message(1001, None, "assistant", "answer " * 500) key = EpochKey("1001", "openai-main", "gpt-test") - service._epochs.maybe_advance(key, store=service.store, params=service.config.resolve_epoch_params()) + service._epochs.maybe_advance( + key, store=service.store, params=service.config.resolve_epoch_params() + ) before = service._epochs.current_anchor(key) result = await _reply(service) assert service._epochs.current_anchor(key) > before diff --git a/tests/unit/adapters/test_daily_briefing_plugin.py b/tests/unit/adapters/test_daily_briefing_plugin.py index 5e7f26f8..1ae75412 100644 --- a/tests/unit/adapters/test_daily_briefing_plugin.py +++ b/tests/unit/adapters/test_daily_briefing_plugin.py @@ -27,7 +27,9 @@ class FakeBot: send_group_msg = staticmethod(fake_send_group_msg) bot = FakeBot() - monkeypatch.setattr(daily_briefing_plugin, "_is_group_enabled", lambda group_id: group_id == "123456") + monkeypatch.setattr( + daily_briefing_plugin, "_is_group_enabled", lambda group_id: group_id == "123456" + ) monkeypatch.setattr(daily_briefing_plugin, "_on_cooldown", lambda group_id: False) monkeypatch.setattr(daily_briefing_plugin, "_mark_triggered", lambda group_id: None) monkeypatch.setattr(daily_briefing_plugin, "_render_briefing", fake_render) @@ -36,7 +38,9 @@ class FakeBot: async def before_generate(period): before_generate_calls.append(period) - result = await daily_briefing_plugin.send_daily_briefing_now("123456", "noon", bot, before_generate) + result = await daily_briefing_plugin.send_daily_briefing_now( + "123456", "noon", bot, before_generate + ) assert result == {"period": "noon", "model_used": "model-a", "char_count": len("briefing text")} assert rendered == [("123456", "noon")] diff --git a/tests/unit/adapters/test_daily_summary_plugin.py b/tests/unit/adapters/test_daily_summary_plugin.py index 894bc7d9..5bf47da8 100644 --- a/tests/unit/adapters/test_daily_summary_plugin.py +++ b/tests/unit/adapters/test_daily_summary_plugin.py @@ -68,11 +68,17 @@ async def fake_send_long_message(_bot, group_id, content): monkeypatch.setattr(daily_summary_plugin, "datetime", _FixedDateTime) monkeypatch.setattr(daily_summary_plugin, "daily_enabled_groups", _EnabledGroups()) - monkeypatch.setattr(daily_summary_plugin.chat_archive, "read_window", lambda *args, **kwargs: ["m1", "m2"]) + monkeypatch.setattr( + daily_summary_plugin.chat_archive, "read_window", lambda *args, **kwargs: ["m1", "m2"] + ) monkeypatch.setattr( daily_summary_plugin, "get_llm_service", - lambda: types.SimpleNamespace(config=types.SimpleNamespace(daily_summary=types.SimpleNamespace(min_messages=1))), + lambda: types.SimpleNamespace( + config=types.SimpleNamespace( + daily_summary=types.SimpleNamespace(min_messages=1) + ) + ), ) monkeypatch.setattr(daily_summary_plugin, "_on_cooldown", lambda group_id: False) monkeypatch.setattr(daily_summary_plugin, "_mark_triggered", lambda group_id: None) @@ -83,7 +89,9 @@ async def fake_send_long_message(_bot, group_id, content): async def before_generate(): before_generate_calls.append("called") - result = await daily_summary_plugin.send_daily_summary_now("123456", types.SimpleNamespace(), before_generate) + result = await daily_summary_plugin.send_daily_summary_now( + "123456", types.SimpleNamespace(), before_generate + ) assert result == {"model_used": "model-a", "char_count": len("summary text")} assert sent == [(123456, "summary text")] @@ -101,11 +109,17 @@ def contains(group_id): monkeypatch.setattr(daily_summary_plugin, "datetime", _FixedDateTime) monkeypatch.setattr(daily_summary_plugin, "daily_enabled_groups", _EnabledGroups()) - monkeypatch.setattr(daily_summary_plugin.chat_archive, "read_window", lambda *args, **kwargs: ["m1"]) + monkeypatch.setattr( + daily_summary_plugin.chat_archive, "read_window", lambda *args, **kwargs: ["m1"] + ) monkeypatch.setattr( daily_summary_plugin, "get_llm_service", - lambda: types.SimpleNamespace(config=types.SimpleNamespace(daily_summary=types.SimpleNamespace(min_messages=2))), + lambda: types.SimpleNamespace( + config=types.SimpleNamespace( + daily_summary=types.SimpleNamespace(min_messages=2) + ) + ), ) monkeypatch.setattr(daily_summary_plugin, "_on_cooldown", lambda group_id: False) monkeypatch.setattr(daily_summary_plugin, "_mark_triggered", lambda group_id: None) diff --git a/tests/unit/adapters/test_forward.py b/tests/unit/adapters/test_forward.py index 32eddc28..5540535d 100644 --- a/tests/unit/adapters/test_forward.py +++ b/tests/unit/adapters/test_forward.py @@ -87,7 +87,10 @@ async def test_extracts_from_reply_when_current_has_none(): bot = _StubBot({"fid_via_reply": _forward_payload()}) # Current message is the quote + @bot + user's question: no forward segment current = DummyMessage([at_seg("12345"), text_seg("你怎么看这个")]) - reply = DummyReply(message="[合并转发消息]", user_id="10001", sender=DummySender(nickname="Alice"), message_id="42") + reply = DummyReply( + message="[合并转发消息]", user_id="10001", + sender=DummySender(nickname="Alice"), message_id="42", + ) reply.raw_message = DummyMessage([forward_seg("fid_via_reply")]) text, images = await extract_forward_content( diff --git a/tests/unit/adapters/test_group_messages.py b/tests/unit/adapters/test_group_messages.py index 50a22e0b..2a0f3589 100644 --- a/tests/unit/adapters/test_group_messages.py +++ b/tests/unit/adapters/test_group_messages.py @@ -155,7 +155,9 @@ def __init__(self, monkeypatch, settings): monkeypatch.setattr(gm, "rate_limiter", self.rate_limiter) monkeypatch.setattr(gm, "rule_switch", FakeRuleSwitch()) monkeypatch.setattr(gm, "stats_tracker", FakeStats()) - monkeypatch.setattr(gm, "offline_message_store", SimpleNamespace(pop_pending=lambda g, u: None)) + monkeypatch.setattr( + gm, "offline_message_store", SimpleNamespace(pop_pending=lambda g, u: None) + ) monkeypatch.setattr(gm, "recent_messages", self.recent) monkeypatch.setattr(gm, "awakening_state", self.awakening_state) monkeypatch.setattr(gm, "record_chat_message", lambda *a, **k: None) @@ -232,7 +234,10 @@ def test_repeat_original_preserves_all_message_segment_types(): Message([MessageSegment.face(264), MessageSegment.text("晚安")]), [("face", {"id": "264"}), ("text", {"text": "晚"})], ), - (Message([MessageSegment.text("hello"), MessageSegment.face(264)]), [("text", {"text": "hello"})]), + ( + Message([MessageSegment.text("hello"), MessageSegment.face(264)]), + [("text", {"text": "hello"})], + ), ], ) def test_repeat_trim_removes_rightmost_content_unit(incoming, expected): @@ -278,7 +283,9 @@ def test_plain_rule_reply_cq_literal_stays_text(): async def test_passive_trigger_excludes_current_message_from_context(harness_factory): h = harness_factory() - h.awakening_state.bot_messages.add(100, "the Kubernetes deployment failed with ImagePullBackOff") + h.awakening_state.bot_messages.add( + 100, "the Kubernetes deployment failed with ImagePullBackOff" + ) _seed_recent(h, ["早上好", "今天吃什么", "周末去哪玩"]) event = DummyGroupEvent(DummyMessage([text_seg("Kubernetes ImagePullBackOff again?")])) @@ -312,7 +319,9 @@ async def test_voice_transcript_can_hit_passive_trigger(harness_factory): h.awakening_state.bot_messages.add(100, "Kubernetes ImagePullBackOff warnings") _seed_recent(h, ["早上好"]) - message = DummyMessage([record_seg("voice.silk", text="Kubernetes ImagePullBackOff 又 warnings 了吗")]) + message = DummyMessage( + [record_seg("voice.silk", text="Kubernetes ImagePullBackOff 又 warnings 了吗")] + ) await h.handle(DummyGroupEvent(message)) h.svc.quick_judge_detailed.assert_awaited_once() @@ -411,7 +420,9 @@ def boom(*args, **kwargs): h.svc.generate_reply.assert_not_awaited() -async def test_group_identity_index_used_for_at_rendering_and_mentioned_ids(harness_factory, monkeypatch): +async def test_group_identity_index_used_for_at_rendering_and_mentioned_ids( + harness_factory, monkeypatch +): """入口渲染用群合并身份索引:@ 已登记成员渲染标准身份、mentioned_qq_ids 随 prompt 传服务层。""" from quickquip.llm.identity import IdentityEntry, IdentityIndex diff --git a/tests/unit/adapters/test_history_quote.py b/tests/unit/adapters/test_history_quote.py index d3fe4934..e289c36e 100644 --- a/tests/unit/adapters/test_history_quote.py +++ b/tests/unit/adapters/test_history_quote.py @@ -23,7 +23,10 @@ def __init__(self, canonical_name: str): class _FakeIdentityIndex(IdentityIndex): def __init__(self, by_alias=None, canonical_by_uid=None): entries = [IdentityEntry(name, [qq]) for qq, name in (canonical_by_uid or {}).items()] - entries.extend(IdentityEntry(alias, list(value.qq_ids), [alias]) for alias, value in (by_alias or {}).items()) + entries.extend( + IdentityEntry(alias, list(value.qq_ids), [alias]) + for alias, value in (by_alias or {}).items() + ) super().__init__(entries=entries) self._build_indexes() @@ -33,7 +36,10 @@ def __init__(self, rows): self._rows = rows self.calls = [] - def search_by_sender(self, group_id, *, user_ids=(), name_pattern="", offset=0, limit=50, identity_snapshot=None): + def search_by_sender( + self, group_id, *, user_ids=(), name_pattern="", offset=0, limit=50, + identity_snapshot=None, + ): self.calls.append({"user_ids": list(user_ids), "name_pattern": name_pattern}) return [dict(r) for r in self._rows], len(self._rows) @@ -216,7 +222,9 @@ async def test_quote_by_no_match_reports_miss(monkeypatch): def test_quote_same_name_candidates_include_qq(monkeypatch): - index = IdentityIndex(entries=[IdentityEntry("同名", ["12345"]), IdentityEntry("同名", ["23456"])]) + index = IdentityIndex( + entries=[IdentityEntry("同名", ["12345"]), IdentityEntry("同名", ["23456"])] + ) index._build_indexes() monkeypatch.setattr(history, "get_sender_identity_sources", lambda group: ({}, index)) assert _resolve_sender_candidates(10001, "同名") == ["12345", "23456"] diff --git a/tests/unit/adapters/test_lifecycle.py b/tests/unit/adapters/test_lifecycle.py index 97801815..82fe894a 100644 --- a/tests/unit/adapters/test_lifecycle.py +++ b/tests/unit/adapters/test_lifecycle.py @@ -53,11 +53,15 @@ async def fake_close_persistent_stores(): fake_scheduler_module = types.ModuleType("nonebot_plugin_apscheduler") fake_scheduler_module.scheduler = types.SimpleNamespace(add_job=lambda *args, **kwargs: None) monkeypatch.setitem(sys.modules, "nonebot_plugin_apscheduler", fake_scheduler_module) - monkeypatch.setattr(lifecycle, "tieba_service", types.SimpleNamespace(shutdown=fake_tieba_shutdown)) + monkeypatch.setattr( + lifecycle, "tieba_service", types.SimpleNamespace(shutdown=fake_tieba_shutdown) + ) monkeypatch.setattr( lifecycle, "get_llm_service", - lambda: types.SimpleNamespace(shutdown=fake_llm_shutdown, startup=lambda *args, **kwargs: None), + lambda: types.SimpleNamespace( + shutdown=fake_llm_shutdown, startup=lambda *args, **kwargs: None + ), ) monkeypatch.setattr(lifecycle, "save_all", fake_save_all) monkeypatch.setattr(lifecycle, "close_persistent_stores", fake_close_persistent_stores) @@ -82,12 +86,18 @@ def test_reload_if_changed_watches_awakening_config(monkeypatch, tmp_path): calls: list[str] = [] monkeypatch.setattr(lifecycle, "RULE_SWITCH_PATH", rule_path) monkeypatch.setattr(lifecycle, "CONFIG_AWAKENING_TOML", awakening_path) - monkeypatch.setattr(lifecycle.rule_switch, "load", lambda path: calls.append(f"rule:{Path(path).name}")) - monkeypatch.setattr(lifecycle, "reload_awakening_and_reschedule", lambda: calls.append("awakening")) + monkeypatch.setattr( + lifecycle.rule_switch, "load", lambda path: calls.append(f"rule:{Path(path).name}") + ) + monkeypatch.setattr( + lifecycle, "reload_awakening_and_reschedule", lambda: calls.append("awakening") + ) monkeypatch.setattr(lifecycle.daily_enabled_groups, "path", daily_path) monkeypatch.setattr(lifecycle.daily_enabled_groups, "load", lambda: calls.append("daily")) monkeypatch.setattr(lifecycle.daily_briefing_enabled_groups, "path", briefing_path) - monkeypatch.setattr(lifecycle.daily_briefing_enabled_groups, "load", lambda: calls.append("briefing")) + monkeypatch.setattr( + lifecycle.daily_briefing_enabled_groups, "load", lambda: calls.append("briefing") + ) monkeypatch.setattr(lifecycle.boredom_enabled_groups, "path", boredom_path) monkeypatch.setattr(lifecycle.boredom_enabled_groups, "load", lambda: calls.append("boredom")) @@ -111,18 +121,25 @@ def test_reload_if_changed_watches_period_report_groups(monkeypatch, tmp_path): boredom_path = tmp_path / "boredom.json" weekly_path = tmp_path / "weekly.json" monthly_path = tmp_path / "monthly.json" - for path in (rule_path, awakening_path, daily_path, briefing_path, boredom_path, weekly_path, monthly_path): + for path in ( + rule_path, awakening_path, daily_path, briefing_path, + boredom_path, weekly_path, monthly_path, + ): path.write_text("{}", encoding="utf-8") calls: list[str] = [] monkeypatch.setattr(lifecycle, "RULE_SWITCH_PATH", rule_path) monkeypatch.setattr(lifecycle, "CONFIG_AWAKENING_TOML", awakening_path) monkeypatch.setattr(lifecycle.rule_switch, "load", lambda path: calls.append("rule")) - monkeypatch.setattr(lifecycle, "reload_awakening_and_reschedule", lambda: calls.append("awakening")) + monkeypatch.setattr( + lifecycle, "reload_awakening_and_reschedule", lambda: calls.append("awakening") + ) monkeypatch.setattr(lifecycle.daily_enabled_groups, "path", daily_path) monkeypatch.setattr(lifecycle.daily_enabled_groups, "load", lambda: calls.append("daily")) monkeypatch.setattr(lifecycle.daily_briefing_enabled_groups, "path", briefing_path) - monkeypatch.setattr(lifecycle.daily_briefing_enabled_groups, "load", lambda: calls.append("briefing")) + monkeypatch.setattr( + lifecycle.daily_briefing_enabled_groups, "load", lambda: calls.append("briefing") + ) monkeypatch.setattr(lifecycle.boredom_enabled_groups, "path", boredom_path) monkeypatch.setattr(lifecycle.boredom_enabled_groups, "load", lambda: calls.append("boredom")) monkeypatch.setattr(lifecycle.weekly_enabled_groups, "path", weekly_path) diff --git a/tests/unit/adapters/test_llm_delivery_commands.py b/tests/unit/adapters/test_llm_delivery_commands.py index 7d99b076..fc0b4b41 100644 --- a/tests/unit/adapters/test_llm_delivery_commands.py +++ b/tests/unit/adapters/test_llm_delivery_commands.py @@ -72,7 +72,11 @@ def get_chat_settings(self, chat_id, chat_type: str = "group"): return self.settings def set_chat_agent_delivery_enabled( - self, chat_id, enabled, chat_type: str = "group", domain: DeliveryDomain = DeliveryDomain.ALL + self, + chat_id, + enabled, + chat_type: str = "group", + domain: DeliveryDomain = DeliveryDomain.ALL, ) -> None: self.calls.append((enabled, chat_type, domain)) @@ -86,7 +90,9 @@ def set_chat_agent_delivery_enabled( def _register(service) -> _FakeLlmCmd: cmd = _FakeLlmCmd() - llm_part.register_llm_commands(lambda name, **kw: (cmd if name == "llm" else _FakeLlmCmd()), list, _SEG) + llm_part.register_llm_commands( + lambda name, **kw: (cmd if name == "llm" else _FakeLlmCmd()), list, _SEG + ) return cmd diff --git a/tests/unit/adapters/test_record_commands.py b/tests/unit/adapters/test_record_commands.py index e4afe11e..0eb49727 100644 --- a/tests/unit/adapters/test_record_commands.py +++ b/tests/unit/adapters/test_record_commands.py @@ -50,12 +50,22 @@ def snapshot(monkeypatch): async def test_screenshot_remember_and_literal_cq(tmp_path, monkeypatch, snapshot): store = LLMStore(tmp_path / "llm.db") - svc = SimpleNamespace(remember_memory=lambda group, content, **kw: store.add_memory(group, content, content_parts=kw["content_parts"])) + svc = SimpleNamespace( + remember_memory=lambda group, content, **kw: store.add_memory( + group, content, content_parts=kw["content_parts"] + ) + ) monkeypatch.setattr(memory, "_ensure_llm_bindings", lambda: None) monkeypatch.setattr(memory, "get_llm_service", lambda: svc) monkeypatch.setattr(memory, "_allow_scope_management", lambda event: True) - message = Message([MessageSegment.text("/remember "), MessageSegment("at", {"qq": "12345", "name": "旧名"}), MessageSegment.text(" 喜欢编程")]) - event = SimpleNamespace(group_id=10001, user_id=99999, message_type="group", get_message=lambda: message) + message = Message( + [MessageSegment.text("/remember "), + MessageSegment("at", {"qq": "12345", "name": "旧名"}), + MessageSegment.text(" 喜欢编程")] + ) + event = SimpleNamespace( + group_id=10001, user_id=99999, message_type="group", get_message=lambda: message + ) matcher = register(memory.register_memory_commands)["remember"] with pytest.raises(Finished): await matcher.handlers[0](event) @@ -68,9 +78,17 @@ async def test_screenshot_remember_and_literal_cq(tmp_path, monkeypatch, snapsho async def test_screenshot_quote_name_fallback_and_command_retained(tmp_path, monkeypatch, snapshot): store = GroupQuoteStore(tmp_path / "quotes.db") monkeypatch.setattr(history, "group_quote_store", store) - monkeypatch.setattr(history, "get_sender_identity_sources", lambda scope: (snapshot.names, snapshot.index)) - reply = SimpleNamespace(user_id="12345", sender=SimpleNamespace(nickname="旧作者", card=""), message="/remember [CQ:at,name=可用名称,qq=23456,extra=ok] [CQ:image,file=x]") - event = SimpleNamespace(group_id=10001, user_id=99999, self_id=88888, message_type="group", reply=reply, get_message=lambda: Message("/quote")) + monkeypatch.setattr( + history, "get_sender_identity_sources", lambda scope: (snapshot.names, snapshot.index) + ) + reply = SimpleNamespace( + user_id="12345", sender=SimpleNamespace(nickname="旧作者", card=""), + message="/remember [CQ:at,name=可用名称,qq=23456,extra=ok] [CQ:image,file=x]" + ) + event = SimpleNamespace( + group_id=10001, user_id=99999, self_id=88888, message_type="group", + reply=reply, get_message=lambda: Message("/quote"), + ) matcher = register(history.register_history_commands)["quote"] with pytest.raises(Finished): await matcher.handlers[0](event) @@ -94,7 +112,11 @@ async def test_screenshot_quote_name_fallback_and_command_retained(tmp_path, mon async def test_quote_pure_media_rejected(tmp_path, monkeypatch, snapshot): store = GroupQuoteStore(tmp_path / "quotes.db") monkeypatch.setattr(history, "group_quote_store", store) - event = SimpleNamespace(group_id=10001, user_id=99999, message_type="group", reply=SimpleNamespace(user_id="12345", message="[CQ:image,file=x]"), get_message=lambda: Message("/quote")) + event = SimpleNamespace( + group_id=10001, user_id=99999, message_type="group", + reply=SimpleNamespace(user_id="12345", message="[CQ:image,file=x]"), + get_message=lambda: Message("/quote"), + ) matcher = register(history.register_history_commands)["quote"] with pytest.raises(Finished): await matcher.handlers[0](event) @@ -105,7 +127,11 @@ async def test_quote_pure_media_rejected(tmp_path, monkeypatch, snapshot): async def test_quote_trailing_whitespace_limit_returns_feedback(tmp_path, monkeypatch, snapshot): store = GroupQuoteStore(tmp_path / "quotes.db") monkeypatch.setattr(history, "group_quote_store", store) - event = SimpleNamespace(group_id=10001, user_id=99999, message_type="group", reply=SimpleNamespace(user_id="12345", message="字" * 500 + " " * 5), get_message=lambda: Message("/quote")) + event = SimpleNamespace( + group_id=10001, user_id=99999, message_type="group", + reply=SimpleNamespace(user_id="12345", message="字" * 500 + " " * 5), + get_message=lambda: Message("/quote"), + ) matcher = register(history.register_history_commands)["quote"] with pytest.raises(Finished): await matcher.handlers[0](event) @@ -127,7 +153,10 @@ def filter_in_thread(*args): {"user_id": "12345", "sender": "旧名", "text": "普通发言", "ts": 1}, {"user_id": "23456", "sender": "某人", "text": "[CQ:at,qq=12345]", "ts": 2}, ])) - event = SimpleNamespace(group_id=10001, user_id=99999, message_type="group", get_message=lambda: Message("/find 标准名")) + event = SimpleNamespace( + group_id=10001, user_id=99999, message_type="group", + get_message=lambda: Message("/find 标准名"), + ) matcher = register(history.register_history_commands)["find"] with pytest.raises(Finished): await matcher.handlers[0](event) diff --git a/tests/unit/adapters/test_web_admin_actions.py b/tests/unit/adapters/test_web_admin_actions.py index 2c5b2a1d..41b0475b 100644 --- a/tests/unit/adapters/test_web_admin_actions.py +++ b/tests/unit/adapters/test_web_admin_actions.py @@ -45,8 +45,14 @@ def _fake_reload_and_reschedule(): calls.append("awakening+reschedule") return 300 - monkeypatch.setattr(awakening_plugin, "reload_awakening_and_reschedule", _fake_reload_and_reschedule) - monkeypatch.setattr(web_admin_actions, "reload_chat_rules_pipeline", lambda: calls.append("rules") or {"rules": 1}) + monkeypatch.setattr( + awakening_plugin, "reload_awakening_and_reschedule", _fake_reload_and_reschedule + ) + monkeypatch.setattr( + web_admin_actions, + "reload_chat_rules_pipeline", + lambda: calls.append("rules") or {"rules": 1}, + ) result = await web_admin_actions.execute_web_admin_action( WebAdminAction( diff --git a/tests/unit/chat/test_awakening.py b/tests/unit/chat/test_awakening.py index 79deed12..731534bc 100644 --- a/tests/unit/chat/test_awakening.py +++ b/tests/unit/chat/test_awakening.py @@ -539,7 +539,9 @@ def test_prompt_mentions_images_only_when_selected(self): assert "这条触发消息包含图片" in with_image assert "不要编造具体图像细节" in with_image assert "这条触发消息包含图片" not in without_image - assert build_passive_trigger_raw_user_text(result, ["https://example.test/a.png"]) == "[图片] 这是什么?" + assert build_passive_trigger_raw_user_text( + result, ["https://example.test/a.png"] + ) == "[图片] 这是什么?" def test_raw_user_text_preserves_voice_transcript(self): voice_only = AwakeningTriggerResult( @@ -933,7 +935,9 @@ def test_business_false_caches_false(self): svc.quick_judge_detailed = AsyncMock(return_value=_qj('{"score": 0.2}')) result, s = self._run_relevance(svc) assert result is None - assert s.llm_cache_get(_RULE_RELEVANCE, "g1", llm_cache_text("今天天气怎么样", 0.5)) is False + assert s.llm_cache_get( + _RULE_RELEVANCE, "g1", llm_cache_text("今天天气怎么样", 0.5) + ) is False def _assert_technical_failure(self, svc): result, s = self._run_relevance(svc) @@ -1005,7 +1009,9 @@ def test_qa_technical_failure_no_cache(self): check_qa("g1", "请问怎么解决这个问题?", settings, svc, s) ) assert result is None - assert s.llm_cache_get(_RULE_QA, "g1", llm_cache_text("请问怎么解决这个问题?", 0.5)) is None + assert s.llm_cache_get( + _RULE_QA, "g1", llm_cache_text("请问怎么解决这个问题?", 0.5) + ) is None def test_strict_parse_distinguishes_false_from_garbage(self): assert _parse_judge_text('{"trigger": false}', 0.5) is False @@ -1273,7 +1279,9 @@ async def _drive_boredom_send( chat 层只产出待发送计划;传输(``int(gid)`` 转换与消息拼装)归发送方, 成功后 ``confirm_boredom_sent`` 确认;send 异常按 adapter 语义记 warning 后吞掉继续。 """ - async for plan in iter_boredom_send_plans(groups, rule_switch, svc, rate_limiter, config=config): + async for plan in iter_boredom_send_plans( + groups, rule_switch, svc, rate_limiter, config=config + ): try: await bot.send_group_msg( group_id=int(plan.group_id), diff --git a/tests/unit/chat/test_chain_game.py b/tests/unit/chat/test_chain_game.py index fe1694e9..6acda96e 100644 --- a/tests/unit/chat/test_chain_game.py +++ b/tests/unit/chat/test_chain_game.py @@ -7,7 +7,9 @@ class TestFullCapture: def test_start_and_progress(self): - cg = ChainGameManager([make_chain_def("full_group", r"^来一个(.+)$", ["好的", "$1", "666"])]) + cg = ChainGameManager( + [make_chain_def("full_group", r"^来一个(.+)$", ["好的", "$1", "666"])] + ) r = cg.process(group_id=1, text="来一个哈哈哈", now_ts=0) assert r is not None and r["reply"] == "好的" assert r["rule_name"] == "full_group_start" @@ -16,7 +18,9 @@ def test_start_and_progress(self): assert r["rule_name"] == "full_group_progress" def test_session_ends_after_odd_chain(self): - cg = ChainGameManager([make_chain_def("full_group", r"^来一个(.+)$", ["好的", "$1", "666"])]) + cg = ChainGameManager( + [make_chain_def("full_group", r"^来一个(.+)$", ["好的", "$1", "666"])] + ) cg.process(group_id=1, text="来一个哈哈哈", now_ts=0) cg.process(group_id=1, text="哈哈哈", now_ts=1) assert cg.process(group_id=1, text="哈哈哈", now_ts=2) is None @@ -42,20 +46,26 @@ def test_second_char(self): class TestChainShape: def test_multi_character_token(self): - cg = ChainGameManager([make_chain_def("multi_tok", r"^(.+)发车$", ["上车了", "准备好了", "出发!"])]) + cg = ChainGameManager( + [make_chain_def("multi_tok", r"^(.+)发车$", ["上车了", "准备好了", "出发!"])] + ) assert cg.process(group_id=5, text="快速发车", now_ts=0)["reply"] == "上车了" assert cg.process(group_id=5, text="准备好了", now_ts=1)["reply"] == "出发!" assert cg.process(group_id=5, text="准备好了", now_ts=2) is None def test_even_length_with_stop_token(self): - cg = ChainGameManager([make_chain_def("even_chain", r"^(.+)启动$", ["准备", "就绪", "冲", "STOP"])]) + cg = ChainGameManager( + [make_chain_def("even_chain", r"^(.+)启动$", ["准备", "就绪", "冲", "STOP"])] + ) assert cg.process(group_id=6, text="快速启动", now_ts=0)["reply"] == "准备" assert cg.process(group_id=6, text="就绪", now_ts=1)["reply"] == "冲" assert cg.process(group_id=6, text="STOP", now_ts=2) is None assert cg.process(group_id=6, text="就绪", now_ts=3) is None def test_stop_token_ends_session_early(self): - cg = ChainGameManager([make_chain_def("early_stop", r"^(.+)启动$", ["准备", "就绪", "冲", "STOP"])]) + cg = ChainGameManager( + [make_chain_def("early_stop", r"^(.+)启动$", ["准备", "就绪", "冲", "STOP"])] + ) cg.process(group_id=7, text="快速启动", now_ts=0) assert cg.process(group_id=7, text="STOP", now_ts=1) is None assert cg.process(group_id=7, text="就绪", now_ts=2) is None @@ -69,7 +79,9 @@ def test_noise_does_not_break_chain(self): assert cg.process(group_id=8, text="开始", now_ts=2)["reply"] == "完成" def test_timeout_invalidates_session(self): - cg = ChainGameManager([make_chain_def("timeout", r"^(.+)准备$", ["好", "开始", "完成"], timeout=5)]) + cg = ChainGameManager( + [make_chain_def("timeout", r"^(.+)准备$", ["好", "开始", "完成"], timeout=5)] + ) cg.process(group_id=9, text="ABC准备", now_ts=0) assert cg.process(group_id=9, text="开始", now_ts=6) is None @@ -97,7 +109,9 @@ def test_chaingamedef_from_dict(): class TestOrCandidates: def test_each_alternative_matches(self): - cg = ChainGameManager([make_chain_def("or_test", r"^(.+)出发$", ["准备", "就绪|ready|OK", "出发!"])]) + cg = ChainGameManager( + [make_chain_def("or_test", r"^(.+)出发$", ["准备", "就绪|ready|OK", "出发!"])] + ) assert cg.process(group_id=40, text="快速出发", now_ts=0)["reply"] == "准备" assert cg.process(group_id=40, text="就绪", now_ts=1)["reply"] == "出发!" @@ -108,7 +122,9 @@ def test_each_alternative_matches(self): assert cg.process(group_id=42, text="OK", now_ts=1)["reply"] == "出发!" def test_non_candidate_ignored_session_survives(self): - cg = ChainGameManager([make_chain_def("or_test", r"^(.+)出发$", ["准备", "就绪|ready|OK", "出发!"])]) + cg = ChainGameManager( + [make_chain_def("or_test", r"^(.+)出发$", ["准备", "就绪|ready|OK", "出发!"])] + ) assert cg.process(group_id=43, text="快速出发", now_ts=0)["reply"] == "准备" assert cg.process(group_id=43, text="差不多得了", now_ts=1) is None assert cg.process(group_id=43, text="OK", now_ts=2)["reply"] == "出发!" diff --git a/tests/unit/chat/test_chat_archive.py b/tests/unit/chat/test_chat_archive.py index f02ddc69..5d2b8fce 100644 --- a/tests/unit/chat/test_chat_archive.py +++ b/tests/unit/chat/test_chat_archive.py @@ -113,9 +113,13 @@ def test_unavailable_archive_retries_with_monotonic_clock(tmp_path: Path, monkey clock = [1.0] monkeypatch.setattr(archive_module, "monotonic", lambda: clock[0]) - with patch.object(ChatArchive, "_connect", side_effect=sqlite3.OperationalError("database is locked")): + with patch.object( + ChatArchive, "_connect", side_effect=sqlite3.OperationalError("database is locked") + ): archive = ChatArchive(tmp_path / "a.db") - assert archive.record_result("10001", "n", "暂时不可用", message_id="m1") is RecordResult.FAILED + assert archive.record_result( + "10001", "n", "暂时不可用", message_id="m1" + ) is RecordResult.FAILED # The first failed retry starts the cooldown; the hot path remains fail-soft. assert archive.record("10001", "n", "冷却中", message_id="m2") is False clock[0] = 62.0 @@ -128,7 +132,9 @@ def test_record_result_reports_connection_failure(tmp_path: Path): from unittest.mock import patch archive = ChatArchive(tmp_path / "a.db") - with patch.object(archive, "_connect", side_effect=sqlite3.OperationalError("database is locked")): + with patch.object( + archive, "_connect", side_effect=sqlite3.OperationalError("database is locked") + ): assert archive.record_result("10001", "n", "未写入", message_id="m1") is RecordResult.FAILED assert archive.read_all("10001") == [] diff --git a/tests/unit/chat/test_daily_briefing.py b/tests/unit/chat/test_daily_briefing.py index 3f9e11b4..0b7135b7 100644 --- a/tests/unit/chat/test_daily_briefing.py +++ b/tests/unit/chat/test_daily_briefing.py @@ -203,8 +203,14 @@ async def test_briefing_context_excludes_bot_rows(tmp_path: Path, briefing_confi yesterday = [ (datetime(2026, 4, 14, 9, 0, tzinfo=LOCAL_TZ), "1001", "张三", "群友话题甲"), - (datetime(2026, 4, 14, 10, 0, tzinfo=LOCAL_TZ), "1002", "QuickQuip", "bot 刷屏词汇填充填充填充"), - (datetime(2026, 4, 14, 11, 0, tzinfo=LOCAL_TZ), "1002", "QuickQuip", "bot 刷屏词汇填充填充填充"), + ( + datetime(2026, 4, 14, 10, 0, tzinfo=LOCAL_TZ), + "1002", "QuickQuip", "bot 刷屏词汇填充填充填充", + ), + ( + datetime(2026, 4, 14, 11, 0, tzinfo=LOCAL_TZ), + "1002", "QuickQuip", "bot 刷屏词汇填充填充填充", + ), (datetime(2026, 4, 14, 12, 0, tzinfo=LOCAL_TZ), "1001", "张三", "群友话题乙"), ] for ts, user_id, sender, text in yesterday: diff --git a/tests/unit/chat/test_group_quotes.py b/tests/unit/chat/test_group_quotes.py index c00e502b..0c62557b 100644 --- a/tests/unit/chat/test_group_quotes.py +++ b/tests/unit/chat/test_group_quotes.py @@ -88,7 +88,9 @@ def test_random_falls_back_after_all_quotes_seen(tmp_path, clock): def test_recent_random_window_expires(tmp_path, clock): - store = GroupQuoteStore(tmp_path / "quotes.db", recent_random_window_seconds=10, time_func=clock) + store = GroupQuoteStore( + tmp_path / "quotes.db", recent_random_window_seconds=10, time_func=clock + ) for i in range(2): store.add("g1", "u1", "A", f"quote {i}", "u2") diff --git a/tests/unit/chat/test_period_serializer.py b/tests/unit/chat/test_period_serializer.py index b6344fad..4a493e55 100644 --- a/tests/unit/chat/test_period_serializer.py +++ b/tests/unit/chat/test_period_serializer.py @@ -163,7 +163,11 @@ def test_line_part_cap_splits_bare_continuation(): def test_url_replaced_by_domain(): messages = [ _msg("甲", "看 https://www.bilibili.com/video/BV1xx 很好", _ts(ss=0)), - _msg("甲", "还有 http://Github.com/a/b?c=1 和 https://news.ycombinator.com/item?id=1", _ts(ss=1)), + _msg( + "甲", + "还有 http://Github.com/a/b?c=1 和 https://news.ycombinator.com/item?id=1", + _ts(ss=1), + ), ] text, stats = serialize_period_chat(messages, local_tz=TZ) diff --git a/tests/unit/chat/test_record_identities.py b/tests/unit/chat/test_record_identities.py index 3bd85778..caa60a82 100644 --- a/tests/unit/chat/test_record_identities.py +++ b/tests/unit/chat/test_record_identities.py @@ -10,10 +10,16 @@ def test_quote_pagination_mentions_and_author_separate(tmp_path, snapshot): store = GroupQuoteStore(tmp_path / "quotes.db") for i in range(7): - store.add("10001", "23456", "旧作者", str(i), "99999", content_parts=legacy(f"[CQ:at,qq=12345,name=旧名] {i}")) + store.add( + "10001", "23456", "旧作者", str(i), "99999", + content_parts=legacy(f"[CQ:at,qq=12345,name=旧名] {i}"), + ) store.add("10001", "12345", "旧作者", "没有提及", "99999") with store._db: - store._db.execute("UPDATE quotes SET content_parts_json=NULL, content='[CQ:at,qq=12345,name=旧名] 0' WHERE id=1") + store._db.execute( + "UPDATE quotes SET content_parts_json=NULL, " + "content='[CQ:at,qq=12345,name=旧名] 0' WHERE id=1" + ) rows, total = store.search("10001", "标准名", offset=2, limit=2) assert total == 7 and [r["id"] for r in rows] == [5, 4] assert all("@标准名" in r["content_display"] for r in rows) @@ -26,7 +32,10 @@ def test_quote_pagination_mentions_and_author_separate(tmp_path, snapshot): def test_offline_retains_mentions_and_plain_display(tmp_path, snapshot): store = OfflineMessageStore(tmp_path / "offline.db") - store.add("10001", "12345", "旧发送者", "23456", "", content_parts=legacy("[CQ:at,qq=12345] [CQ:at,qq=all] [CQ:record,file=x]")) + store.add( + "10001", "12345", "旧发送者", "23456", "", + content_parts=legacy("[CQ:at,qq=12345] [CQ:at,qq=all] [CQ:record,file=x]"), + ) pending = store.list_pending_for("10001", "23456") assert "[标准名 " in pending[0].format_display() assert "@标准名 @全体成员 [语音]" in pending[0].format_display() diff --git a/tests/unit/chat/test_reply_probability.py b/tests/unit/chat/test_reply_probability.py index 9bb43c5d..e4836a5f 100644 --- a/tests/unit/chat/test_reply_probability.py +++ b/tests/unit/chat/test_reply_probability.py @@ -70,13 +70,17 @@ def test_resolve_defaults_to_always_reply(restore_chat_rules): def test_key_level_probability_used_as_fallback(restore_chat_rules): - chat_config.RATE_LIMIT_RULES["prob_key"] = {"global_limit": 1, "user_limit": 1, "probability": 0.25} + chat_config.RATE_LIMIT_RULES["prob_key"] = { + "global_limit": 1, "user_limit": 1, "probability": 0.25 + } assert resolve_probability("prob_key") == 0.25 assert resolve_probability("prob_key", {"name": "x"}) == 0.25 def test_rule_level_overrides_key_level(restore_chat_rules): - chat_config.RATE_LIMIT_RULES["prob_key"] = {"global_limit": 1, "user_limit": 1, "probability": 0.25} + chat_config.RATE_LIMIT_RULES["prob_key"] = { + "global_limit": 1, "user_limit": 1, "probability": 0.25 + } rule = {"name": "x", "probability": 0.75} assert resolve_probability("prob_key", rule) == 0.75 @@ -589,11 +593,17 @@ def test_matcher_suppress_scoped_per_group(restore_chat_rules, frozen_now): } ] ) - assert match_text_rule("你好", user_id=1, sender_name="n", now=frozen_now, group_id=1001) is not None + assert match_text_rule( + "你好", user_id=1, sender_name="n", now=frozen_now, group_id=1001 + ) is not None # 同群第二次被防连发压制 → 无候选规则 - assert match_text_rule("你好", user_id=1, sender_name="n", now=frozen_now, group_id=1001) is None + assert match_text_rule( + "你好", user_id=1, sender_name="n", now=frozen_now, group_id=1001 + ) is None # 另一个群不受影响 - assert match_text_rule("你好", user_id=1, sender_name="n", now=frozen_now, group_id=1002) is not None + assert match_text_rule( + "你好", user_id=1, sender_name="n", now=frozen_now, group_id=1002 + ) is not None # ── example 推荐默认值(直接解析文件,不依赖运行时容器)────── diff --git a/tests/unit/chat/test_scheduled_messages.py b/tests/unit/chat/test_scheduled_messages.py index faf5acae..d1506e51 100644 --- a/tests/unit/chat/test_scheduled_messages.py +++ b/tests/unit/chat/test_scheduled_messages.py @@ -49,7 +49,10 @@ def test_invalid_entries_skipped(tmp_path): "jobs": [ {"id": "sm_good", "cron": "0 7 * * *", "group_ids": ["123"], "message": "好"}, {"id": "", "cron": "0 7 * * *", "group_ids": ["123"], "message": "无 id"}, - {"id": "sm_bad", "cron": "not-a-cron", "group_ids": ["123"], "message": "坏 cron"}, + { + "id": "sm_bad", "cron": "not-a-cron", + "group_ids": ["123"], "message": "坏 cron", + }, "not-a-dict", ] } diff --git a/tests/unit/common/test_bot_action_trace.py b/tests/unit/common/test_bot_action_trace.py index eb2c2c94..d54f7a2e 100644 --- a/tests/unit/common/test_bot_action_trace.py +++ b/tests/unit/common/test_bot_action_trace.py @@ -83,7 +83,9 @@ def test_payload_summarizes_forward_message_types_without_content(): def test_overlay_ignores_unknown_fields(): with bot_action_trace(trigger_kind="command", reason_code="command.demo"): - with overlay_bot_action_trace(reason_code="command.specific", unknown_field="ignored") as trace: + with overlay_bot_action_trace( + reason_code="command.specific", unknown_field="ignored" + ) as trace: assert trace.reason_code == "command.specific" assert not hasattr(trace, "unknown_field") @@ -128,7 +130,10 @@ def on_called_api(cls, func): def test_log_bot_action_trace_returns_payload(monkeypatch): messages = [] - monkeypatch.setattr("quickquip.common.bot_action_trace._logger.info", lambda *args: messages.append(args)) + monkeypatch.setattr( + "quickquip.common.bot_action_trace._logger.info", + lambda *args: messages.append(args), + ) payload = log_bot_action_trace(api="send_msg", data={"message": "hello"}) diff --git a/tests/unit/common/test_identity_sources.py b/tests/unit/common/test_identity_sources.py index 34533b4b..4708de51 100644 --- a/tests/unit/common/test_identity_sources.py +++ b/tests/unit/common/test_identity_sources.py @@ -8,20 +8,22 @@ # 与部署分发的全局模板同形态:people 段只有一个未填写的占位条目, # special_accounts 整段处于注释状态。 -_PLACEHOLDER_TEMPLATE = """# ── QuickQuip 标准身份词表 ────────────────────────────────────────────── -# 复制为 identities.yaml 后按你的群编辑。 - -people: - - canonical_name: - qq_ids: - - "" - aliases: - note: - -# special_accounts: -# - qq_id: "1000000000" -# canonical_name: Bot -""" +_PLACEHOLDER_TEMPLATE = ( + "# ── QuickQuip 标准身份词表 ──────────────────────────────────────────────\n" + "# 复制为 identities.yaml 后按你的群编辑。\n" + "\n" + "people:\n" + " - canonical_name:\n" + " qq_ids:\n" + ' - ""\n' + " aliases:\n" + " note:\n" + "\n" + "# special_accounts:\n" + '# - qq_id: "1000000000"\n' + "# canonical_name: Bot\n" + "" +) def test_load_index_accepts_placeholder_only_template(tmp_path: Path): diff --git a/tests/unit/common/test_recent_message_buffer.py b/tests/unit/common/test_recent_message_buffer.py index 38854a05..1db33a9c 100644 --- a/tests/unit/common/test_recent_message_buffer.py +++ b/tests/unit/common/test_recent_message_buffer.py @@ -34,7 +34,10 @@ def test_group_isolation(): def test_image_urls_round_trip(): buf = RecentMessageBuffer(max_messages_per_group=20, ttl_seconds=60) - buf.add_message(1, "u1", "a", "A", "看这张图", image_urls=["http://x/1.png", "http://x/2.png"], now_ts=0) + buf.add_message( + 1, "u1", "a", "A", "看这张图", + image_urls=["http://x/1.png", "http://x/2.png"], now_ts=0, + ) buf.add_message(1, "u2", "b", "B", "纯文字", now_ts=1) recent = buf.list_recent(1, now_ts=2) assert recent[0]["image_urls"] == ["http://x/1.png", "http://x/2.png"] diff --git a/tests/unit/common/test_record_content.py b/tests/unit/common/test_record_content.py index 2504fd35..0ff8a896 100644 --- a/tests/unit/common/test_record_content.py +++ b/tests/unit/common/test_record_content.py @@ -6,7 +6,14 @@ from quickquip.app.identities import IdentityRepository, IdentitySnapshot from quickquip.common.identity import IdentityEntry -from quickquip.common.record_content import from_segments, legacy, plain, references, render, validate +from quickquip.common.record_content import ( + from_segments, + legacy, + plain, + references, + render, + validate, +) from quickquip.common.record_storage import migrate @@ -19,19 +26,42 @@ def test_protocol_boundaries(snapshot): assert references(body) == {"12345", "23456"} assert render(body, snapshot) == "@标准名 喜欢 [图片] @未登记名片" assert body["parts"][1]["name"] == "旧,名&" - assert render(legacy("12345 @名字 abc@QQ12345 @QQ12345abc [CQ:at,qq=no]"), snapshot) == "12345 @名字 abc@QQ12345 @QQ12345abc [CQ:at,qq=no]" + assert render( + legacy("12345 @名字 abc@QQ12345 @QQ12345abc [CQ:at,qq=no]"), snapshot + ) == "12345 @名字 abc@QQ12345 @QQ12345abc [CQ:at,qq=no]" literal = from_segments([{"type": "text", "data": {"text": raw}}]) assert render(literal, snapshot) == raw assert references(literal) == set() def test_command_only_stripped_from_text_segment(snapshot): - message = [{"type": "text", "data": {"text": "/remember "}}, {"type": "at", "data": {"qq": "12345", "name": "旧名"}}, {"type": "text", "data": {"text": " /remember 保留"}}, {"type": "at", "data": {"qq": "all"}}, {"type": "at", "data": {"qq": "99999", "name": "机器人"}}] - assert render(from_segments(message, "remember"), snapshot) == "@标准名 /remember 保留@全体成员@机器人" + message = [ + {"type": "text", "data": {"text": "/remember "}}, + {"type": "at", "data": {"qq": "12345", "name": "旧名"}}, + {"type": "text", "data": {"text": " /remember 保留"}}, + {"type": "at", "data": {"qq": "all"}}, + {"type": "at", "data": {"qq": "99999", "name": "机器人"}}, + ] + assert ( + render(from_segments(message, "remember"), snapshot) + == "@标准名 /remember 保留@全体成员@机器人" + ) assert render(from_segments(message), snapshot).startswith("/remember ") -@pytest.mark.parametrize("part", [{"type": "member", "qq": "all"}, {"type": "member", "qq": 12345}, {"type": "member", "qq": "12345", "usage": "bad"}, {"type": "member", "qq": "12345", "name": []}, {"type": "member", "qq": "12345", "usage": []}, {"type": "media", "media": []}, {"type": "media", "media": "bad"}, {"type": "text", "text": None}]) +@pytest.mark.parametrize( + "part", + [ + {"type": "member", "qq": "all"}, + {"type": "member", "qq": 12345}, + {"type": "member", "qq": "12345", "usage": "bad"}, + {"type": "member", "qq": "12345", "name": []}, + {"type": "member", "qq": "12345", "usage": []}, + {"type": "media", "media": []}, + {"type": "media", "media": "bad"}, + {"type": "text", "text": None}, + ], +) def test_validation_rejects_invalid_parts(part): with pytest.raises(ValueError): validate({"version": 1, "parts": [part]}) @@ -43,7 +73,9 @@ def test_validation_enforces_length(): def test_identity_merge_and_ambiguous_names(): - global_index = index(IdentityEntry("同名", ["12345"], [], ""), IdentityEntry("同名", ["23456"], [], "")) + global_index = index( + IdentityEntry("同名", ["12345"], [], ""), IdentityEntry("同名", ["23456"], [], "") + ) group = index(IdentityEntry("群标准名", ["12345"], ["旧别名"], "")) snap = IdentitySnapshot(global_index.merge(group)) assert snap.name("12345") == "群标准名" @@ -87,7 +119,9 @@ def worker(_): with ThreadPoolExecutor(max_workers=4) as executor: list(executor.map(worker, range(12))) with sqlite3.connect(path) as conn: - assert len([r for r in conn.execute("PRAGMA table_info(memories)") if r[1] == "content_parts_json"]) == 1 + assert len( + [r for r in conn.execute("PRAGMA table_info(memories)") if r[1] == "content_parts_json"] + ) == 1 @@ -122,5 +156,7 @@ def test_group_index_cache_reuses_merge_until_reload(tmp_path): def test_record_storage_rejects_unknown_table(tmp_path): from quickquip.common.record_storage import save_parts - with sqlite3.connect(tmp_path / "db") as conn, pytest.raises(ValueError, match="unsupported record table"): + with sqlite3.connect(tmp_path / "db") as conn, pytest.raises( + ValueError, match="unsupported record table" + ): save_parts(conn, "not_a_record", 1, "10001", plain("text")) diff --git a/tests/unit/generation/test_audio_http_tts.py b/tests/unit/generation/test_audio_http_tts.py index 16fb8234..d5728586 100644 --- a/tests/unit/generation/test_audio_http_tts.py +++ b/tests/unit/generation/test_audio_http_tts.py @@ -133,7 +133,8 @@ def fake_urlopen(http_request, *, timeout, context): id="local-http-tts", protocol="http_tts", base_url="http://127.0.0.1:5000", api_key_env="" ) model = AudioModelConfig( - id="t", model="m", voice_id="v", format="mp3", extra_body={"__path": "synthesize", "text": "{text}"} + id="t", model="m", voice_id="v", format="mp3", + extra_body={"__path": "synthesize", "text": "{text}"}, ) asyncio.run(generate_audio(model, provider, "测试")) @@ -157,7 +158,8 @@ def fake_urlopen(http_request, *, timeout, context): id="local-http-tts", protocol="http_tts", base_url="http://127.0.0.1:5000", api_key_env="" ) model = AudioModelConfig( - id="t", model="m", voice_id="", format="mp3", extra_body={"__path": "/tts", "text": "{text}", "voice": "{voice}"} + id="t", model="m", voice_id="", format="mp3", + extra_body={"__path": "/tts", "text": "{text}", "voice": "{voice}"}, ) asyncio.run(generate_audio(model, provider, "测试")) @@ -185,7 +187,8 @@ def fake_urlopen(http_request, *, timeout, context): extra_body={"speaker": "{voice}", "fallback_text": "{text}"}, ) model = AudioModelConfig( - id="t", model="m", voice_id="alloy", format="mp3", extra_body={"__path": "/tts", "text": "{text}"} + id="t", model="m", voice_id="alloy", format="mp3", + extra_body={"__path": "/tts", "text": "{text}"}, ) asyncio.run(generate_audio(model, provider, "你好")) @@ -210,7 +213,8 @@ def fake_urlopen(http_request, *, timeout, context): id="local-http-tts", protocol="http_tts", base_url="http://127.0.0.1:5000", api_key_env="" ) model = AudioModelConfig( - id="t", model="m", voice_id="alloy", format="mp3", extra_body={"__path": "/tts", "text": "{text}"} + id="t", model="m", voice_id="alloy", format="mp3", + extra_body={"__path": "/tts", "text": "{text}"}, ) asyncio.run(generate_audio(model, provider, "say {voice} now")) diff --git a/tests/unit/generation/test_audio_openai_tts.py b/tests/unit/generation/test_audio_openai_tts.py index e4314b57..2e1f461e 100644 --- a/tests/unit/generation/test_audio_openai_tts.py +++ b/tests/unit/generation/test_audio_openai_tts.py @@ -95,7 +95,8 @@ async def fake_http_raw_bytes(url, *, headers, payload, timeout): monkeypatch.setattr("quickquip.generation.audio._http_raw_bytes", fake_http_raw_bytes) provider = AudioProviderConfig( - id="local-openai-tts", protocol="openai_tts", base_url="http://127.0.0.1:8000/v1", api_key_env="" + id="local-openai-tts", protocol="openai_tts", + base_url="http://127.0.0.1:8000/v1", api_key_env="", ) model = AudioModelConfig(id="local-tts", model="tts-1", voice_id="alloy", format="mp3") diff --git a/tests/unit/generation/test_music_minimax.py b/tests/unit/generation/test_music_minimax.py index a8dfd173..28ddfc2f 100644 --- a/tests/unit/generation/test_music_minimax.py +++ b/tests/unit/generation/test_music_minimax.py @@ -73,7 +73,9 @@ async def fake_http_json(url, *, method, headers, payload, timeout): monkeypatch.setattr("quickquip.generation.music._http_json", fake_http_json) monkeypatch.setattr("quickquip.generation.music._get_api_key", lambda provider: "secret") - result = asyncio.run(generate_music(model, provider, "Mandopop, Summer", lyrics="[Verse]\n海风吹")) + result = asyncio.run( + generate_music(model, provider, "Mandopop, Summer", lyrics="[Verse]\n海风吹") + ) assert result.audio_bytes == b"hello" assert result.mime_type == "audio/mpeg" diff --git a/tests/unit/generation/test_svg_render.py b/tests/unit/generation/test_svg_render.py index 585f953e..89c1dc0d 100644 --- a/tests/unit/generation/test_svg_render.py +++ b/tests/unit/generation/test_svg_render.py @@ -27,7 +27,8 @@ async def test_render_happy_path(): async def test_render_ignores_svg_width_height_attributes(): bomb = _GOOD_SVG.replace( '', - '', + '', ) png = await render_svg_to_png(bomb) assert _png_size(png) == (240, 120) diff --git a/tests/unit/generation/test_svg_sanitize.py b/tests/unit/generation/test_svg_sanitize.py index 1276b28e..9e8561a2 100644 --- a/tests/unit/generation/test_svg_sanitize.py +++ b/tests/unit/generation/test_svg_sanitize.py @@ -42,7 +42,10 @@ def test_rejects_doctype_and_entities(self): with pytest.raises(SvgSanitizeError): sanitize_svg(']>' + _wrap()) with pytest.raises(SvgSanitizeError): - sanitize_svg('' + _wrap()) + sanitize_svg( + '' + _wrap() + ) with pytest.raises(SvgSanitizeError): sanitize_svg("" + _wrap()) @@ -80,7 +83,12 @@ def test_filter_param_limits(self): with pytest.raises(SvgSanitizeError, match="baseFrequency"): sanitize_svg(_wrap('')) with pytest.raises(SvgSanitizeError, match="filter"): - sanitize_svg(_wrap('')) + sanitize_svg( + _wrap( + '' + '' + ) + ) def test_filter_param_limits_single_quote_and_scientific_notation(self): """CR M2 回归:单引号与科学计数法形态不得绕过参数上限。""" @@ -91,7 +99,9 @@ def test_filter_param_limits_single_quote_and_scientific_notation(self): with pytest.raises(SvgSanitizeError, match="baseFrequency"): sanitize_svg(_wrap("")) with pytest.raises(SvgSanitizeError, match="filter"): - sanitize_svg(_wrap("")) + sanitize_svg( + _wrap("") + ) def test_filter_param_words_in_text_content_not_rejected(self): """文本内容里出现属性样式字样不触发误拒(过度拦截回归)。""" @@ -131,7 +141,8 @@ def test_lenient_clamps_instead_of_rejecting(self): class TestStripRootSizeAttrs: def test_strips_only_root_tag_sizes(self): svg = ( - '' + '' '' ) cleaned = strip_root_size_attrs(svg) @@ -140,7 +151,9 @@ def test_strips_only_root_tag_sizes(self): assert '' in cleaned def test_keeps_unquoted_and_single_quoted(self): - cleaned = strip_root_size_attrs("") + cleaned = strip_root_size_attrs( + "" + ) assert "99999" not in cleaned.split("" in cleaned diff --git a/tests/unit/llm/test_agent_records_store.py b/tests/unit/llm/test_agent_records_store.py index 00fba6bd..7cdcc9a6 100644 --- a/tests/unit/llm/test_agent_records_store.py +++ b/tests/unit/llm/test_agent_records_store.py @@ -57,7 +57,9 @@ def _begin(store: LLMStore, scope: str = "1001") -> LoopHandle: return store.begin_loop(scope, 0, TriggerKind.GROUP_DIRECT, _user_payload()) -def _response(text: str = "回复正文", *, tools: int = 0, native: dict | None = None) -> TurnResponseRecord: +def _response( + text: str = "回复正文", *, tools: int = 0, native: dict | None = None +) -> TurnResponseRecord: return TurnResponseRecord( text=text, text_policy=TextPolicy.ALLOWED, @@ -84,7 +86,9 @@ def _declarations(count: int) -> list[ToolDeclarationRecord]: _chunk_seq = 0 -def _chunk_plan(count: int, turn_id: str | None = None, text_len: int | None = None) -> list[DeliveryPlanItem]: +def _chunk_plan( + count: int, turn_id: str | None = None, text_len: int | None = None +) -> list[DeliveryPlanItem]: if count == 0 or text_len is None: return [] global _chunk_seq @@ -126,7 +130,8 @@ def test_migration_backfills_legacy_loops(tmp_path: Path): store = LLMStore(db_path) with store._connect() as conn: loops = conn.execute( - "SELECT loop_id, trigger_kind, status, legacy, anchor_row_id FROM agent_loops ORDER BY anchor_row_id" + "SELECT loop_id, trigger_kind, status, legacy, anchor_row_id " + "FROM agent_loops ORDER BY anchor_row_id" ).fetchall() # 孤立段 + 三个 user 锚点 Loop(§4.3.3:连续 user 各自独立 Loop) assert [row["trigger_kind"] for row in loops] == [ @@ -135,7 +140,8 @@ def test_migration_backfills_legacy_loops(tmp_path: Path): assert all(row["status"] == "legacy" for row in loops) # 原行原样保留:行数、ID、正文、message_id 不变(§4.3.4)。 rows = conn.execute( - "SELECT id, role, content, message_id, agent_loop_id, agent_turn_id FROM conversation_messages ORDER BY id" + "SELECT id, role, content, message_id, agent_loop_id, agent_turn_id " + "FROM conversation_messages ORDER BY id" ).fetchall() assert len(rows) == len(legacy_rows()) assert [row["id"] for row in rows] == list(range(1, len(legacy_rows()) + 1)) @@ -192,7 +198,9 @@ def test_concurrent_upgrade_from_114_preserves_legacy_data(tmp_path: Path, monke ("1001", 1, "2026-09-01"), ) original_rows = conn.execute("SELECT * FROM conversation_messages ORDER BY id").fetchall() - original_columns = [row[1] for row in conn.execute("PRAGMA table_info(conversation_messages)")] + original_columns = [ + row[1] for row in conn.execute("PRAGMA table_info(conversation_messages)") + ] start = Barrier(2) column_reads = Barrier(2) @@ -237,7 +245,10 @@ def open_store(): columns = [row[1] for row in conn.execute("PRAGMA table_info(group_settings)")] assert columns.count("agent_delivery_enabled") == 1 select = ", ".join(original_columns) - assert conn.execute(f"SELECT {select} FROM conversation_messages ORDER BY id").fetchall() == original_rows + assert ( + conn.execute(f"SELECT {select} FROM conversation_messages ORDER BY id").fetchall() + == original_rows + ) assert conn.execute("SELECT COUNT(*) FROM agent_loops").fetchone()[0] == 4 assert conn.execute("SELECT COUNT(*) FROM agent_schema_migrations").fetchone()[0] == 1 assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] @@ -249,7 +260,9 @@ def open_store(): def test_begin_loop_writes_user_trigger_row(store: LLMStore): handle = _begin(store) with store._connect() as conn: - loop = conn.execute("SELECT * FROM agent_loops WHERE loop_id=?", (handle.loop_id,)).fetchone() + loop = conn.execute( + "SELECT * FROM agent_loops WHERE loop_id=?", (handle.loop_id,) + ).fetchone() user = conn.execute( "SELECT * FROM conversation_messages WHERE agent_loop_id=?", (handle.loop_id,) ).fetchone() @@ -276,7 +289,9 @@ def test_commit_turn_atomic_write(store: LLMStore): turn_id=turn_id, ) with store._connect() as conn: - turn = conn.execute("SELECT * FROM agent_turns WHERE turn_id=?", (record.turn_id,)).fetchone() + turn = conn.execute( + "SELECT * FROM agent_turns WHERE turn_id=?", (record.turn_id,) + ).fetchone() message = conn.execute( "SELECT * FROM conversation_messages WHERE id=?", (record.message_row_id,) ).fetchone() @@ -370,7 +385,8 @@ def test_ephemeral_result_never_persists_body(store: LLMStore): ) with store._connect() as conn: row = conn.execute( - "SELECT result_json, status, result_omission_reason FROM agent_tool_executions WHERE execution_id=?", + "SELECT result_json, status, result_omission_reason " + "FROM agent_tool_executions WHERE execution_id=?", (exec_id,), ).fetchone() result = json.loads(row["result_json"]) @@ -399,7 +415,9 @@ def test_not_executed_terminal_with_reason(store: LLMStore): def test_delivery_attempt_and_lookup(store: LLMStore): handle = _begin(store) text = "要发出去的正文" - record = store.commit_turn(handle, _response(text), _declarations(0), _chunk_plan(1, text_len=len(text))) + record = store.commit_turn( + handle, _response(text), _declarations(0), _chunk_plan(1, text_len=len(text)) + ) delivery_id = record.delivery_ids[0] attempt = store.start_delivery(handle, delivery_id) store.finish_delivery(attempt, DeliveryReceipt(status=DeliveryStatus.SENT, message_id="qq-1")) @@ -419,10 +437,16 @@ def test_delivery_attempt_and_lookup(store: LLMStore): def test_attempt_terminal_not_overwritten(store: LLMStore): handle = _begin(store) text = "正文" - record = store.commit_turn(handle, _response(text), _declarations(0), _chunk_plan(1, text_len=len(text))) + record = store.commit_turn( + handle, _response(text), _declarations(0), _chunk_plan(1, text_len=len(text)) + ) attempt = store.start_delivery(handle, record.delivery_ids[0]) - store.finish_delivery(attempt, DeliveryReceipt(status=DeliveryStatus.FAILED, error_code="timeout")) - store.finish_delivery(attempt, DeliveryReceipt(status=DeliveryStatus.SENT, message_id="late")) + store.finish_delivery( + attempt, DeliveryReceipt(status=DeliveryStatus.FAILED, error_code="timeout") + ) + store.finish_delivery( + attempt, DeliveryReceipt(status=DeliveryStatus.SENT, message_id="late") + ) with store._connect() as conn: row = conn.execute( "SELECT status FROM agent_delivery_attempts WHERE attempt_id=?", (attempt.attempt_id,) @@ -433,11 +457,15 @@ def test_attempt_terminal_not_overwritten(store: LLMStore): def test_unknown_upgrade_on_trusted_receipt(store: LLMStore): handle = _begin(store) text = "正文" - record = store.commit_turn(handle, _response(text), _declarations(0), _chunk_plan(1, text_len=len(text))) + record = store.commit_turn( + handle, _response(text), _declarations(0), _chunk_plan(1, text_len=len(text)) + ) attempt = store.start_delivery(handle, record.delivery_ids[0]) # 模拟崩溃恢复:close_loop 把 sending 收敛为 unknown。 store.close_loop(handle, LoopStatus.INTERRUPTED, "test") - store.finish_delivery(attempt, DeliveryReceipt(status=DeliveryStatus.SENT, message_id="late-ok")) + store.finish_delivery( + attempt, DeliveryReceipt(status=DeliveryStatus.SENT, message_id="late-ok") + ) with store._connect() as conn: attempt_row = conn.execute( "SELECT status, qq_message_id FROM agent_delivery_attempts WHERE attempt_id=?", @@ -457,14 +485,17 @@ def test_unknown_upgrade_on_trusted_receipt(store: LLMStore): def test_close_loop_sweeps_and_is_idempotent(store: LLMStore): handle = _begin(store) text = "多段" - record = store.commit_turn(handle, _response(text), _declarations(0), _chunk_plan(2, text_len=len(text))) + record = store.commit_turn( + handle, _response(text), _declarations(0), _chunk_plan(2, text_len=len(text)) + ) store.start_delivery(handle, record.delivery_ids[0]) store.close_loop(handle, LoopStatus.INTERRUPTED, "delivery_failed") with store._connect() as conn: statuses = { row["delivery_id"]: row["status"] for row in conn.execute( - "SELECT delivery_id, status FROM agent_deliveries WHERE loop_id=?", (handle.loop_id,) + "SELECT delivery_id, status FROM agent_deliveries WHERE loop_id=?", + (handle.loop_id,) ) } assert statuses[record.delivery_ids[0]] == "unknown" # 已在途,回执未落库 @@ -484,7 +515,9 @@ def test_recover_unfinished_loops(store: LLMStore): # Loop B:Turn 已提交,工具 declared/running,交付 planned。 handle_b = store.begin_loop("1002", 0, TriggerKind.PRIVATE_DIRECT, _user_payload("私聊")) text = "正文" - store.commit_turn(handle_b, _response(text), _declarations(2), _chunk_plan(1, text_len=len(text))) + store.commit_turn( + handle_b, _response(text), _declarations(2), _chunk_plan(1, text_len=len(text)) + ) exec_ids = [d.execution_id for d in _declarations(2)] store.mark_tool_started(handle_b, exec_ids[0]) @@ -513,7 +546,9 @@ def test_recover_unfinished_loops(store: LLMStore): def test_load_closed_loops_returns_complete_records(store: LLMStore): handle = _begin(store) text = "完整正文" - record = store.commit_turn(handle, _response(text), _declarations(1), _chunk_plan(1, text_len=len(text))) + record = store.commit_turn( + handle, _response(text), _declarations(1), _chunk_plan(1, text_len=len(text)) + ) store.close_loop(handle, LoopStatus.COMPLETED, None) loops = store.load_closed_loops("1001") assert len(loops) == 1 @@ -554,10 +589,16 @@ def test_prune_closed_loops_by_age_and_count(store: LLMStore): for i in range(4): handle = store.begin_loop("1001", 0, TriggerKind.GROUP_DIRECT, _user_payload(f"问{i}")) text = f"答{i}" - store.commit_turn(handle, _response(text), _declarations(0), _chunk_plan(1, text_len=len(text))) + store.commit_turn( + handle, _response(text), _declarations(0), _chunk_plan(1, text_len=len(text)) + ) store.close_loop(handle, LoopStatus.COMPLETED, None) # 数量上限 2:清最旧的两个。 - report = store.prune_closed_loops("1001", active_anchors=[], policy=RetentionPolicy(retention_days=30, max_loops=2, max_bytes=64 * 1024 * 1024)) + report = store.prune_closed_loops( + "1001", + active_anchors=[], + policy=RetentionPolicy(retention_days=30, max_loops=2, max_bytes=64 * 1024 * 1024), + ) assert len(report.deleted_loop_ids) == 2 remaining = store.load_closed_loops("1001") assert len(remaining) == 2 @@ -574,7 +615,9 @@ def test_prune_respects_active_epoch_floor(store: LLMStore): for i in range(4): handle = store.begin_loop("1001", 0, TriggerKind.GROUP_DIRECT, _user_payload(f"问{i}")) text = f"答{i}" - store.commit_turn(handle, _response(text), _declarations(0), _chunk_plan(1, text_len=len(text))) + store.commit_turn( + handle, _response(text), _declarations(0), _chunk_plan(1, text_len=len(text)) + ) store.close_loop(handle, LoopStatus.COMPLETED, None) handles.append(handle) # 活动纪元从第 3 个 Loop 开始:最旧两个可删,其后受保护。 diff --git a/tests/unit/llm/test_briefing.py b/tests/unit/llm/test_briefing.py index 0a4895ce..9b219951 100644 --- a/tests/unit/llm/test_briefing.py +++ b/tests/unit/llm/test_briefing.py @@ -6,7 +6,13 @@ from quickquip.chat.daily_briefing import DailyBriefingContext from quickquip.llm.briefing import generate_daily_briefing -from quickquip.llm.config import DailyBriefingConfig, LLMConfig, PersonaConfig, ProviderConfig, RuntimeConfig +from quickquip.llm.config import ( + DailyBriefingConfig, + LLMConfig, + PersonaConfig, + ProviderConfig, + RuntimeConfig, +) from quickquip.llm.provider import LLMResponse @@ -58,7 +64,11 @@ def _llm_config() -> LLMConfig: return LLMConfig( runtime=runtime, providers={"a": provider_a, "b": provider_b}, - personas={"default": PersonaConfig(id="default", display_name="默认", system_prompt="你是测试人格。")}, + personas={ + "default": PersonaConfig( + id="default", display_name="默认", system_prompt="你是测试人格。" + ) + }, daily_briefing=DailyBriefingConfig(model_cascade=["a/m1", "b/m2"], max_output_chars=320), ) @@ -163,7 +173,9 @@ def _record(feature, **kwargs): await generate_daily_briefing( context=_context(), - persona=PersonaConfig(id="nightwatch", display_name="守夜人", system_prompt="你是测试人格。"), + persona=PersonaConfig( + id="nightwatch", display_name="守夜人", system_prompt="你是测试人格。" + ), group_id="1001", briefing_config=_llm_config().daily_briefing, llm_config=_llm_config(), diff --git a/tests/unit/llm/test_config_validate.py b/tests/unit/llm/test_config_validate.py index 03d7b011..9a86d7a0 100644 --- a/tests/unit/llm/test_config_validate.py +++ b/tests/unit/llm/test_config_validate.py @@ -335,7 +335,9 @@ def test_builtin_search_on_non_gemini_protocol_warns_and_stays_inert(tmp_path, c with caplog.at_level(logging.WARNING, logger="quickquip.llm.config"): loaded = _load( tmp_path, - _good_provider().replace('models = ["gpt-x"]', 'models = ["gpt-x"]\nbuiltin_search = true') + _good_provider().replace( + 'models = ["gpt-x"]', 'models = ["gpt-x"]\nbuiltin_search = true' + ) + _PERSONA, ) @@ -493,7 +495,8 @@ def test_epoch_params_invalid_provider_override_falls_back_to_runtime(tmp_path: """ + _good_provider().replace( 'models = ["gpt-x"]', - 'models = ["gpt-x"]\nepoch_cold_target_tokens = 100\nepoch_cold_trigger_tokens = 50', + 'models = ["gpt-x"]\n' + 'epoch_cold_target_tokens = 100\nepoch_cold_trigger_tokens = 50', ) + _PERSONA, ) diff --git a/tests/unit/llm/test_draw_svg_tool.py b/tests/unit/llm/test_draw_svg_tool.py index 55ec7ddb..89acf2d0 100644 --- a/tests/unit/llm/test_draw_svg_tool.py +++ b/tests/unit/llm/test_draw_svg_tool.py @@ -55,7 +55,9 @@ def svg_tool_env(monkeypatch): """隔离三处模块级单例:渲染限流器、生成配置、真实渲染。""" monkeypatch.setattr( svg_module, "_RENDER_RATE_LIMITER", - KeyedRateLimiter({"svg_render": {"global_limit": 10, "user_limit": 2, "scope": "global", "window": 60}}), + KeyedRateLimiter( + {"svg_render": {"global_limit": 10, "user_limit": 2, "scope": "global", "window": 60}} + ), ) config = _FakeGenerationConfig() monkeypatch.setattr(generation_service, "get_config", lambda **_: config) diff --git a/tests/unit/llm/test_health.py b/tests/unit/llm/test_health.py index 04c12689..092bae50 100644 --- a/tests/unit/llm/test_health.py +++ b/tests/unit/llm/test_health.py @@ -74,7 +74,9 @@ async def test_health_reports_bound_image_preprocessor(llm_service, monkeypatch) class _StubClient: pass - monkeypatch.setattr("quickquip.llm.service.build_provider_client", lambda provider: _StubClient()) + monkeypatch.setattr( + "quickquip.llm.service.build_provider_client", lambda provider: _StubClient() + ) llm_service.config_path.write_text( MIN_LLM_CONFIG_TOML + """ @@ -218,7 +220,9 @@ class _OkClient: async def complete(self, request): return object() - monkeypatch.setattr("quickquip.llm.provider.build_provider_client", lambda p, **_kwargs: _OkClient()) + monkeypatch.setattr( + "quickquip.llm.provider.build_provider_client", lambda p, **_kwargs: _OkClient() + ) report = await llm_service.build_health_report(10001, probe_provider=True) items = {item.name: item for item in report.items} @@ -235,7 +239,9 @@ class _OkClient: async def complete(self, request): return object() - monkeypatch.setattr("quickquip.llm.provider.build_provider_client", lambda p, **_kwargs: _OkClient()) + monkeypatch.setattr( + "quickquip.llm.provider.build_provider_client", lambda p, **_kwargs: _OkClient() + ) text = await llm_service.format_provider_probe() assert "Provider 探活" in text @@ -278,7 +284,9 @@ async def complete(self, request): assert "backup" not in text -async def test_format_current_provider_probe_failure_prefaces_config_effective(llm_service, monkeypatch): +async def test_format_current_provider_probe_failure_prefaces_config_effective( + llm_service, monkeypatch +): """探活未通过时应前置'配置已生效',避免 reload 成功但探活 ❌ 被误读为 reload 失败。""" monkeypatch.setenv("OPENAI_API_KEY", "test-key") @@ -286,7 +294,9 @@ class _FailClient: async def complete(self, request): raise RuntimeError("boom") - monkeypatch.setattr("quickquip.llm.provider.build_provider_client", lambda p, **_kwargs: _FailClient()) + monkeypatch.setattr( + "quickquip.llm.provider.build_provider_client", lambda p, **_kwargs: _FailClient() + ) text = await llm_service.format_current_provider_probe(10001, chat_type="group") assert "配置已生效" in text diff --git a/tests/unit/llm/test_history_safety.py b/tests/unit/llm/test_history_safety.py index a54abe2d..f3428bb0 100644 --- a/tests/unit/llm/test_history_safety.py +++ b/tests/unit/llm/test_history_safety.py @@ -93,7 +93,9 @@ def test_blocked_loop_uses_safe_archive_without_mutating_records(tmp_path, locat ) serialized = json.dumps([asdict(message) for message in projection.messages]) assert MARKER not in serialized - assert not any(message.native_content or message.tool_calls for message in projection.messages) + assert not any( + message.native_content or message.tool_calls for message in projection.messages + ) assert not any(message.role == "tool" for message in projection.messages) assert loop == original @@ -108,7 +110,9 @@ def test_unaffected_loop_preserves_native_bytes(tmp_path, mode): safe, archived = prepare_safe_history([loop], sensitive) assert safe[0] is loop assert not archived - original = project_loops_with_budget([loop], target=owner, protocol="claude", budget_tokens=10000) + original = project_loops_with_budget( + [loop], target=owner, protocol="claude", budget_tokens=10000 + ) result = project_loops_with_budget(safe, target=owner, protocol="claude", budget_tokens=10000) assert result == original assert result.messages[1].native_content == loop.turns[0].native_state["blocks"] @@ -125,7 +129,9 @@ def test_numeric_tool_arguments_are_scanned(tmp_path): loop, _ = _loop() tool = replace(loop.turns[0].tools[0], arguments_json='{"user_id":123456789}') loop = replace(loop, turns=(replace(loop.turns[0], tools=(tool,)),)) - _, archived = prepare_safe_history([loop], make_sensitive_filter(tmp_path, "block", "123456789")) + _, archived = prepare_safe_history( + [loop], make_sensitive_filter(tmp_path, "block", "123456789") + ) assert archived == {loop.loop_id} diff --git a/tests/unit/llm/test_identity_loop.py b/tests/unit/llm/test_identity_loop.py index 4df7f867..f24a02f1 100644 --- a/tests/unit/llm/test_identity_loop.py +++ b/tests/unit/llm/test_identity_loop.py @@ -246,8 +246,14 @@ def test_collect_mention_profiles_dedupes_candidates(tmp_path: Path): quoted_user_id="", ) assert profiles == [ - {"canonical_name": "4s", "user_id": "40004", "aliases": "Туманность、哈基四", "note": "大部分以四字开头的称呼通常指 4s"}, - {"canonical_name": "镜子", "user_id": "10002", "aliases": "镜千翎、哈基镜", "note": "特别注意不要和王者荣耀的镜混淆"}, + { + "canonical_name": "4s", "user_id": "40004", + "aliases": "Туманность、哈基四", "note": "大部分以四字开头的称呼通常指 4s", + }, + { + "canonical_name": "镜子", "user_id": "10002", "aliases": "镜千翎、哈基镜", + "note": "特别注意不要和王者荣耀的镜混淆", + }, ] diff --git a/tests/unit/llm/test_inputs.py b/tests/unit/llm/test_inputs.py index d60f8ad8..1292e84f 100644 --- a/tests/unit/llm/test_inputs.py +++ b/tests/unit/llm/test_inputs.py @@ -7,7 +7,15 @@ from plugins.llm_runtime import ResolvedGroupSettings from tests.fixtures.configs import IDENTITIES_YAML -from tests.fixtures.onebot import DummyMessage, DummyReply, DummySender, at_seg, image_seg, record_seg, text_seg +from tests.fixtures.onebot import ( + DummyMessage, + DummyReply, + DummySender, + at_seg, + image_seg, + record_seg, + text_seg, +) PREFIX_SETTINGS = ResolvedGroupSettings( diff --git a/tests/unit/llm/test_mcp_dual_era.py b/tests/unit/llm/test_mcp_dual_era.py index ca9f5024..e3cbc218 100644 --- a/tests/unit/llm/test_mcp_dual_era.py +++ b/tests/unit/llm/test_mcp_dual_era.py @@ -50,7 +50,9 @@ def test_modern_server_info_falls_back_to_draft_metadata(): def test_modern_server_info_rejects_missing_or_malformed_metadata(): assert _extract_modern_server_info({}) == {} - assert _extract_modern_server_info({"_meta": {"io.modelcontextprotocol/serverInfo": "bad"}}) == {} + assert _extract_modern_server_info( + {"_meta": {"io.modelcontextprotocol/serverInfo": "bad"}} + ) == {} assert _extract_modern_server_info({"serverInfo": "bad"}) == {} @@ -329,16 +331,28 @@ def test_sanitize_error_message_preserves_safe_text(): def test_detect_alias_conflicts_no_duplicates(): bindings = [ - MCPToolBinding(alias="mcp_a_tool1", server_id="a", tool_name="tool1", description="", input_schema={}), - MCPToolBinding(alias="mcp_b_tool2", server_id="b", tool_name="tool2", description="", input_schema={}), + MCPToolBinding( + alias="mcp_a_tool1", server_id="a", tool_name="tool1", + description="", input_schema={}, + ), + MCPToolBinding( + alias="mcp_b_tool2", server_id="b", tool_name="tool2", + description="", input_schema={}, + ), ] assert _detect_alias_conflicts(bindings) == set() def test_detect_alias_conflicts_finds_exact_duplicates(): bindings = [ - MCPToolBinding(alias="mcp_a_tool1", server_id="a", tool_name="tool1", description="", input_schema={}), - MCPToolBinding(alias="mcp_a_tool1", server_id="b", tool_name="tool1", description="", input_schema={}), + MCPToolBinding( + alias="mcp_a_tool1", server_id="a", tool_name="tool1", + description="", input_schema={}, + ), + MCPToolBinding( + alias="mcp_a_tool1", server_id="b", tool_name="tool1", + description="", input_schema={}, + ), ] assert _detect_alias_conflicts(bindings) == {"mcp_a_tool1"} @@ -353,8 +367,14 @@ def test_detect_alias_conflicts_finds_sanitization_collisions(): assert alias1 == alias2 bindings = [ - MCPToolBinding(alias=alias1, server_id="srv", tool_name="foo.bar", description="", input_schema={}), - MCPToolBinding(alias=alias2, server_id="srv", tool_name="foo_bar", description="", input_schema={}), + MCPToolBinding( + alias=alias1, server_id="srv", tool_name="foo.bar", + description="", input_schema={}, + ), + MCPToolBinding( + alias=alias2, server_id="srv", tool_name="foo_bar", + description="", input_schema={}, + ), ] conflicts = _detect_alias_conflicts(bindings) assert alias1 in conflicts @@ -441,7 +461,10 @@ def test_is_recognized_modern_error_body_accepts_version_error(): """UnsupportedProtocolVersionError (-32022) IS a recognized modern error.""" from quickquip.llm.mcp.codec import is_recognized_modern_error_body - modern_body = b'{"jsonrpc":"2.0","id":1,"error":{"code":-32022,"message":"Unsupported version","data":{"supported":["2026-07-28"]}}}' + modern_body = ( + b'{"jsonrpc":"2.0","id":1,"error":{"code":-32022,"message":"Unsupported version",' + b'"data":{"supported":["2026-07-28"]}}}' + ) assert is_recognized_modern_error_body(modern_body) diff --git a/tests/unit/llm/test_mcp_image_content.py b/tests/unit/llm/test_mcp_image_content.py index 09b1eb9d..ac19ccb7 100644 --- a/tests/unit/llm/test_mcp_image_content.py +++ b/tests/unit/llm/test_mcp_image_content.py @@ -100,7 +100,9 @@ def test_strict_decoder_enforces_five_mib_before_decoding(): at_limit = base64.b64encode(b"x" * maximum).decode("ascii") above_limit = base64.b64encode(b"x" * (maximum + 1)).decode("ascii") - assert len(_decode_image_candidate(MCPInlineImageCandidate(0, at_limit, "image/png"))) == maximum + assert len( + _decode_image_candidate(MCPInlineImageCandidate(0, at_limit, "image/png")) + ) == maximum assert _decode_image_candidate(MCPInlineImageCandidate(0, above_limit, "image/png")) is None @@ -240,7 +242,11 @@ async def test_gemini_tool_image_follows_complete_function_response_batch(): ], ) async def test_provider_never_serializes_images_for_tool_error(client_type, response): - protocol = {"_InlineOpenAIClient": "openai", "_InlineClaudeClient": "claude", "_InlineGeminiClient": "gemini"}[client_type.__name__] + protocol = { + "_InlineOpenAIClient": "openai", + "_InlineClaudeClient": "claude", + "_InlineGeminiClient": "gemini", + }[client_type.__name__] client = client_type(_config(protocol), response) await client.complete(_tool_request(is_error=True)) diff --git a/tests/unit/llm/test_mcp_result_normalization.py b/tests/unit/llm/test_mcp_result_normalization.py index a174fe8b..7bf1022b 100644 --- a/tests/unit/llm/test_mcp_result_normalization.py +++ b/tests/unit/llm/test_mcp_result_normalization.py @@ -64,7 +64,8 @@ def test_structured_content_preserves_existing_text_fallback_behavior(): ), ( {"content": [{"type": "resource_link", "uri": - f"https://example.test/file?token={RESOURCE_QUERY_SENTINEL}", "mimeType": "text/plain"}]}, + f"https://example.test/file?token={RESOURCE_QUERY_SENTINEL}", + "mimeType": "text/plain"}]}, "1 个 link 项", ), ( @@ -113,7 +114,10 @@ def test_malformed_resource_uri_is_not_rendered_or_allowed_to_break_normalizatio @pytest.mark.parametrize( "payload", [ - {"isError": True, "content": [{"type": "image", "data": BASE64_SENTINEL, "mimeType": "image/png"}]}, + { + "isError": True, + "content": [{"type": "image", "data": BASE64_SENTINEL, "mimeType": "image/png"}], + }, {"isError": True, "content": [{"type": "resource", "resource": { "uri": f"https://example.test?token={RESOURCE_QUERY_SENTINEL}", "blob": RESOURCE_BODY_SENTINEL, diff --git a/tests/unit/llm/test_media_guard.py b/tests/unit/llm/test_media_guard.py index a82c8ea9..72471474 100644 --- a/tests/unit/llm/test_media_guard.py +++ b/tests/unit/llm/test_media_guard.py @@ -91,7 +91,9 @@ def test_empty_data_dropped(): def test_broken_gif_dropped(): - kept, dropped = guard_inline_media([("truncated.gif", b"GIF89a" + b"\x00" * 32, "image/gif")], 0) + kept, dropped = guard_inline_media( + [("truncated.gif", b"GIF89a" + b"\x00" * 32, "image/gif")], 0 + ) assert kept == [] assert dropped == ["truncated.gif"] diff --git a/tests/unit/llm/test_projection_budget.py b/tests/unit/llm/test_projection_budget.py index f1299af3..4eb21cee 100644 --- a/tests/unit/llm/test_projection_budget.py +++ b/tests/unit/llm/test_projection_budget.py @@ -88,7 +88,10 @@ def test_archive_and_minimal_levels_apply_under_tight_budget(): _loop( f"loop_{i}", ( - _turn("turn_0", text=f"第{i}轮正文。" * 20, tools=(_big_result_exec("exec_0", big),)), + _turn( + "turn_0", text=f"第{i}轮正文。" * 20, + tools=(_big_result_exec("exec_0", big),), + ), _turn("turn_1", text=f"第{i}轮总结。" * 20), ), ) diff --git a/tests/unit/llm/test_prompting.py b/tests/unit/llm/test_prompting.py index 96a363d1..70aef52b 100644 --- a/tests/unit/llm/test_prompting.py +++ b/tests/unit/llm/test_prompting.py @@ -88,10 +88,13 @@ def test_merge_filters_empty_and_whitespace(): def test_scenes_from_history_groups_between_assistant(): history = [ - {"role": "user", "user_id": "1", "sender_name": "A", "content": "msg1", "raw_content": "msg1"}, - {"role": "user", "user_id": "2", "sender_name": "B", "content": "msg2", "raw_content": "msg2"}, + {"role": "user", "user_id": "1", "sender_name": "A", + "content": "msg1", "raw_content": "msg1"}, + {"role": "user", "user_id": "2", "sender_name": "B", + "content": "msg2", "raw_content": "msg2"}, {"role": "assistant", "content": "reply1"}, - {"role": "user", "user_id": "1", "sender_name": "A", "content": "msg3", "raw_content": "msg3"}, + {"role": "user", "user_id": "1", "sender_name": "A", + "content": "msg3", "raw_content": "msg3"}, ] scenes = _build_scenes_from_history(history) assert len(scenes) == 2 @@ -106,8 +109,10 @@ def test_scenes_from_history_groups_between_assistant(): def test_scenes_from_history_no_assistant(): history = [ - {"role": "user", "user_id": "1", "sender_name": "A", "content": "msg1", "raw_content": "msg1"}, - {"role": "user", "user_id": "2", "sender_name": "B", "content": "msg2", "raw_content": "msg2"}, + {"role": "user", "user_id": "1", "sender_name": "A", + "content": "msg1", "raw_content": "msg1"}, + {"role": "user", "user_id": "2", "sender_name": "B", + "content": "msg2", "raw_content": "msg2"}, ] scenes = _build_scenes_from_history(history) assert len(scenes) == 1 @@ -260,7 +265,9 @@ def test_current_scene_collects_all_images(): assert "quoted.png" in scene.images # 转发图片不作为媒体本体附带(媒体本体永不进前缀),仅保留 [附图 N 张] 文本 assert "forward.png" not in scene.images - assert any("[附图 1 张]" in s["text"] for s in scene.speakers if s["canonical_name"] == "转发消息") + assert any( + "[附图 1 张]" in s["text"] for s in scene.speakers if s["canonical_name"] == "转发消息" + ) # --------------------------------------------------------------------------- @@ -269,7 +276,9 @@ def test_current_scene_collects_all_images(): def test_render_current_scene(): scene = LLMSceneMessage( - speakers=[{"user_id": "123", "sender_name": "扎师傅", "canonical_name": "扎师傅", "text": "你好"}], + speakers=[ + {"user_id": "123", "sender_name": "扎师傅", "canonical_name": "扎师傅", "text": "你好"} + ], images=[], scene_type="current", ) text = _render_scene_to_text(scene) @@ -447,7 +456,8 @@ def test_build_messages_with_recent_buffer(): def test_build_messages_recent_not_merged_into_context(): """回归:recent 补丁不再混入【上文】,history 尾行与现场分属两段。""" history = [ - {"role": "user", "user_id": "1", "sender_name": "A", "content": "旧话", "raw_content": "旧话"}, + {"role": "user", "user_id": "1", "sender_name": "A", + "content": "旧话", "raw_content": "旧话"}, ] recent = [{"user_id": "2", "sender_name": "B", "text": "现场发言"}] msgs = build_messages( @@ -695,7 +705,8 @@ def test_persona_world_relationships_str_or_list(): def test_persona_voice_habits_join_with_delimiter(): out = _compile_structured_persona({ - "voice": {"verbal_habits": ["常说嗯", "爱用反问"], "verbal_constraints": ["不爆粗", "不撒谎"]}, + "voice": {"verbal_habits": ["常说嗯", "爱用反问"], + "verbal_constraints": ["不爆粗", "不撒谎"]}, }) assert "口头习惯:常说嗯、爱用反问" in out assert "语言约束:\n- 不爆粗\n- 不撒谎" in out @@ -796,7 +807,9 @@ def _first_divergence(a: str, b: str) -> str: def _static_prompt_kwargs() -> dict: return { - "persona": SimpleNamespace(system_prompt="你是测试人格。", style_prompt="短一点。", extras={}), + "persona": SimpleNamespace( + system_prompt="你是测试人格。", style_prompt="短一点。", extras={} + ), "group_id": 1001, "tool_specs": [], "search_tool_name": "search_web", @@ -901,10 +914,16 @@ def test_turn_envelope_memories_private_wording(frozen_now): def test_turn_envelope_vocab_and_glossary_hits(frozen_now): vocab = _vocab_stub( - matches=[SimpleNamespace(alias="哈基镜", name="镜子", note="特别注意不要和王者荣耀的镜混淆")], + matches=[ + SimpleNamespace( + alias="哈基镜", name="镜子", note="特别注意不要和王者荣耀的镜混淆" + ) + ], glossary=[("区", "群里常见的内部称谓,通常是熟人间的玩笑叫法。")], ) - envelope = build_turn_envelope(now=frozen_now, prompt="哈基镜是区吗?", memories=[], vocab=vocab) + envelope = build_turn_envelope( + now=frozen_now, prompt="哈基镜是区吗?", memories=[], vocab=vocab + ) assert "以下词表命中仅用于帮助你做称呼消歧,不要机械复读:" in envelope assert "- 哈基镜 通常指 镜子;注意:特别注意不要和王者荣耀的镜混淆" in envelope assert "以下黑话解释仅在当前话题相关时参考:" in envelope @@ -942,7 +961,11 @@ def test_build_messages_prepends_envelope_with_history(): content = msgs[-1].content assert content.startswith(_ENVELOPE_SAMPLE + "\n") # 末条 user 是 pending 上文与当前消息的合并:信封在最前,其后【上文】→【当前提问】 - assert content.index("【轮次上下文】") < content.index(SCENE_MARKER_CONTEXT) < content.index(SCENE_MARKER_CURRENT) + assert ( + content.index("【轮次上下文】") + < content.index(SCENE_MARKER_CONTEXT) + < content.index(SCENE_MARKER_CURRENT) + ) def test_build_messages_tail_order_envelope_context_live_current(): @@ -987,4 +1010,7 @@ def test_build_messages_empty_envelope_unchanged(): max_trigger_context_messages=5, current_sender_name="C", current_user_id="3", ) - assert build_messages(**kwargs)[-1].content == build_messages(**kwargs, turn_envelope="")[-1].content + assert ( + build_messages(**kwargs)[-1].content + == build_messages(**kwargs, turn_envelope="")[-1].content + ) diff --git a/tests/unit/llm/test_provider_claude.py b/tests/unit/llm/test_provider_claude.py index 30771a44..6a58516e 100644 --- a/tests/unit/llm/test_provider_claude.py +++ b/tests/unit/llm/test_provider_claude.py @@ -218,7 +218,8 @@ async def test_claude_cache_tokens_parsed(): # 5m/1h 细分求和回退(无顶层 cache_creation_input_tokens 时) data2 = {"model": "claude-test", "content": [{"type": "text", "text": "ok"}], "usage": {"input_tokens": 100, "output_tokens": 50, - "cache_creation": {"ephemeral_5m_input_tokens": 30, "ephemeral_1h_input_tokens": 50}}} + "cache_creation": {"ephemeral_5m_input_tokens": 30, + "ephemeral_1h_input_tokens": 50}}} resp2 = await FakeClaudeClient(base, data2).complete(request) assert resp2.cache_creation_tokens == 80 assert resp2.cache_read_tokens is None diff --git a/tests/unit/llm/test_provider_enabled.py b/tests/unit/llm/test_provider_enabled.py index 7a27cb9a..c6db5e25 100644 --- a/tests/unit/llm/test_provider_enabled.py +++ b/tests/unit/llm/test_provider_enabled.py @@ -137,7 +137,9 @@ async def test_quick_judge_falls_back_past_disabled_provider(tmp_path: Path): def _builder(provider): built.append(provider.id) - return _StubJudgeClient(LLMResponse(text='{"trigger": false}', model="m1", finish_reason="stop")) + return _StubJudgeClient( + LLMResponse(text='{"trigger": false}', model="m1", finish_reason="stop") + ) result = await run_quick_judge_detailed(cfg, "判定一下", client_builder=_builder) diff --git a/tests/unit/llm/test_provider_gemini.py b/tests/unit/llm/test_provider_gemini.py index 5ce83425..961d12ef 100644 --- a/tests/unit/llm/test_provider_gemini.py +++ b/tests/unit/llm/test_provider_gemini.py @@ -120,7 +120,9 @@ async def test_gemini_mcp_style_array_schema_gets_default_items(): input_schema={"type": "object", "properties": {"tags": {"type": "array"}}}, ) ] - client = FakeGeminiClient(_provider_config(), {"candidates": [{"content": {"parts": [{"text": "ok"}]}}]}) + client = FakeGeminiClient( + _provider_config(), {"candidates": [{"content": {"parts": [{"text": "ok"}]}}]} + ) await client.complete(request) diff --git a/tests/unit/llm/test_provider_retry.py b/tests/unit/llm/test_provider_retry.py index 99d785ba..fe2f34f3 100644 --- a/tests/unit/llm/test_provider_retry.py +++ b/tests/unit/llm/test_provider_retry.py @@ -197,7 +197,9 @@ async def test_stream_retryable_error_retries_without_non_stream_fallback(captur async def test_stream_non_retryable_error_propagates_without_fallback(captured_delays): # except LLMProviderError: raise —— 4xx 既不重试也不回退非流式 config = _config(retry_max_attempts=3, retry_base_delay=0.25, retry_jitter=0.0) - client = StreamScriptedClient(config, [LLMProviderError("HTTP 401 unauthorized", status_code=401)]) + client = StreamScriptedClient( + config, [LLMProviderError("HTTP 401 unauthorized", status_code=401)] + ) with pytest.raises(LLMProviderError): await client.complete(_req()) assert client.stream_calls == 1 @@ -220,7 +222,10 @@ async def test_stream_generic_failure_falls_back_to_non_stream(captured_delays): async def test_absorbed_failures_record_single_ok_usage(monkeypatch, captured_delays): calls = [] - async def spy(client, request, response, started, stream_used, state, error_msg="", finished_at=None): + async def spy( + client, request, response, started, stream_used, state, + error_msg="", finished_at=None, + ): calls.append((state, response is not None)) monkeypatch.setattr("quickquip.llm.usage._record_usage", spy) @@ -234,7 +239,10 @@ async def spy(client, request, response, started, stream_used, state, error_msg= async def test_exhausted_retries_record_single_error_usage(monkeypatch, captured_delays): calls = [] - async def spy(client, request, response, started, stream_used, state, error_msg="", finished_at=None): + async def spy( + client, request, response, started, stream_used, state, + error_msg="", finished_at=None, + ): calls.append((state, response is not None)) monkeypatch.setattr("quickquip.llm.usage._record_usage", spy) diff --git a/tests/unit/llm/test_provider_streaming.py b/tests/unit/llm/test_provider_streaming.py index f5c6e6e5..462195ff 100644 --- a/tests/unit/llm/test_provider_streaming.py +++ b/tests/unit/llm/test_provider_streaming.py @@ -60,7 +60,10 @@ def test_trace_reconstructs_complete_chat_completion(self): class TestStripLeadingReasoningContent: def test_think_block(self): - assert strip_leading_reasoning_content("\n先想一想\n\n最终答复") == "最终答复" + assert ( + strip_leading_reasoning_content("\n先想一想\n\n最终答复") + == "最终答复" + ) def test_thinking_fence(self): assert strip_leading_reasoning_content("```thinking\n分析\n```\n最终答复") == "最终答复" @@ -68,7 +71,9 @@ def test_thinking_fence(self): class TestClaudeStreaming: def test_text_only(self): - resp = ClaudeProviderClient._assemble_stream_response(CLAUDE_TEXT_CHUNKS, "claude-sonnet-4-6") + resp = ClaudeProviderClient._assemble_stream_response( + CLAUDE_TEXT_CHUNKS, "claude-sonnet-4-6" + ) assert resp.text == "你好世界" assert resp.finish_reason == "end_turn" assert resp.input_tokens == 15 @@ -76,7 +81,9 @@ def test_text_only(self): assert resp.tool_calls == [] def test_tool_use(self): - resp = ClaudeProviderClient._assemble_stream_response(CLAUDE_TOOL_CHUNKS, "claude-sonnet-4-6") + resp = ClaudeProviderClient._assemble_stream_response( + CLAUDE_TOOL_CHUNKS, "claude-sonnet-4-6" + ) assert resp.text == "" assert len(resp.tool_calls) == 1 assert resp.tool_calls[0].id == "toolu_1" diff --git a/tests/unit/llm/test_quick_judge_detailed.py b/tests/unit/llm/test_quick_judge_detailed.py index e334b678..087e7419 100644 --- a/tests/unit/llm/test_quick_judge_detailed.py +++ b/tests/unit/llm/test_quick_judge_detailed.py @@ -119,5 +119,7 @@ async def test_public_quick_judge_returns_text_on_ok(llm_service, monkeypatch): def test_no_provider_returns_trigger_false_text(): - result = QuickJudgeResult(text='{"trigger": false}', outcome="no_provider", provider_id="", model="") + result = QuickJudgeResult( + text='{"trigger": false}', outcome="no_provider", provider_id="", model="" + ) assert result.to_diagnostic()["outcome"] == "no_provider" diff --git a/tests/unit/llm/test_record_memories.py b/tests/unit/llm/test_record_memories.py index 3cece357..76f6e5f4 100644 --- a/tests/unit/llm/test_record_memories.py +++ b/tests/unit/llm/test_record_memories.py @@ -16,7 +16,10 @@ def test_memory_legacy_matching_scope_and_delete(tmp_path, snapshot): own = store.add_memory("10001", "个人事实无需出现名字", scope="user", user_id="12345") store.add_memory("10001", "他人的私密事实", scope="user", user_id="23456", content_parts=body) with store._connect() as conn: - conn.execute("UPDATE memories SET content_parts_json=NULL, content='[CQ:at,name=旧名,qq=12345] 历史' WHERE id=?", (member_id,)) + conn.execute( + "UPDATE memories SET content_parts_json=NULL, " + "content='[CQ:at,name=旧名,qq=12345] 历史' WHERE id=?", (member_id,) + ) rows = store.list_memories("10001", keyword="别名") assert len(rows) == 3 assert rows[-1]["content_display"] == "@标准名 历史" @@ -24,11 +27,18 @@ def test_memory_legacy_matching_scope_and_delete(tmp_path, snapshot): for query in ("别名", "标准名", "12345", "[CQ:at,name=旧名,qq=12345]"): hits = store.search_memories("10001", user_id="12345", query=query, limit=10) assert {r["id"] for r in hits} == {own, member_id} - assert [r["id"] for r in store.search_memories("10001", user_id="12345", query="", limit=1, scope="user")] == [own] + assert [ + r["id"] + for r in store.search_memories("10001", user_id="12345", query="", limit=1, scope="user") + ] == [own] assert store.delete_memories("10001", f"#{member_id}") == 1 with store._connect() as conn: - assert not conn.execute("SELECT * FROM memories_member_refs WHERE record_id=?", (member_id,)).fetchall() - snapshot.index = index(IdentityEntry("同名", ["12345"], [], ""), IdentityEntry("同名", ["23456"], [], "")) + assert not conn.execute( + "SELECT * FROM memories_member_refs WHERE record_id=?", (member_id,) + ).fetchall() + snapshot.index = index( + IdentityEntry("同名", ["12345"], [], ""), IdentityEntry("同名", ["23456"], [], "") + ) with pytest.raises(ValueError, match="12345"): store.delete_memories("10001", "同名") store.clear_memories("10001") @@ -53,13 +63,21 @@ class Service(ToolMixin, ScopeMixin): assert "其他成员私密" not in result -async def test_auto_memory_projection_preserves_history_and_model_strings(llm_service, snapshot, monkeypatch): +async def test_auto_memory_projection_preserves_history_and_model_strings( + llm_service, snapshot, monkeypatch +): monkeypatch.setattr(llm_service._identity_repository, "snapshot", lambda scope: snapshot) scope = "10001" historical = "[CQ:at,name=旧称呼,qq=23456] 的历史提及" literal = "代码示例 [CQ:at,qq=23456] 保持原文" - llm_service.store.append_conversation_message(scope, "12345", "user", historical, raw_content=historical, canonical_name="旧标准", sender_name="旧卡") - llm_service.store.append_conversation_message(scope, "12345", "user", literal, raw_content=literal, canonical_name="旧标准", sender_name="旧卡") + llm_service.store.append_conversation_message( + scope, "12345", "user", historical, raw_content=historical, + canonical_name="旧标准", sender_name="旧卡", + ) + llm_service.store.append_conversation_message( + scope, "12345", "user", literal, raw_content=literal, + canonical_name="旧标准", sender_name="旧卡", + ) before = llm_service.store.list_recent_conversation_messages(scope, 10) prompts = [] async def judge(prompt, **kwargs): @@ -67,7 +85,11 @@ async def judge(prompt, **kwargs): return '{"memories": ["模型写出的小明喜欢编程"]}' monkeypatch.setattr(llm_service, "quick_judge", judge) llm_service._auto_memory_turns[scope] = 9 - await llm_service._extract_auto_memory(scope_key=scope, user_id="12345", sender_name="旧卡", canonical_name="旧标准", user_text=literal, assistant_text="收到,我会根据当前发言和近期语境判断是否值得记住这些信息。") + await llm_service._extract_auto_memory( + scope_key=scope, user_id="12345", sender_name="旧卡", canonical_name="旧标准", + user_text=literal, + assistant_text="收到,我会根据当前发言和近期语境判断是否值得记住这些信息。", + ) assert "标准名(QQ 12345)" in prompts[0] assert "@未登记名片 的历史提及" in prompts[0] assert literal in prompts[0] diff --git a/tests/unit/llm/test_rendering.py b/tests/unit/llm/test_rendering.py index 16b13f64..78320165 100644 --- a/tests/unit/llm/test_rendering.py +++ b/tests/unit/llm/test_rendering.py @@ -6,7 +6,15 @@ from plugins.message_rendering import render_message_for_llm, render_reply_for_llm from tests.fixtures.configs import IDENTITIES_YAML -from tests.fixtures.onebot import DummyMessage, DummyReply, DummySender, at_seg, forward_seg, image_seg, text_seg +from tests.fixtures.onebot import ( + DummyMessage, + DummyReply, + DummySender, + at_seg, + forward_seg, + image_seg, + text_seg, +) def _identity_index(tmp_path: Path) -> IdentityIndex: @@ -167,7 +175,8 @@ def test_source_block_skips_urls_already_in_text_and_dedupes(): ) assert text == ( - "详见 https://example.test/a 已在正文。\n\n来源:\n- 重复一 — example.test\n- 同域不同页 — example.test" + "详见 https://example.test/a 已在正文。\n\n来源:\n" + "- 重复一 — example.test\n- 同域不同页 — example.test" ) @@ -199,7 +208,10 @@ def test_source_block_redirect_url_renders_title_only(): "回答。", _report([ ("https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbC=", "youtube.com"), - ("https://vertexaisearch.cloud.google.com/grounding-api-redirect/XyZ=", "QuickQuip README"), + ( + "https://vertexaisearch.cloud.google.com/grounding-api-redirect/XyZ=", + "QuickQuip README", + ), ]), ) diff --git a/tests/unit/llm/test_request_budget.py b/tests/unit/llm/test_request_budget.py index 360ad50b..ce81656b 100644 --- a/tests/unit/llm/test_request_budget.py +++ b/tests/unit/llm/test_request_budget.py @@ -66,7 +66,8 @@ def test_estimate_request_tokens_counts_native_content(): def test_estimate_request_tokens_counts_thinking_blocks(): thinking = "理" * 2000 msg = LLMConversationMessage( - role="assistant", content="", thinking_blocks=[{"type": "reasoning", "reasoning_content": thinking}] + role="assistant", content="", + thinking_blocks=[{"type": "reasoning", "reasoning_content": thinking}], ) base = estimate_request_tokens(_request([LLMConversationMessage(role="assistant", content="")])) assert estimate_request_tokens(_request([msg])) >= base + estimate_tokens(thinking) diff --git a/tests/unit/llm/test_request_media_budget.py b/tests/unit/llm/test_request_media_budget.py index d2f5c639..56ebf3bd 100644 --- a/tests/unit/llm/test_request_media_budget.py +++ b/tests/unit/llm/test_request_media_budget.py @@ -48,7 +48,13 @@ def _payload_images(value): yield from _payload_images(child) -@pytest.fixture(params=[("openai", OpenAIProviderClient), ("claude", ClaudeProviderClient), ("gemini", GeminiProviderClient)]) +@pytest.fixture( + params=[ + ("openai", OpenAIProviderClient), + ("claude", ClaudeProviderClient), + ("gemini", GeminiProviderClient), + ] +) def client(request, monkeypatch): protocol, client_type = request.param config = ProviderConfig(id="review", protocol=protocol, base_url="https://example.test/v1", @@ -81,8 +87,11 @@ async def download(_): request = _request([ LLMConversationMessage(role="user", content="look", image_urls=[url]), LLMConversationMessage(role="assistant", tool_calls=calls[:2]), - *[LLMConversationMessage(role="tool", content="result", tool_call_id=call.id, - tool_name=call.name, inline_images=[_image(raw)]) for call in calls[:2]], + *[ + LLMConversationMessage(role="tool", content="result", tool_call_id=call.id, + tool_name=call.name, inline_images=[_image(raw)]) + for call in calls[:2] + ], LLMConversationMessage(role="assistant", tool_calls=calls[2:]), LLMConversationMessage(role="tool", content="result", tool_call_id="t2", tool_name="look", inline_images=[_image(raw)]), @@ -93,11 +102,17 @@ async def download(_): if client.config.protocol == "openai": ids = [msg["tool_call_id"] for msg in payload["messages"] if msg["role"] == "tool"] elif client.config.protocol == "claude": - ids = [block["tool_use_id"] for msg in payload["messages"] if isinstance(msg["content"], list) - for block in msg["content"] if block["type"] == "tool_result"] + ids = [ + block["tool_use_id"] + for msg in payload["messages"] if isinstance(msg["content"], list) + for block in msg["content"] if block["type"] == "tool_result" + ] else: - ids = [part["functionResponse"]["id"] for msg in payload["contents"] for part in msg["parts"] - if "functionResponse" in part] + ids = [ + part["functionResponse"]["id"] + for msg in payload["contents"] for part in msg["parts"] + if "functionResponse" in part + ] assert ids == ["t0", "t1", "t2"] @@ -119,7 +134,9 @@ async def test_unlimited_budget_still_dedupes_and_ignores_failed_tool_images(cli client.config = replace(client.config, max_inline_media_bytes=0) request = _request([ LLMConversationMessage(role="user", content="old", inline_images=[_image(a)]), - LLMConversationMessage(role="user", content="current", inline_images=[_image(a), _image(b)]), + LLMConversationMessage( + role="user", content="current", inline_images=[_image(a), _image(b)] + ), LLMConversationMessage(role="assistant", tool_calls=[LLMToolCall("t1", "look", "{}")]), LLMConversationMessage(role="tool", content="failed", tool_name="look", tool_call_id="t1", is_tool_error=True, inline_images=[_image(error_image)]), @@ -136,7 +153,9 @@ async def download(url): return LLMImageInput(url, "image/png", base64.b64encode(raw).decode("ascii")) monkeypatch.setattr(client, "_download_image", download) request = _request([LLMConversationMessage(role="user", content="image", image_urls=["https://example.test/a.png"])]) - results = await asyncio.gather(client._build_request_parts(request), client._build_request_parts(request)) + results = await asyncio.gather( + client._build_request_parts(request), client._build_request_parts(request) + ) assert [list(_payload_images(payload)) for _, _, payload in results] == [[raw], [raw]] diff --git a/tests/unit/llm/test_schedule_messages_tool.py b/tests/unit/llm/test_schedule_messages_tool.py index 73e49da2..eda4e989 100644 --- a/tests/unit/llm/test_schedule_messages_tool.py +++ b/tests/unit/llm/test_schedule_messages_tool.py @@ -33,6 +33,28 @@ def tool_env(monkeypatch, tmp_path): return type("ScheduleToolEnv", (), {"store": store, "reloads": reloads})() +def test_input_schema_descriptions_are_strings(llm_service): + """schema 契约守卫:description 等说明字段必须是 str。 + + 隐式字符串拼接折叠时的尾逗号会把 description 变成单元素元组, + 经 json 序列化成数组发往 provider(E501 折行 PR 曾引入,CR 拦截)。 + """ + def _walk(node): + if isinstance(node, dict): + for key, value in node.items(): + if key in ("description", "type", "enum") and not isinstance(value, (str, list)): + raise AssertionError( + f"schema {key} is {type(value).__name__}, expected str/list" + ) + _walk(value) + elif isinstance(node, list): + for item in node: + _walk(item) + + for spec in llm_service.tool_registry.list_specs(): + _walk(spec.input_schema) + + async def test_private_chat_rejected(tool_env): svc = _FakeService() out = await svc._tool_manage_scheduled_messages( diff --git a/tests/unit/llm/test_single_shot_entries.py b/tests/unit/llm/test_single_shot_entries.py index 1255913d..867837c8 100644 --- a/tests/unit/llm/test_single_shot_entries.py +++ b/tests/unit/llm/test_single_shot_entries.py @@ -67,7 +67,9 @@ async def test_defectify_empty_input_returns_usage(llm_service): assert result["llm_used"] is False -async def test_defectify_sensitive_input_blocked(llm_service, monkeypatch, tmp_path, patch_provider_builder): +async def test_defectify_sensitive_input_blocked( + llm_service, monkeypatch, tmp_path, patch_provider_builder +): stub = StubProviderClient() patch_provider_builder(lambda provider: stub) _block_filter(monkeypatch, tmp_path, "合成阻断词") @@ -165,7 +167,9 @@ async def test_defectify_empty_response_text(llm_service, patch_provider_builder assert result["llm_used"] is True -async def test_defectify_output_scan_blocked_falls_back(llm_service, monkeypatch, tmp_path, patch_provider_builder): +async def test_defectify_output_scan_blocked_falls_back( + llm_service, monkeypatch, tmp_path, patch_provider_builder +): stub = StubBehaviorProviderClient(LLMResponse(text="这回复带合成输出词", model="gpt-test")) patch_provider_builder(lambda provider: stub) _block_filter(monkeypatch, tmp_path, "合成输出词") @@ -192,7 +196,9 @@ async def test_turmfluch_empty_input_returns_usage(llm_service): assert result["llm_used"] is False -async def test_turmfluch_sensitive_input_blocked(llm_service, monkeypatch, tmp_path, patch_provider_builder): +async def test_turmfluch_sensitive_input_blocked( + llm_service, monkeypatch, tmp_path, patch_provider_builder +): stub = StubProviderClient() patch_provider_builder(lambda provider: stub) _block_filter(monkeypatch, tmp_path, "合成阻断词") @@ -275,7 +281,9 @@ async def test_turmfluch_unexpected_exception(llm_service, patch_provider_builde assert result["llm_used"] is True -async def test_turmfluch_output_scan_blocked_falls_back(llm_service, monkeypatch, tmp_path, patch_provider_builder): +async def test_turmfluch_output_scan_blocked_falls_back( + llm_service, monkeypatch, tmp_path, patch_provider_builder +): stub = StubBehaviorProviderClient(LLMResponse(text="疑虑了", model="gpt-test")) patch_provider_builder(lambda provider: stub) _block_filter(monkeypatch, tmp_path, "疑虑") # 词表名本身被合成过滤器拦下 @@ -310,7 +318,9 @@ async def test_card_le_nearest_success_returns_four_keys(llm_service, patch_prov assert "破防" in request.messages[-1].content -async def test_card_le_nearest_uses_quick_judge_model_override(llm_service, monkeypatch, patch_provider_builder): +async def test_card_le_nearest_uses_quick_judge_model_override( + llm_service, monkeypatch, patch_provider_builder +): monkeypatch.setattr(llm_service.config.quick_judge, "model", "gpt-alt") stub = StubBehaviorProviderClient(LLMResponse(text="狂宴了", model="gpt-alt")) patch_provider_builder(lambda provider: stub) @@ -331,7 +341,9 @@ async def test_card_le_nearest_no_provider_returns_none(llm_service): assert await llm_service.generate_card_le_nearest(captured="破防", **_CHAT) is None -async def test_card_le_nearest_sensitive_input_returns_none(llm_service, monkeypatch, tmp_path, patch_provider_builder): +async def test_card_le_nearest_sensitive_input_returns_none( + llm_service, monkeypatch, tmp_path, patch_provider_builder +): stub = StubBehaviorProviderClient(LLMResponse(text="疑虑了", model="gpt-test")) patch_provider_builder(lambda provider: stub) _block_filter(monkeypatch, tmp_path, "合成阻断词") @@ -353,7 +365,9 @@ async def test_card_le_nearest_invalid_name_returns_none(llm_service, patch_prov assert await llm_service.generate_card_le_nearest(captured="破防", **_CHAT) is None -async def test_card_le_nearest_output_scan_blocked_returns_none(llm_service, monkeypatch, tmp_path, patch_provider_builder): +async def test_card_le_nearest_output_scan_blocked_returns_none( + llm_service, monkeypatch, tmp_path, patch_provider_builder +): stub = StubBehaviorProviderClient(LLMResponse(text="疑虑了", model="gpt-test")) patch_provider_builder(lambda provider: stub) _block_filter(monkeypatch, tmp_path, "疑虑") diff --git a/tests/unit/llm/test_store.py b/tests/unit/llm/test_store.py index a2702519..2dde3c7e 100644 --- a/tests/unit/llm/test_store.py +++ b/tests/unit/llm/test_store.py @@ -71,7 +71,9 @@ def test_conversation_crop_deletes_below_floor(store: LLMStore) -> None: def test_conversation_list_since_returns_asc_with_ids(store: LLMStore) -> None: - store.append_conversation_message(1007, "u", "user", "q1", message_id="m1", raw_content="q1 raw") + store.append_conversation_message( + 1007, "u", "user", "q1", message_id="m1", raw_content="q1 raw" + ) store.append_conversation_message(1007, None, "assistant", "a1") store.append_conversation_message(1007, "u", "user", "q2", message_id="m2") all_rows = store.list_conversation_messages_since(1007, 0, limit=100) @@ -428,7 +430,8 @@ def test_group_settings_agent_delivery_half_migration(tmp_path: Path) -> None: allow_at INTEGER, updated_at TEXT NOT NULL ); - INSERT INTO group_settings (group_id, agent_delivery_enabled, agent_delivery_intermediate_enabled, updated_at) + INSERT INTO group_settings + (group_id, agent_delivery_enabled, agent_delivery_intermediate_enabled, updated_at) VALUES ('9101', 1, 0, '2026-09-11T00:00:00+00:00'); """ ) diff --git a/tests/unit/llm/test_summarize_period.py b/tests/unit/llm/test_summarize_period.py index e20edb2b..f63143f8 100644 --- a/tests/unit/llm/test_summarize_period.py +++ b/tests/unit/llm/test_summarize_period.py @@ -34,7 +34,11 @@ def _llm_config() -> LLMConfig: return LLMConfig( runtime=RuntimeConfig(default_provider="a", default_persona="default"), providers={"a": provider_a, "b": provider_b}, - personas={"default": PersonaConfig(id="default", display_name="默认", system_prompt="你是测试人格。")}, + personas={ + "default": PersonaConfig( + id="default", display_name="默认", system_prompt="你是测试人格。" + ) + }, ) @@ -53,7 +57,9 @@ def _assert_format_note(system_prompt: str) -> None: def _sample_messages(n: int = 5) -> list[dict]: - return [{"ts": 1600000000.0 + i * 3600, "sender": f"u{i}", "text": f"消息{i}"} for i in range(n)] + return [ + {"ts": 1600000000.0 + i * 3600, "sender": f"u{i}", "text": f"消息{i}"} for i in range(n) + ] @pytest.mark.asyncio diff --git a/tests/unit/llm/test_summarize_upgrade.py b/tests/unit/llm/test_summarize_upgrade.py index ab18a01e..206b9f3b 100644 --- a/tests/unit/llm/test_summarize_upgrade.py +++ b/tests/unit/llm/test_summarize_upgrade.py @@ -45,7 +45,11 @@ def _llm_config(providers: list[ProviderConfig]) -> LLMConfig: return LLMConfig( runtime=RuntimeConfig(default_provider=providers[0].id, default_persona="default"), providers={p.id: p for p in providers}, - personas={"default": PersonaConfig(id="default", display_name="默认", system_prompt="你是测试人格。")}, + personas={ + "default": PersonaConfig( + id="default", display_name="默认", system_prompt="你是测试人格。" + ) + }, ) @@ -106,7 +110,9 @@ async def test_daily_summary_wide_window_log_not_truncated(monkeypatch): stub = _StubClient(LLMResponse(text="日报", model="big", finish_reason="stop")) monkeypatch.setattr("quickquip.llm.summarize.build_provider_client", lambda p: stub) # 40 万字符日志:旧 300k 上限会截断,1M 窗口推导后应完整进入。 - big_log = [{"ts": 1600000000.0 + i, "sender": f"u{i%10}", "text": "聊" * 100} for i in range(4000)] + big_log = [ + {"ts": 1600000000.0 + i, "sender": f"u{i%10}", "text": "聊" * 100} for i in range(4000) + ] await generate_daily_summary( big_log, PersonaConfig(id="default", display_name="默认", system_prompt="s"), @@ -150,7 +156,9 @@ async def complete(self, request): return _Client() monkeypatch.setattr("quickquip.llm.summarize.build_provider_client", _builder) - big_log = [{"ts": 1600000000.0 + i, "sender": f"u{i%10}", "text": "聊" * 100} for i in range(5000)] + big_log = [ + {"ts": 1600000000.0 + i, "sender": f"u{i%10}", "text": "聊" * 100} for i in range(5000) + ] content, model_used = await generate_daily_summary( big_log, PersonaConfig(id="default", display_name="默认", system_prompt="s"), diff --git a/tests/unit/llm/test_tools_enabled_mode.py b/tests/unit/llm/test_tools_enabled_mode.py index ab2b2308..3eca2112 100644 --- a/tests/unit/llm/test_tools_enabled_mode.py +++ b/tests/unit/llm/test_tools_enabled_mode.py @@ -110,7 +110,9 @@ def test_config_warns_when_enabled_nonempty_without_mode(tmp_path, caplog): def test_config_no_append_semantics_warning_with_explicit_mode(tmp_path, caplog): config_path = tmp_path / "llm.toml" - config_path.write_text('[tools]\nenabled = ["draw_svg"]\nenabled_mode = "append"\n', encoding="utf-8") + config_path.write_text( + '[tools]\nenabled = ["draw_svg"]\nenabled_mode = "append"\n', encoding="utf-8" + ) with caplog.at_level(logging.WARNING, logger="quickquip.llm.config"): load_llm_config(config_path) assert not any("enabled_mode" in record.message for record in caplog.records) diff --git a/tests/unit/llm/test_usage_metering.py b/tests/unit/llm/test_usage_metering.py index 804228f5..469e7811 100644 --- a/tests/unit/llm/test_usage_metering.py +++ b/tests/unit/llm/test_usage_metering.py @@ -28,7 +28,10 @@ def _req() -> LLMRequest: async def test_complete_ok_records_usage(monkeypatch): calls = [] - async def spy(client, request, response, started, stream_used, state, error_msg="", finished_at=None): + async def spy( + client, request, response, started, stream_used, state, + error_msg="", finished_at=None, + ): calls.append((state, response is not None)) monkeypatch.setattr("quickquip.llm.usage._record_usage", spy) @@ -50,7 +53,10 @@ async def test_complete_does_not_await_usage_record(monkeypatch): entered = asyncio.Event() calls = [] - async def spy(client, request, response, started, stream_used, state, error_msg="", finished_at=None): + async def spy( + client, request, response, started, stream_used, state, + error_msg="", finished_at=None, + ): calls.append(state) entered.set() await gate.wait() @@ -74,7 +80,10 @@ async def spy(client, request, response, started, stream_used, state, error_msg= async def test_complete_error_records_state(monkeypatch): calls = [] - async def spy(client, request, response, started, stream_used, state, error_msg="", finished_at=None): + async def spy( + client, request, response, started, stream_used, state, + error_msg="", finished_at=None, + ): calls.append((state, response)) monkeypatch.setattr("quickquip.llm.usage._record_usage", spy) @@ -95,7 +104,10 @@ async def test_complete_cancelled_propagates(monkeypatch): CancelledError 正确传播且计量任务仍被调度执行。""" calls = [] - async def spy(client, request, response, started, stream_used, state, error_msg="", finished_at=None): + async def spy( + client, request, response, started, stream_used, state, + error_msg="", finished_at=None, + ): calls.append((state, response)) monkeypatch.setattr("quickquip.llm.usage._record_usage", spy) @@ -427,8 +439,13 @@ class FakeReq: model = "m" responses = [ - LLMResponse(text="完整", model="m", input_tokens=100, output_tokens=50, finish_reason="stop"), - LLMResponse(text="残稿", model="m", input_tokens=100, output_tokens=50, finish_reason="MAX_TOKENS"), + LLMResponse( + text="完整", model="m", input_tokens=100, output_tokens=50, finish_reason="stop" + ), + LLMResponse( + text="残稿", model="m", input_tokens=100, output_tokens=50, + finish_reason="MAX_TOKENS", + ), LLMResponse(text=" ", model="m", input_tokens=100, output_tokens=50, finish_reason="stop"), ] with usage_scope("summary", group_id="10001"): diff --git a/tests/unit/llm/test_usage_store.py b/tests/unit/llm/test_usage_store.py index 2a739027..d17fff22 100644 --- a/tests/unit/llm/test_usage_store.py +++ b/tests/unit/llm/test_usage_store.py @@ -40,13 +40,26 @@ def test_existing_schema_is_migrated_without_rewriting_rows(tmp_path): path = tmp_path / "old.db" with sqlite3.connect(path) as conn: - conn.execute("CREATE TABLE llm_usage_events (id INTEGER PRIMARY KEY, ts TEXT NOT NULL, provider_id TEXT NOT NULL, protocol TEXT NOT NULL, model TEXT NOT NULL, stream INTEGER NOT NULL, input_tokens INTEGER, output_tokens INTEGER, cost_usd REAL NOT NULL DEFAULT 0, priced INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'ok')") - conn.execute("INSERT INTO llm_usage_events (id, ts, provider_id, protocol, model, stream, input_tokens, output_tokens) VALUES (1, '2026-08-11T00:00:00+00:00', 'p', 'claude', 'm', 1, 10, 5)") + conn.execute( + "CREATE TABLE llm_usage_events (id INTEGER PRIMARY KEY, ts TEXT NOT NULL, " + "provider_id TEXT NOT NULL, protocol TEXT NOT NULL, model TEXT NOT NULL, " + "stream INTEGER NOT NULL, input_tokens INTEGER, output_tokens INTEGER, " + "cost_usd REAL NOT NULL DEFAULT 0, priced INTEGER NOT NULL DEFAULT 0, " + "state TEXT NOT NULL DEFAULT 'ok')" + ) + conn.execute( + "INSERT INTO llm_usage_events (id, ts, provider_id, protocol, model, stream, " + "input_tokens, output_tokens) VALUES (1, '2026-08-11T00:00:00+00:00', " + "'p', 'claude', 'm', 1, 10, 5)" + ) store = LLMUsageStore(path) - store.record({"provider_id": "p2", "protocol": "openai", "model": "m2", "stream": 0, "state": "ok"}) + store.record({"provider_id": "p2", "protocol": "openai", "model": "m2", + "stream": 0, "state": "ok"}) with store.connect() as conn: columns = {row[1] for row in conn.execute("PRAGMA table_info(llm_usage_events)")} - old = conn.execute("SELECT input_tokens, output_tokens FROM llm_usage_events WHERE id = 1").fetchone() + old = conn.execute( + "SELECT input_tokens, output_tokens FROM llm_usage_events WHERE id = 1" + ).fetchone() assert {"fresh_input_tokens", "total_tokens", "pricing_confidence"} <= columns assert (old[0], old[1]) == (10, 5) @@ -79,9 +92,12 @@ def test_envelope_tokens_migration_and_summary(tmp_path): ) """) store = LLMUsageStore(path) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok", "envelope_tokens": 400}) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok", "envelope_tokens": 600}) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok"}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok", "envelope_tokens": 400}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok", "envelope_tokens": 600}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok"}) with store.connect() as conn: columns = {row[1] for row in conn.execute("PRAGMA table_info(llm_usage_events)")} assert "envelope_tokens" in columns @@ -120,9 +136,12 @@ def test_epoch_history_tokens_migration_and_summary(tmp_path): ) """) store = LLMUsageStore(path) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok", "epoch_history_tokens": 4000}) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok", "epoch_history_tokens": 4400}) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok"}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok", "epoch_history_tokens": 4000}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok", "epoch_history_tokens": 4400}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok"}) with store.connect() as conn: columns = {row[1] for row in conn.execute("PRAGMA table_info(llm_usage_events)")} assert "epoch_history_tokens" in columns @@ -161,9 +180,12 @@ def test_media_image_count_migration_and_summary(tmp_path): ) """) store = LLMUsageStore(path) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok", "media_image_count": 1}) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok", "media_image_count": 3}) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok"}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok", "media_image_count": 1}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok", "media_image_count": 3}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok"}) with store.connect() as conn: columns = {row[1] for row in conn.execute("PRAGMA table_info(llm_usage_events)")} assert "media_image_count" in columns @@ -202,9 +224,12 @@ def test_patch_tokens_migration_and_summary(tmp_path): ) """) store = LLMUsageStore(path) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok", "patch_tokens": 300}) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok", "patch_tokens": 500}) - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, "state": "ok"}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok", "patch_tokens": 300}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok", "patch_tokens": 500}) + store.record({"provider_id": "p", "protocol": "claude", "model": "m", "stream": 1, + "state": "ok"}) with store.connect() as conn: columns = {row[1] for row in conn.execute("PRAGMA table_info(llm_usage_events)")} assert "patch_tokens" in columns @@ -218,7 +243,12 @@ def _create_legacy_usage_db(path): import sqlite3 with sqlite3.connect(path) as conn: - conn.execute("CREATE TABLE llm_usage_events (id INTEGER PRIMARY KEY, ts TEXT NOT NULL, provider_id TEXT NOT NULL, protocol TEXT NOT NULL, model TEXT NOT NULL, stream INTEGER NOT NULL, cost_usd REAL NOT NULL DEFAULT 0, priced INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'ok')") + conn.execute( + "CREATE TABLE llm_usage_events (id INTEGER PRIMARY KEY, ts TEXT NOT NULL, " + "provider_id TEXT NOT NULL, protocol TEXT NOT NULL, model TEXT NOT NULL, " + "stream INTEGER NOT NULL, cost_usd REAL NOT NULL DEFAULT 0, " + "priced INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'ok')" + ) def test_concurrent_first_open_migration_is_race_safe(tmp_path): diff --git a/tests/unit/scripts/test_backfill_chat_archive.py b/tests/unit/scripts/test_backfill_chat_archive.py index ac97a6ae..cc3f87f8 100644 --- a/tests/unit/scripts/test_backfill_chat_archive.py +++ b/tests/unit/scripts/test_backfill_chat_archive.py @@ -71,7 +71,9 @@ def record_result(self, *args, **kwargs): assert "归档现状:0 条 / 0 群(dry-run 未写入)" in output -def test_backfill_real_archive_retries_failed_rows_and_remains_idempotent(tmp_path, monkeypatch, capsys): +def test_backfill_real_archive_retries_failed_rows_and_remains_idempotent( + tmp_path, monkeypatch, capsys +): import sqlite3 from unittest.mock import patch from quickquip.chat.archive import ChatArchive @@ -89,7 +91,9 @@ def test_backfill_real_archive_retries_failed_rows_and_remains_idempotent(tmp_pa monkeypatch.setattr(sys, "argv", [str(SCRIPT_PATH)]) original = archive.record_result def fail_write(*args, **kwargs): - with patch.object(archive, "_connect", side_effect=sqlite3.OperationalError("database is locked")): + with patch.object( + archive, "_connect", side_effect=sqlite3.OperationalError("database is locked") + ): return original(*args, **kwargs) with patch.object(archive, "record_result", fail_write): assert module.main() == 1 diff --git a/tests/unit/scripts/test_backfill_record_identities.py b/tests/unit/scripts/test_backfill_record_identities.py index 0f937dfb..f41e85dc 100644 --- a/tests/unit/scripts/test_backfill_record_identities.py +++ b/tests/unit/scripts/test_backfill_record_identities.py @@ -11,7 +11,9 @@ from tests.fixtures.record_identities import snapshot as snapshot def test_backfill_preview_repeat_race_backup_and_match_parity(tmp_path, snapshot): - backfill = runpy.run_path(str(Path(__file__).resolve().parents[3] / "scripts/backfill_record_identities.py"))["backfill"] + backfill = runpy.run_path( + str(Path(__file__).resolve().parents[3] / "scripts/backfill_record_identities.py") + )["backfill"] path = tmp_path / "old.db" raw = "[CQ:at,name=旧名,qq=12345] 的事实" with sqlite3.connect(path) as conn: @@ -21,7 +23,9 @@ def test_backfill_preview_repeat_race_backup_and_match_parity(tmp_path, snapshot assert preview["convertible"] == 2 assert not list(tmp_path.glob("*.bak")) with sqlite3.connect(path) as conn: - assert "content_parts_json" not in [r[1] for r in conn.execute("PRAGMA table_info(memories)")] + assert "content_parts_json" not in [ + r[1] for r in conn.execute("PRAGMA table_info(memories)") + ] def race(row): if row["id"] == 2: with sqlite3.connect(path) as conn: @@ -29,10 +33,14 @@ def race(row): result = backfill(path, "memories", apply=True, batch_size=1, before_write=race) assert result["written"] == result["concurrent_skipped"] == 1 with sqlite3.connect(path) as conn: - content, body = conn.execute("SELECT content, content_parts_json FROM memories WHERE id=1").fetchone() + content, body = conn.execute( + "SELECT content, content_parts_json FROM memories WHERE id=1" + ).fetchone() assert content == raw for q in ["标准名", "别名", "12345", "旧名"]: - assert matches({"content": raw}, q, snapshot) == matches({"content": raw, "content_parts_json": body}, q, snapshot) + assert matches({"content": raw}, q, snapshot) == matches( + {"content": raw, "content_parts_json": body}, q, snapshot + ) assert references(decode(content, body)) == {"12345"} assert backfill(path, "memories", apply=True)["existing"] == 1 assert backfill(path, "memories", apply=True)["existing"] == 2 @@ -41,21 +49,31 @@ def race(row): def test_backfill_repairs_only_missing_index_and_failure_exit(tmp_path): - script = runpy.run_path(str(Path(__file__).resolve().parents[3] / "scripts/backfill_record_identities.py")) + script = runpy.run_path( + str(Path(__file__).resolve().parents[3] / "scripts/backfill_record_identities.py") + ) path = tmp_path / "quotes.db" store = GroupQuoteStore(path) - ident = store.add("10001", "23456", "名字", "", "99999", content_parts=legacy("[CQ:at,qq=12345]")) + ident = store.add( + "10001", "23456", "名字", "", "99999", content_parts=legacy("[CQ:at,qq=12345]") + ) with store._db: - encoded = store._db.execute("SELECT content_parts_json FROM quotes WHERE id=?", (ident,)).fetchone()[0] + encoded = store._db.execute( + "SELECT content_parts_json FROM quotes WHERE id=?", (ident,) + ).fetchone()[0] store._db.execute("DELETE FROM quotes_member_refs") store.close() result = script["backfill"](path, "quotes", apply=True) assert result["index_repaired"] == 1 with sqlite3.connect(path) as conn: - assert conn.execute("SELECT content_parts_json FROM quotes WHERE id=?", (ident,)).fetchone()[0] == encoded + assert conn.execute( + "SELECT content_parts_json FROM quotes WHERE id=?", (ident,) + ).fetchone()[0] == encoded assert conn.execute("SELECT qq FROM quotes_member_refs").fetchone()[0] == "12345" conn.execute("UPDATE quotes SET content_parts_json='invalid'") - assert script["main"](["--database", "quotes", "--path", str(path), "--preview-limit", "0"]) == 1 + assert script["main"]( + ["--database", "quotes", "--path", str(path), "--preview-limit", "0"] + ) == 1 @@ -67,11 +85,20 @@ def test_backfill_shipped_and_standalone_help(tmp_path): script = "scripts/backfill_record_identities.py" assert "!" + script in (root / ".dockerignore").read_text().splitlines() for dockerfile in ("Dockerfile", "prod.example/Dockerfile"): - assert any(line.startswith("COPY ") and script in line for line in (root / dockerfile).read_text().splitlines()) + assert any( + line.startswith("COPY ") and script in line + for line in (root / dockerfile).read_text().splitlines() + ) assert script in (root / "prod.example/deploy-manifest.txt").read_text().splitlines() workflow = (root / ".github/workflows/release.yml").read_text() assert "scripts\\backfill_record_identities.py --help" in workflow - assert any("Copy-Item" in line and "scripts\\backfill_record_identities.py" in line for line in workflow.splitlines()) - result = subprocess.run([sys.executable, "-I", str(root / script), "--help"], cwd=tmp_path, capture_output=True, text=True) + assert any( + "Copy-Item" in line and "scripts\\backfill_record_identities.py" in line + for line in workflow.splitlines() + ) + result = subprocess.run( + [sys.executable, "-I", str(root / script), "--help"], + cwd=tmp_path, capture_output=True, text=True, + ) assert result.returncode == 0, result.stderr assert "--apply" in result.stdout diff --git a/tests/unit/scripts/test_deploy_v4.py b/tests/unit/scripts/test_deploy_v4.py index fe22f8eb..27f2a5a0 100644 --- a/tests/unit/scripts/test_deploy_v4.py +++ b/tests/unit/scripts/test_deploy_v4.py @@ -61,11 +61,15 @@ def deployment(tmp_path): for name in ("llbot", "quickquip", "web-admin") }})) elif "--format" in args: - print(json.dumps({"services": {"quickquip": {"environment": {"ONEBOT_ACCESS_TOKEN": "new"}}}})) + print(json.dumps( + {"services": {"quickquip": {"environment": {"ONEBOT_ACCESS_TOKEN": "new"}}}} + )) elif "build" in args and os.environ.get("FAIL_BUILD") == "1": sys.exit(1) elif "up" in args: - if os.environ.get("FAIL_UP") == "all" or (os.environ.get("FAIL_UP") == "new" and release == os.environ["TEST_NEW"]): + if os.environ.get("FAIL_UP") == "all" or ( + os.environ.get("FAIL_UP") == "new" and release == os.environ["TEST_NEW"] + ): sys.exit(1) elif "ps" in args: print("test-container") @@ -75,7 +79,12 @@ def deployment(tmp_path): print("true") ''') docker.chmod(0o700) - env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}", TEST_ROOT=str(root), TEST_NEW=NEW) + env = dict( + os.environ, + PATH=f"{bin_dir}:{os.environ['PATH']}", + TEST_ROOT=str(root), + TEST_NEW=NEW, + ) return root, inbox, env @@ -101,7 +110,9 @@ def test_success_commits_environment_and_previous(deployment): @pytest.mark.parametrize("failure", ["build", "up"]) def test_failure_restores_original_files_and_links(deployment, failure): root, inbox, _ = deployment - result = run_deploy(deployment, **({"FAIL_BUILD": "1"} if failure == "build" else {"FAIL_UP": "new"})) + result = run_deploy( + deployment, **({"FAIL_BUILD": "1"} if failure == "build" else {"FAIL_UP": "new"}) + ) assert result.returncode == 1, result.stdout + result.stderr assert (root / ".env").read_text() == "ONEBOT_ACCESS_TOKEN=old\n" assert (root / "current").readlink() == Path("releases") / OLD @@ -134,9 +145,14 @@ def test_lock_rejects_second_action_before_live_mutation(deployment): assert not (root / "calls").exists() -@pytest.mark.parametrize("args", [["-Rollback", "-DryRun"], ["-Status", "-Migrate"], ["-Status", "-SkipHealth"]]) +@pytest.mark.parametrize( + "args", + [["-Rollback", "-DryRun"], ["-Status", "-Migrate"], ["-Status", "-SkipHealth"]], +) def test_bash_rejects_invalid_modes_before_side_effects(args): - result = subprocess.run(["bash", str(TEMPLATE / "deploy-v4.sh"), *args], capture_output=True, text=True) + result = subprocess.run( + ["bash", str(TEMPLATE / "deploy-v4.sh"), *args], capture_output=True, text=True + ) assert result.returncode != 0 assert "FAILED:" in result.stderr @@ -148,7 +164,9 @@ def test_shared_transaction_restores_token_and_missing_files(tmp_path): (incoming / "shared/prod").mkdir(parents=True) (incoming / "shared/.env").write_text("ONEBOT_ACCESS_TOKEN=new\r\n") (incoming / "shared/prod/sendkey.env").write_text("SENDKEY=synthetic\n") - (incoming / "candidate-compose.json").write_text(json.dumps({"services": {"quickquip": {"environment": {"ONEBOT_ACCESS_TOKEN": "new"}}}})) + (incoming / "candidate-compose.json").write_text( + json.dumps({"services": {"quickquip": {"environment": {"ONEBOT_ACCESS_TOKEN": "new"}}}}) + ) path = root / "prod/llbot-data/default_config.json" path.parent.mkdir(parents=True) original = b'{"ob11":{"connect":[{}, {"token":"old","url":"old-url"}]}}' diff --git a/tests/unit/sts/test_passive.py b/tests/unit/sts/test_passive.py index d2c59934..80513e7b 100644 --- a/tests/unit/sts/test_passive.py +++ b/tests/unit/sts/test_passive.py @@ -52,7 +52,9 @@ async def test_cache_avoids_repeat_llm_call(): async def test_regex_miss_returns_none_without_llm(): svc = FakeLLM() - assert await passive.match_card_le("我吃完饭了,好饱", llm_service=svc, group_id=1) is None # 了不在句末 + assert await passive.match_card_le( + "我吃完饭了,好饱", llm_service=svc, group_id=1 + ) is None # 了不在句末 assert await passive.match_card_le("睡了", llm_service=svc, group_id=1) is None # 仅 1 字 assert svc.calls == 0 diff --git a/tests/unit/tieba/test_crawler.py b/tests/unit/tieba/test_crawler.py index 4be5627e..80d885f6 100644 --- a/tests/unit/tieba/test_crawler.py +++ b/tests/unit/tieba/test_crawler.py @@ -49,7 +49,9 @@ def test_recognizes_captcha_markers(self, crawler: TiebaCrawler): assert crawler.is_challenge_page("", "", "访问受限") is True def test_normal_page_not_flagged(self, crawler: TiebaCrawler): - assert crawler.is_challenge_page("测试吧", "正常内容", "https://tieba.baidu.com/f?kw=测试") is False + assert crawler.is_challenge_page( + "测试吧", "正常内容", "https://tieba.baidu.com/f?kw=测试" + ) is False class TestExtractUrlsFromContent: diff --git a/tests/unit/web/test_awakening_routes.py b/tests/unit/web/test_awakening_routes.py index 3a3e662f..78e16df1 100644 --- a/tests/unit/web/test_awakening_routes.py +++ b/tests/unit/web/test_awakening_routes.py @@ -126,7 +126,10 @@ def test_render_keeps_scan_interval_fallback_dynamic(temp_awakening_config): def test_set_awakening_settings_queues_awakening_reload(monkeypatch, temp_awakening_config): captured: list[str] = [] - monkeypatch.setattr(awakening_route.action_queue, "enqueue", lambda action_type: captured.append(action_type) or {"id": "a1"}) + monkeypatch.setattr( + awakening_route.action_queue, "enqueue", + lambda action_type: captured.append(action_type) or {"id": "a1"}, + ) monkeypatch.setattr(awakening_route.audit_logger, "log", lambda *args, **kwargs: None) result = awakening_route.set_awakening_settings( diff --git a/tests/unit/web/test_config_routes.py b/tests/unit/web/test_config_routes.py index b16862aa..1b1f8cb9 100644 --- a/tests/unit/web/test_config_routes.py +++ b/tests/unit/web/test_config_routes.py @@ -132,7 +132,10 @@ def test_sensitive_words_config_key_is_not_writable(monkeypatch, tmp_path): def test_put_awakening_config_queues_reload(monkeypatch, tmp_path): base = _patch_config_dir(monkeypatch, tmp_path) captured: list[str] = [] - monkeypatch.setattr(config.action_queue, "enqueue", lambda action_type: captured.append(action_type) or {"id": "a1"}) + monkeypatch.setattr( + config.action_queue, "enqueue", + lambda action_type: captured.append(action_type) or {"id": "a1"}, + ) monkeypatch.setattr(config.audit_logger, "log", lambda *args, **kwargs: None) result = config.put_config( @@ -149,7 +152,10 @@ def test_put_awakening_config_queues_reload(monkeypatch, tmp_path): def test_put_chat_rules_config_queues_rules_reload(monkeypatch, tmp_path): _patch_config_dir(monkeypatch, tmp_path) captured: list[str] = [] - monkeypatch.setattr(config.action_queue, "enqueue", lambda action_type: captured.append(action_type) or {"id": "r1"}) + monkeypatch.setattr( + config.action_queue, "enqueue", + lambda action_type: captured.append(action_type) or {"id": "r1"}, + ) monkeypatch.setattr(config.audit_logger, "log", lambda *args, **kwargs: None) result = config.put_config( @@ -166,7 +172,10 @@ def test_put_llm_config_does_not_queue_reload(monkeypatch, tmp_path): """llm 改动不自动 reload——reload_runtime 含探活会静默扣费(opt-in)。""" _patch_config_dir(monkeypatch, tmp_path) captured: list[str] = [] - monkeypatch.setattr(config.action_queue, "enqueue", lambda action_type: captured.append(action_type) or {"id": "x1"}) + monkeypatch.setattr( + config.action_queue, "enqueue", + lambda action_type: captured.append(action_type) or {"id": "x1"}, + ) monkeypatch.setattr(config.audit_logger, "log", lambda *args, **kwargs: None) result = config.put_config( @@ -183,7 +192,10 @@ def test_put_llm_config_does_not_queue_reload(monkeypatch, tmp_path): def test_put_restart_needed_configs_do_not_queue_reload(monkeypatch, tmp_path, key): _patch_config_dir(monkeypatch, tmp_path) captured: list[str] = [] - monkeypatch.setattr(config.action_queue, "enqueue", lambda action_type: captured.append(action_type) or {"id": "g1"}) + monkeypatch.setattr( + config.action_queue, "enqueue", + lambda action_type: captured.append(action_type) or {"id": "g1"}, + ) monkeypatch.setattr(config.audit_logger, "log", lambda *args, **kwargs: None) result = config.put_config( diff --git a/tests/unit/web/test_conversation_deletion.py b/tests/unit/web/test_conversation_deletion.py index 1746c0be..3db19f0a 100644 --- a/tests/unit/web/test_conversation_deletion.py +++ b/tests/unit/web/test_conversation_deletion.py @@ -10,7 +10,12 @@ from quickquip.app.web.action_queue import WebAdminActionQueue from quickquip.app.web.routes import conversations, llm_runtime from quickquip.adapters.nonebot import web_admin_actions -from quickquip.llm.agent_records import TriggerKind, TurnOutputStatus, TextPolicy, TurnResponseRecord +from quickquip.llm.agent_records import ( + TriggerKind, + TurnOutputStatus, + TextPolicy, + TurnResponseRecord, +) from quickquip.llm.store import LLMStore from quickquip.llm.store_parts.agent_records import UserTriggerPayload @@ -31,7 +36,10 @@ def _seed(store): generation, _ = store.agent_scope_state("12345") handle = store.begin_loop( "12345", generation, TriggerKind.GROUP_DIRECT, - UserTriggerPayload(user_id="23456", sender_name="Test", canonical_name="", content="question", raw_content="question"), + UserTriggerPayload( + user_id="23456", sender_name="Test", canonical_name="", + content="question", raw_content="question", + ), ) store.commit_turn(handle, TurnResponseRecord( text="answer", text_policy=TextPolicy.ALLOWED, output_status=TurnOutputStatus.VISIBLE, @@ -69,14 +77,22 @@ def test_action_route_is_authenticated_and_read_only(setup): queue, _ = setup action_id = queue.enqueue("delete_conversation_row", {"row_id": 1})["id"] app = FastAPI() - app.include_router(llm_runtime.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + app.include_router( + llm_runtime.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) client = TestClient(app) response = client.get(f"/ops/api/llm-runtime/actions/{action_id}") assert response.status_code in (401, 503) for dependency in auth.protected_dependencies: app.dependency_overrides[dependency.dependency] = lambda: None - assert client.get(f"/ops/api/llm-runtime/actions/{action_id}").json()["action"]["status"] == "queued" + assert ( + client.get(f"/ops/api/llm-runtime/actions/{action_id}").json()["action"]["status"] + == "queued" + ) assert client.get("/ops/api/llm-runtime/actions/missing").status_code == 404 assert queue.get(action_id)["status"] == "queued" queue.fail(action_id, "worker failed") - assert client.get(f"/ops/api/llm-runtime/actions/{action_id}").json()["action"]["error"] == "worker failed" + assert ( + client.get(f"/ops/api/llm-runtime/actions/{action_id}").json()["action"]["error"] + == "worker failed" + ) diff --git a/tests/unit/web/test_group_settings_delivery_routes.py b/tests/unit/web/test_group_settings_delivery_routes.py index 4ec965b9..e3d1a9f5 100644 --- a/tests/unit/web/test_group_settings_delivery_routes.py +++ b/tests/unit/web/test_group_settings_delivery_routes.py @@ -70,7 +70,12 @@ def update_group_settings(self, group_id, **fields): agent_delivery_intermediate_enabled=True, agent_delivery_final_enabled=False ) assert routes.put_group_settings("10001", body, object()) == {"ok": True} - assert calls == [("10001", {"agent_delivery_intermediate_enabled": True, "agent_delivery_final_enabled": False})] + assert calls == [ + ( + "10001", + {"agent_delivery_intermediate_enabled": True, "agent_delivery_final_enabled": False}, + ) + ] # 旧键名被 Pydantic 静默忽略(不落库、不报错):旧前端 bundle 只带旧键 # 提交 → payload 为空 → 400;与其他字段一起提交 → 其余字段落库、开关不动。 @@ -101,7 +106,10 @@ def test_list_group_settings_projects_both_delivery_domains(monkeypatch, tmp_pat history_limit INTEGER, updated_at TEXT NOT NULL ); - INSERT INTO group_settings (group_id, agent_delivery_intermediate_enabled, agent_delivery_final_enabled, updated_at) + INSERT INTO group_settings ( + group_id, agent_delivery_intermediate_enabled, + agent_delivery_final_enabled, updated_at + ) VALUES ('10001', 1, 0, '2026-09-11T00:00:00+00:00'); """ ) diff --git a/tests/unit/web/test_groups_routes.py b/tests/unit/web/test_groups_routes.py index b4b9a38f..87936b6d 100644 --- a/tests/unit/web/test_groups_routes.py +++ b/tests/unit/web/test_groups_routes.py @@ -35,7 +35,9 @@ def test_set_weekly_group_enables(monkeypatch): monkeypatch.setattr(message_pipeline, "weekly_enabled_groups", fake) _patch_audit_noop(monkeypatch) - assert groups.set_weekly_group("10001", groups.GroupToggle(enabled=True), object()) == {"ok": True} + assert groups.set_weekly_group( + "10001", groups.GroupToggle(enabled=True), object() + ) == {"ok": True} assert fake.contains("10001") diff --git a/tests/unit/web/test_llm_about_routes.py b/tests/unit/web/test_llm_about_routes.py index dfdb8c80..1c3c9743 100644 --- a/tests/unit/web/test_llm_about_routes.py +++ b/tests/unit/web/test_llm_about_routes.py @@ -26,7 +26,9 @@ def test_list_llm_about_ignores_examples_and_invalid_dirs(monkeypatch, tmp_path) (base / "_example").mkdir(parents=True) (base / "abc").mkdir() (base / "1000000001").mkdir() - (base / "1000000001" / "vocab.yaml").write_text("核心成员:\n Alice: [阿丽]\n", encoding="utf-8") + (base / "1000000001" / "vocab.yaml").write_text( + "核心成员:\n Alice: [阿丽]\n", encoding="utf-8" + ) result = llm_about.list_llm_about() @@ -39,7 +41,9 @@ def test_put_llm_about_rejects_invalid_scope(monkeypatch, tmp_path): _patch_base(monkeypatch, tmp_path) with pytest.raises(HTTPException) as exc: - llm_about.put_llm_about_file("../config", "vocab", llm_about.LLMAboutContent(content=""), _mock_request()) + llm_about.put_llm_about_file( + "../config", "vocab", llm_about.LLMAboutContent(content=""), _mock_request() + ) assert exc.value.status_code == 422 @@ -48,7 +52,9 @@ def test_put_llm_about_rejects_unknown_kind(monkeypatch, tmp_path): _patch_base(monkeypatch, tmp_path) with pytest.raises(HTTPException) as exc: - llm_about.put_llm_about_file("global", "secret", llm_about.LLMAboutContent(content=""), _mock_request()) + llm_about.put_llm_about_file( + "global", "secret", llm_about.LLMAboutContent(content=""), _mock_request() + ) assert exc.value.status_code == 404 @@ -57,7 +63,9 @@ def test_put_llm_about_validates_vocab_shape(monkeypatch, tmp_path): _patch_base(monkeypatch, tmp_path) with pytest.raises(HTTPException) as exc: - llm_about.put_llm_about_file("global", "vocab", llm_about.LLMAboutContent(content="foo: bar\n"), _mock_request()) + llm_about.put_llm_about_file( + "global", "vocab", llm_about.LLMAboutContent(content="foo: bar\n"), _mock_request() + ) assert exc.value.status_code == 400 diff --git a/tests/unit/web/test_llm_runtime_routes.py b/tests/unit/web/test_llm_runtime_routes.py index 53d939fd..d9a38ce5 100644 --- a/tests/unit/web/test_llm_runtime_routes.py +++ b/tests/unit/web/test_llm_runtime_routes.py @@ -13,7 +13,9 @@ def test_health_check_is_queued_without_loading_llm_service(monkeypatch): monkeypatch.setattr( llm_runtime.action_queue, "enqueue", - lambda action_type, payload=None: captured.append((action_type, payload or {})) or {"id": "h1"}, + lambda action_type, payload=None: ( + captured.append((action_type, payload or {})) or {"id": "h1"} + ), ) monkeypatch.setattr(llm_runtime.audit_logger, "log", lambda *args, **kwargs: None) @@ -28,11 +30,15 @@ def test_health_check_accepts_explicit_scope(monkeypatch): monkeypatch.setattr( llm_runtime.action_queue, "enqueue", - lambda action_type, payload=None: captured.append((action_type, payload or {})) or {"id": "h1"}, + lambda action_type, payload=None: ( + captured.append((action_type, payload or {})) or {"id": "h1"} + ), ) monkeypatch.setattr(llm_runtime.audit_logger, "log", lambda *args, **kwargs: None) - llm_runtime.queue_health_check(llm_runtime.HealthBody(scope_key="private:123456", verbose=False), object()) + llm_runtime.queue_health_check( + llm_runtime.HealthBody(scope_key="private:123456", verbose=False), object() + ) assert captured == [("health_check", {"verbose": False, "scope_key": "private:123456"})] diff --git a/tests/unit/web/test_llm_usage_routes.py b/tests/unit/web/test_llm_usage_routes.py index ee95be38..6f96051e 100644 --- a/tests/unit/web/test_llm_usage_routes.py +++ b/tests/unit/web/test_llm_usage_routes.py @@ -37,7 +37,9 @@ def _seed_old(store: LLMUsageStore) -> None: "priced": 1, "state": "ok"}) old_ts = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat() with sqlite3.connect(store.path) as conn: - conn.execute("UPDATE llm_usage_events SET ts = ? WHERE provider_id = ?", (old_ts, "old-prov")) + conn.execute( + "UPDATE llm_usage_events SET ts = ? WHERE provider_id = ?", (old_ts, "old-prov") + ) def _cutoff(days: int) -> str: @@ -95,8 +97,16 @@ def test_summary_empty_store(tmp_path): def test_summary_filters_and_canonical_buckets(tmp_path): store = LLMUsageStore(tmp_path / "u.db") - store.record({"provider_id": "p", "protocol": "claude", "model": "m", "feature": "chat", "group_id": "g", "stream": 1, "input_tokens": 100, "fresh_input_tokens": 30, "total_tokens": 150, "input_token_semantics": "inclusive", "cache_read_tokens": 70, "output_tokens": 50, "cost_usd": 0.01, "priced": 1, "state": "ok", "duration_ms": 100}) - store.record({"provider_id": "q", "protocol": "openai", "model": "n", "feature": "other", "stream": 1, "input_tokens": 10, "output_tokens": 5, "cost_usd": 0.02, "priced": 1, "state": "ok"}) + store.record( + {"provider_id": "p", "protocol": "claude", "model": "m", "feature": "chat", + "group_id": "g", "stream": 1, "input_tokens": 100, "fresh_input_tokens": 30, + "total_tokens": 150, "input_token_semantics": "inclusive", + "cache_read_tokens": 70, "output_tokens": 50, "cost_usd": 0.01, + "priced": 1, "state": "ok", "duration_ms": 100} + ) + store.record({"provider_id": "q", "protocol": "openai", "model": "n", + "feature": "other", "stream": 1, "input_tokens": 10, + "output_tokens": 5, "cost_usd": 0.02, "priced": 1, "state": "ok"}) summary = store.summary(_cutoff(7), provider_id="p", feature="chat") assert summary["request_count"] == 1 assert summary["success_rate"] == 1.0 @@ -106,7 +116,9 @@ def test_summary_filters_and_canonical_buckets(tmp_path): def test_timeline_zero_fills_and_selects_metric(tmp_path): store = LLMUsageStore(tmp_path / "u.db") - store.record({"provider_id": "p", "protocol": "openai", "model": "m", "stream": 1, "input_tokens": 2, "output_tokens": 3, "cost_usd": 0.01, "priced": 1, "state": "ok"}) + store.record({"provider_id": "p", "protocol": "openai", "model": "m", "stream": 1, + "input_tokens": 2, "output_tokens": 3, "cost_usd": 0.01, + "priced": 1, "state": "ok"}) timeline = store.timeline(_cutoff(7), range_days=7, metric="requests") assert len(timeline) == 7 assert sum(point["value"] for point in timeline) == 1 @@ -126,9 +138,11 @@ def test_summary_and_timeline_share_aligned_window(tmp_path): 网格起点外的行被两者一致排除,趋势合计 == 总成本卡片。""" store = LLMUsageStore(tmp_path / "u.db") store.record({"provider_id": "p", "protocol": "openai", "model": "m", "stream": 1, - "input_tokens": 2, "output_tokens": 3, "cost_usd": 0.01, "priced": 1, "state": "ok"}) + "input_tokens": 2, "output_tokens": 3, "cost_usd": 0.01, + "priced": 1, "state": "ok"}) store.record({"provider_id": "out", "protocol": "openai", "model": "m", "stream": 1, - "input_tokens": 2, "output_tokens": 3, "cost_usd": 99.0, "priced": 1, "state": "ok"}) + "input_tokens": 2, "output_tokens": 3, "cost_usd": 99.0, + "priced": 1, "state": "ok"}) boundary = (window_start(7) - timedelta(hours=1)).isoformat() with sqlite3.connect(store.path) as conn: conn.execute("UPDATE llm_usage_events SET ts = ? WHERE provider_id = 'out'", (boundary,)) @@ -307,7 +321,8 @@ async def test_route_dimensions_only_accepts_range(monkeypatch, tmp_path): def test_events_cursor_pagination(tmp_path): store = LLMUsageStore(tmp_path / "u.db") for index in range(3): - store.record({"provider_id": "p", "protocol": "openai", "model": "m", "stream": 1, "state": "ok", "error_message": None}) + store.record({"provider_id": "p", "protocol": "openai", "model": "m", + "stream": 1, "state": "ok", "error_message": None}) first = store.events(cutoff=_cutoff(7), limit=2) assert len(first["items"]) == 2 assert first["next_cursor"] @@ -327,7 +342,8 @@ def test_utc8_early_morning_row_lands_on_business_day(tmp_path): """UTC+8 凌晨(UTC 前一日 17:00 后)的记录计入业务时区当日日桶。""" store = LLMUsageStore(tmp_path / "u.db") store.record({"provider_id": "early", "protocol": "openai", "model": "m", "stream": 1, - "input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01, "priced": 1, "state": "ok"}) + "input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01, + "priced": 1, "state": "ok"}) now_business = datetime.now(_BUSINESS_TZ) # 业务时区今日 01:30 = UTC 前一日 17:30(跨业务日界的凌晨记录) early_business = now_business.replace(hour=1, minute=30, second=0, microsecond=0) @@ -385,9 +401,11 @@ def test_summary_timeline_events_share_business_window(tmp_path): """summary / timeline / events 在业务时区窗口下口径一致。""" store = LLMUsageStore(tmp_path / "u.db") store.record({"provider_id": "p", "protocol": "openai", "model": "m", "stream": 1, - "input_tokens": 10, "output_tokens": 5, "cost_usd": 0.02, "priced": 1, "state": "ok"}) + "input_tokens": 10, "output_tokens": 5, "cost_usd": 0.02, + "priced": 1, "state": "ok"}) store.record({"provider_id": "q", "protocol": "openai", "model": "m", "stream": 1, - "input_tokens": 1, "output_tokens": 1, "cost_usd": 0.03, "priced": 1, "state": "ok"}) + "input_tokens": 1, "output_tokens": 1, "cost_usd": 0.03, + "priced": 1, "state": "ok"}) cutoff = _cutoff(7) summary = store.summary(cutoff) timeline = store.timeline(cutoff, range_days=7, metric="cost") diff --git a/tests/unit/web/test_logs_routes.py b/tests/unit/web/test_logs_routes.py index 256b6313..cc87aa4c 100644 --- a/tests/unit/web/test_logs_routes.py +++ b/tests/unit/web/test_logs_routes.py @@ -95,7 +95,9 @@ def test_list_logs_sorts_and_marks_current(monkeypatch, tmp_path): result = logs.list_logs() assert result["current_file"] == "quickquip_2026-05-10.log" - assert [item["name"] for item in result["files"]] == ["quickquip_2026-05-10.log", "quickquip_2026-05-09.log"] + assert [item["name"] for item in result["files"]] == [ + "quickquip_2026-05-10.log", "quickquip_2026-05-09.log" + ] assert result["files"][0]["is_current"] is True diff --git a/tests/unit/web/test_mcp_dashboard_routes.py b/tests/unit/web/test_mcp_dashboard_routes.py index fa2d7ec4..8c16db62 100644 --- a/tests/unit/web/test_mcp_dashboard_routes.py +++ b/tests/unit/web/test_mcp_dashboard_routes.py @@ -131,7 +131,9 @@ def test_dashboard_runtime_branch_exposes_era_tag(tmp_path, monkeypatch): bindings={}, ) monkeypatch.setattr(message_pipeline, "_ensure_llm_bindings", lambda: None) - monkeypatch.setattr(message_pipeline, "get_llm_service", lambda: SimpleNamespace(mcp_manager=manager)) + monkeypatch.setattr( + message_pipeline, "get_llm_service", lambda: SimpleNamespace(mcp_manager=manager) + ) server = mcp_dashboard.get_mcp_dashboard()["servers"][0] @@ -153,7 +155,9 @@ def test_dashboard_config_only_branch_era_tag_empty(tmp_path, monkeypatch): lambda: (_ for _ in ()).throw(RuntimeError("no runtime")), ) - server_entry = SimpleNamespace(id="cfg-server", transport="stdio", enabled=True, negotiation="modern") + server_entry = SimpleNamespace( + id="cfg-server", transport="stdio", enabled=True, negotiation="modern" + ) def _fake_load(_path): return SimpleNamespace(load_error=None, mcp=SimpleNamespace(servers=[server_entry])) diff --git a/tests/unit/web/test_period_reports_routes.py b/tests/unit/web/test_period_reports_routes.py index 9ac2ce6d..568fe3db 100644 --- a/tests/unit/web/test_period_reports_routes.py +++ b/tests/unit/web/test_period_reports_routes.py @@ -74,7 +74,9 @@ def test_get_text(db): def test_delete(db, monkeypatch): monkeypatch.setattr(period_reports.audit_logger, "log", lambda *a, **k: None) db.upsert("10001", PERIOD_WEEKLY, "2026-W24", "x", "m1") - assert period_reports.delete_period_report("10001", "weekly", "2026-W24", object()) == {"ok": True} + assert period_reports.delete_period_report( + "10001", "weekly", "2026-W24", object() + ) == {"ok": True} assert db.get("10001", PERIOD_WEEKLY, "2026-W24") is None @@ -177,7 +179,8 @@ def test_generation_log_triggers_migration_on_old_schema_db(monkeypatch, tmp_pat ) """) conn.execute( - "INSERT INTO period_reports (group_id, period_type, period_key, generated_at, content) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO period_reports " + "(group_id, period_type, period_key, generated_at, content) VALUES (?, ?, ?, ?, ?)", ("10001", "weekly", "2026-W24", "2026-06-14T06:00:00+00:00", "旧报文"), ) monkeypatch.setattr(period_reports, "_DB", db_path) diff --git a/tests/unit/web/test_quotes_routes.py b/tests/unit/web/test_quotes_routes.py index 69ab0c3c..c426960e 100644 --- a/tests/unit/web/test_quotes_routes.py +++ b/tests/unit/web/test_quotes_routes.py @@ -76,7 +76,9 @@ def _patch_sources(monkeypatch, rows, *, user_names, canonical_by_uid, llm_ok=Tr monkeypatch.setattr(message_pipeline, "group_quote_store", _FakeStore(rows)) from quickquip.app.identities import IdentitySnapshot, web_identities identity = _FakeIdentityIndex(canonical_by_uid if llm_ok else {}) - monkeypatch.setattr(web_identities, "snapshot", lambda gid: IdentitySnapshot(identity, user_names)) + monkeypatch.setattr( + web_identities, "snapshot", lambda gid: IdentitySnapshot(identity, user_names) + ) monkeypatch.setattr( message_pipeline, "get_sender_identity_sources", lambda gid: (user_names or None, identity), @@ -89,7 +91,9 @@ async def test_list_quotes_enriches_sender_display(monkeypatch): user_names={"u1": "新名片"}, canonical_by_uid={"u1": "规范名"}, ) - result = await quotes.list_quotes(group_id="g1", offset=0, limit=50, keyword="", request=object()) + result = await quotes.list_quotes( + group_id="g1", offset=0, limit=50, keyword="", request=object() + ) entry = result["entries"][0] assert entry["sender_display"] == "规范名" assert entry["sender_changed"] is True @@ -99,7 +103,9 @@ async def test_list_quotes_enriches_sender_display(monkeypatch): async def test_list_quotes_falls_back_to_canonical_without_stats(monkeypatch): _patch_sources(monkeypatch, [_row()], user_names={}, canonical_by_uid={"u1": "规范名"}) - result = await quotes.list_quotes(group_id="g1", offset=0, limit=50, keyword="", request=object()) + result = await quotes.list_quotes( + group_id="g1", offset=0, limit=50, keyword="", request=object() + ) entry = result["entries"][0] assert entry["sender_display"] == "规范名" assert entry["sender_changed"] is True @@ -111,7 +117,9 @@ async def test_list_quotes_degrades_to_snapshot_when_llm_unavailable(monkeypatch user_names={}, canonical_by_uid={}, llm_ok=False, ) - result = await quotes.list_quotes(group_id="g1", offset=0, limit=50, keyword="", request=object()) + result = await quotes.list_quotes( + group_id="g1", offset=0, limit=50, keyword="", request=object() + ) entry = result["entries"][0] assert entry["sender_display"] == "旧名片" assert entry["sender_changed"] is False @@ -131,7 +139,9 @@ async def test_list_quotes_falls_back_to_stats_without_llm(monkeypatch): user_names={"u1": "新名片"}, canonical_by_uid={}, llm_ok=False, ) - result = await quotes.list_quotes(group_id="g1", offset=0, limit=50, keyword="", request=object()) + result = await quotes.list_quotes( + group_id="g1", offset=0, limit=50, keyword="", request=object() + ) entry = result["entries"][0] assert entry["sender_display"] == "新名片" assert entry["sender_changed"] is True diff --git a/tests/unit/web/test_record_memory_routes.py b/tests/unit/web/test_record_memory_routes.py index d8a5581b..7134274d 100644 --- a/tests/unit/web/test_record_memory_routes.py +++ b/tests/unit/web/test_record_memory_routes.py @@ -14,7 +14,10 @@ def client(tmp_path, monkeypatch): monkeypatch.setattr(memory, "_DB", tmp_path / "llm.db") path = tmp_path / "identities.yaml" - path.write_text('people:\n - canonical_name: "标准名"\n qq_ids: ["12345"]\n aliases: ["别名"]\n', encoding="utf-8") + path.write_text( + 'people:\n - canonical_name: "标准名"\n qq_ids: ["12345"]\n aliases: ["别名"]\n', + encoding="utf-8", + ) monkeypatch.setattr(memory, "web_identities", IdentityRepository(path, tmp_path / "stats.json")) monkeypatch.setattr(memory.audit_logger, "log", lambda *args, **kwargs: None) app = FastAPI() @@ -35,9 +38,13 @@ def test_reference_edit_metadata_and_plain_client(client): assert result.status_code == 200 store = LLMStore(memory._DB) with store._connect() as conn: - before = conn.execute("SELECT content_parts_json FROM memories WHERE id=?", (ident,)).fetchone()[0] + before = conn.execute( + "SELECT content_parts_json FROM memories WHERE id=?", (ident,) + ).fetchone()[0] assert json.loads(before) == body - assert conn.execute("SELECT qq FROM memories_member_refs WHERE record_id=?", (ident,)).fetchone()[0] == "12345" + assert conn.execute( + "SELECT qq FROM memories_member_refs WHERE record_id=?", (ident,) + ).fetchone()[0] == "12345" # Old clients submit text; even a literal CQ example must stay text. text = "代码 [CQ:at,qq=12345]" assert client.put(f"/api/memory/10001/{ident}", json={"content": text}).status_code == 200 @@ -51,7 +58,14 @@ def test_reference_edit_metadata_and_plain_client(client): assert not conn.execute("SELECT * FROM memories_member_refs").fetchall() -@pytest.mark.parametrize("body", [{"version": 2, "parts": []}, {"version": 1, "parts": [{"type": "member", "qq": "oops"}]}, {"version": 1, "parts": [{"type": "text", "text": "a" * 4097}]}]) +@pytest.mark.parametrize( + "body", + [ + {"version": 2, "parts": []}, + {"version": 1, "parts": [{"type": "member", "qq": "oops"}]}, + {"version": 1, "parts": [{"type": "text", "text": "a" * 4097}]}, + ], +) def test_invalid_parts_rejected(client, body): assert client.post("/api/memory/10001", json={"content_parts": body}).status_code == 422 assert client.get("/api/memory/10001").json() == [] @@ -60,7 +74,9 @@ def test_invalid_parts_rejected(client, body): def test_candidates_from_web_files_and_scope(client, tmp_path): path = tmp_path / "10001" / "identities.yaml" path.parent.mkdir() - path.write_text('people:\n - canonical_name: "群名"\n qq_ids: ["12345"]\n aliases: ["群别名"]\n') + path.write_text( + 'people:\n - canonical_name: "群名"\n qq_ids: ["12345"]\n aliases: ["群别名"]\n' + ) (tmp_path / "stats.json").write_text(json.dumps({"10001": {"user_names": {"23456": "群名片"}}})) candidates = client.get("/api/members/10001?query=群").json() assert {m["qq"] for m in candidates} == {"12345", "23456"} diff --git a/tests/unit/web/test_summaries_health_route.py b/tests/unit/web/test_summaries_health_route.py index 357992a0..4e445760 100644 --- a/tests/unit/web/test_summaries_health_route.py +++ b/tests/unit/web/test_summaries_health_route.py @@ -41,10 +41,19 @@ def _insert_event( def test_summaries_health_aggregates_features(temp_usage_store): - _insert_event(temp_usage_store, feature="summary", state="ok", finish="STOP", outcome="accepted") - _insert_event(temp_usage_store, feature="summary", state="ok", finish="MAX_TOKENS", outcome="discarded_finish", model="flash") - _insert_event(temp_usage_store, feature="summary", state="error", finish=None, outcome="provider_error") - _insert_event(temp_usage_store, feature="summary", state="cancelled", finish=None, outcome="cancelled") + _insert_event( + temp_usage_store, feature="summary", state="ok", finish="STOP", outcome="accepted" + ) + _insert_event( + temp_usage_store, feature="summary", state="ok", finish="MAX_TOKENS", + outcome="discarded_finish", model="flash", + ) + _insert_event( + temp_usage_store, feature="summary", state="error", finish=None, outcome="provider_error" + ) + _insert_event( + temp_usage_store, feature="summary", state="cancelled", finish=None, outcome="cancelled" + ) _insert_event(temp_usage_store, feature="briefing", state="ok", finish="STOP") _insert_event(temp_usage_store, feature="chat", state="ok", finish="STOP") # 不在总结族,排除 @@ -168,7 +177,9 @@ async def test_real_cascade_persists_discarded_and_accepted_hops(temp_usage_stor assert [h["response_outcome"] for h in hops] == ["discarded_finish", "accepted"] -def test_generation_log_triggers_migration_on_old_schema_db(monkeypatch, tmp_path, temp_usage_store): +def test_generation_log_triggers_migration_on_old_schema_db( + monkeypatch, tmp_path, temp_usage_store +): """旧库(无 run_id 列)经 web 进程直调路由不 500:路由先触发惰性迁移(CR S2)。""" import sqlite3 @@ -184,7 +195,8 @@ def test_generation_log_triggers_migration_on_old_schema_db(monkeypatch, tmp_pat ) """) conn.execute( - "INSERT INTO summaries (group_id, summary_date, generated_at, content) VALUES (?, ?, ?, ?)", + "INSERT INTO summaries (group_id, summary_date, generated_at, content) " + "VALUES (?, ?, ?, ?)", ("10001", "2026-05-03", "2026-05-03T06:00:00+00:00", "旧报文"), ) monkeypatch.setattr(summaries_route, "_DB", db_path)