From cc91cad4006101e26eadafa85c7822fdfc238543 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Sun, 6 Sep 2026 20:54:12 +0800 Subject: [PATCH] fix: support total-message rankings without disabling image fact checks --- app/api/groups.py | 3 + app/api/v2_ui_read.py | 7 +- app/db/models.py | 1 + app/db/repository.py | 1 + app/image/fact_verification.py | 3 +- app/pipeline/daily_pipeline.py | 2 +- app/pipeline/generation_stages.py | 9 +- app/ranking/policies.py | 8 +- app/scheduler/task_manifest.py | 1 + docs/TOTAL_MESSAGE_RANKING_ROLLOUT.md | 36 ++++++ frontend/src/api.ts | 2 + frontend/src/pages/v2/GroupDetail.tsx | 6 + frontend/src/pages/v2/rankingPolicy.test.ts | 2 +- scripts/configure_total_message_ranking.py | 113 ++++++++++++++++++ tests/test_generation_concurrency.py | 13 +- tests/test_image_fact_verification.py | 15 +++ tests/test_scheduler.py | 12 ++ ...est_total_message_ranking_configuration.py | 57 +++++++++ tests/test_v2_group_migration.py | 4 +- tests/test_v2_group_prompt_api.py | 21 ++++ tests/test_v2_ranking.py | 26 ++++ tests/test_v2_ui_router_contract.py | 14 ++- 22 files changed, 343 insertions(+), 13 deletions(-) create mode 100644 docs/TOTAL_MESSAGE_RANKING_ROLLOUT.md create mode 100644 scripts/configure_total_message_ranking.py create mode 100644 tests/test_total_message_ranking_configuration.py diff --git a/app/api/groups.py b/app/api/groups.py index 0d00682..dc26927 100644 --- a/app/api/groups.py +++ b/app/api/groups.py @@ -49,6 +49,7 @@ class GroupCreate(BaseModel): prompt_provider: str = "" summary_model: str = "" prompt_model: str = "" + strict_image_fact_check: bool = False image_enabled: bool = True send_target: str = "" ranking_template: str = "default" @@ -75,6 +76,7 @@ class GroupUpdate(BaseModel): prompt_provider: str | None = None summary_model: str | None = None prompt_model: str | None = None + strict_image_fact_check: bool | None = None image_enabled: bool | None = None send_target: str | None = None ranking_template: str | None = None @@ -240,6 +242,7 @@ def list_groups( "prompt_provider": g.prompt_provider, "summary_model": g.summary_model, "prompt_model": g.prompt_model, + "strict_image_fact_check": g.strict_image_fact_check, "image_enabled": g.image_enabled, "send_target": g.send_target, "effective_send_target": effective_send_target(g), diff --git a/app/api/v2_ui_read.py b/app/api/v2_ui_read.py index a9c4fab..7a3cdbe 100644 --- a/app/api/v2_ui_read.py +++ b/app/api/v2_ui_read.py @@ -73,12 +73,15 @@ def dashboard( image_url = "" if image_path.exists() and Path(image_path).stat().st_size > 0: image_url = f"/api/v2/files/{quote(name)}/{selected_run_date}/{FILE_IMAGE}" + ranking_count_policy = "all_messages" ranking_preview: list[dict[str, object]] = [] ranking_error = "" ranking_path = store.ranking_json_path(name, selected_run_date) if ranking_path.exists() and ranking_path.stat().st_size > 0: try: ranking = json.loads(ranking_path.read_text(encoding="utf-8")) + if isinstance(ranking, dict) and ranking.get("count_policy") == "text_primary_with_interactions": + ranking_count_policy = "text_primary_with_interactions" speakers = ranking.get("top_speakers", []) if isinstance(ranking, dict) else [] if not isinstance(speakers, list): raise ValueError("top_speakers 不是数组") @@ -114,9 +117,7 @@ def dashboard( getattr(group, "wechat_send_enabled", False) ), "ranking_template": group.ranking_template, - "ranking_count_policy": getattr( - group, "ranking_count_policy", "all_messages" - ), + "ranking_count_policy": ranking_count_policy, "image_prompt_template": group.image_prompt_template, "status": status, "period_start": run.get("period_start", ""), diff --git a/app/db/models.py b/app/db/models.py index 86c8467..2c25c7f 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -36,6 +36,7 @@ class Group(SQLModel, table=True): prompt_provider: str = "" # 日报 Prompt AI;空值继承全局 summary_model: str = "gpt-6-astra" # 总结主模型 prompt_model: str = "gpt-6-astra" # Prompt 主模型 + strict_image_fact_check: bool = False # 独立开启图片事实校验 image_enabled: bool = True # 是否生图 send_target: str = "" # 可选人工发送目标;为空时自动跟随 wechat_group_name ranking_template: str = "default" # 排行榜模板名 diff --git a/app/db/repository.py b/app/db/repository.py index c033319..62b96e0 100644 --- a/app/db/repository.py +++ b/app/db/repository.py @@ -183,6 +183,7 @@ def _ensure_relationship_schema_current() -> None: "prompt_model": "VARCHAR(64) NOT NULL DEFAULT 'gpt-6-astra'", "image_enabled": "BOOLEAN NOT NULL DEFAULT 1", "send_target": "VARCHAR(256) NOT NULL DEFAULT ''", + "strict_image_fact_check": "BOOLEAN NOT NULL DEFAULT 0", "ranking_template": "VARCHAR(64) NOT NULL DEFAULT 'default'", "ranking_count_policy": "VARCHAR(64) NOT NULL DEFAULT 'all_messages'", "sender_name_policy": "VARCHAR(64) NOT NULL DEFAULT 'resolved'", diff --git a/app/image/fact_verification.py b/app/image/fact_verification.py index e9b8422..18c1988 100644 --- a/app/image/fact_verification.py +++ b/app/image/fact_verification.py @@ -57,7 +57,8 @@ def strict_fact_verification_enabled(prompt_file: Path) -> bool: if not isinstance(run, dict): return False return bool( - run.get("image_fact_contract") == "strict_evidence_v1" + run.get("strict_image_fact_check") is True + or run.get("image_fact_contract") == "strict_evidence_v1" or run.get("ranking_count_policy") == RANKING_POLICY_TEXT_PRIMARY ) diff --git a/app/pipeline/daily_pipeline.py b/app/pipeline/daily_pipeline.py index fdbdd0b..fd63e05 100644 --- a/app/pipeline/daily_pipeline.py +++ b/app/pipeline/daily_pipeline.py @@ -154,7 +154,7 @@ def generate_all( "wechat_group_id", "wechat_group_name", "provider_preference", "schedule_rule", "summary_provider", "summary_model", "prompt_provider", "prompt_model", "image_enabled", "ranking_template", - "ranking_count_policy", "sender_name_policy", + "ranking_count_policy", "sender_name_policy", "strict_image_fact_check", "image_prompt_template", "image_theme", "image_theme_custom", "image_theme_remaining_runs", "image_prompt_override", "send_target", diff --git a/app/pipeline/generation_stages.py b/app/pipeline/generation_stages.py index 107fa6e..05d5b75 100644 --- a/app/pipeline/generation_stages.py +++ b/app/pipeline/generation_stages.py @@ -324,6 +324,9 @@ def _prepare_run( "period_start": context.period_start, "period_end": context.period_end, "send_time": self.settings.schedule_send_time, + "strict_image_fact_check": bool( + run.get("strict_image_fact_check", group.strict_image_fact_check) + ), "image_enabled": bool(group.image_enabled), "ranking_template": group.ranking_template, "ranking_count_policy": getattr( @@ -857,8 +860,12 @@ def _execute_prompt_operation( prompt_meta = committed.get("prompt_meta") self._record_prompt_timing(context, started_at) + run = self.store.load_run(context.group_name, context.run_date) if uses_strict_image_fact_contract( - getattr(context.group, "ranking_count_policy", "all_messages") + getattr(context.group, "ranking_count_policy", "all_messages"), + strict_image_fact_check=bool( + run.get("strict_image_fact_check", context.group.strict_image_fact_check) + ), ): prompt_path = self.store.prompt_path(context.group_name, context.run_date) strict_prompt = append_strict_image_fact_contract( diff --git a/app/ranking/policies.py b/app/ranking/policies.py index a8abde9..5350b8d 100644 --- a/app/ranking/policies.py +++ b/app/ranking/policies.py @@ -30,5 +30,9 @@ def normalize_sender_name_policy(value: object) -> str: return policy -def uses_strict_image_fact_contract(ranking_policy: object) -> bool: - return normalize_ranking_policy(ranking_policy) == RANKING_POLICY_TEXT_PRIMARY +def uses_strict_image_fact_contract( + ranking_policy: object, strict_image_fact_check: bool = False, +) -> bool: + # 兼容旧文字排行,允许全消息排行独立启用校验。 + policy = normalize_ranking_policy(ranking_policy) + return strict_image_fact_check or policy == RANKING_POLICY_TEXT_PRIMARY diff --git a/app/scheduler/task_manifest.py b/app/scheduler/task_manifest.py index 44bce85..a11464b 100644 --- a/app/scheduler/task_manifest.py +++ b/app/scheduler/task_manifest.py @@ -47,6 +47,7 @@ def build_expected_groups( "prompt_provider": str(getattr(group, "prompt_provider", "") or ""), "prompt_model": str(group.prompt_model or ""), "send_time": str(schedule_send_time or "08:30"), + "strict_image_fact_check": bool(getattr(group, "strict_image_fact_check", False)), "image_enabled": bool(group.image_enabled), "ranking_template": str(group.ranking_template or "default"), "ranking_count_policy": str( diff --git a/docs/TOTAL_MESSAGE_RANKING_ROLLOUT.md b/docs/TOTAL_MESSAGE_RANKING_ROLLOUT.md new file mode 100644 index 0000000..58f10c9 --- /dev/null +++ b/docs/TOTAL_MESSAGE_RANKING_ROLLOUT.md @@ -0,0 +1,36 @@ +# 六群总消息排行榜切换 + +## 行为 +下一次新任务使用 all_messages + default,所有可计数非系统消息各计一条。 +只展示发言人数、总消息与 Top 名单。旧模板、历史排名和图片保持原样。 +strict_image_fact_check 是独立布尔群配置,默认 false;本次六群设为 true。 +旧 text_primary_with_interactions 继续隐式开启严格图片校验。 +群 API 支持读写该字段;任务清单、run.json 和恢复路径保存该设置。 +Dashboard 根据选中 ranking.json 的 count_policy 展示,缺少字段按 all_messages 兼容。 + +## 正式生效步骤(尚未执行) +须先完成 PR 验收并获得用户当次合并、生产更新和服务重启授权。 +1. 核实正式监听进程、管理器、实际数据库路径和当天任务状态;如有在途生成或发送,等待完成。 +2. 记录当天及历史产物校验值和发送状态,在停止正式服务后确认 8766 已释放,调度器和独立任务进程均已退出。 +3. 在已更新代码的目录使用 Python 执行以下脚本。数据库参数必须是上一步核实的正式文件;不得使用工作目录中新建的空数据库。 +4. 应用后构建前端、按原管理方式启动服务,核实监听 PID、进程 ancestry、HTTP 健康、调度状态和群配置。 +5. 六个活动群 ID 23–28 应均为 all_messages / default / strict_image_fact_check=true;其他字段保持原值。核对历史产物校验值与发送状态未改变。不得通过真实取数、生图或发送来测试这次切换。 + +只读预览: +```powershell +python scripts/configure_total_message_ranking.py --database "已核实的数据库绝对路径" +``` + +取得授权且服务停止后应用: +```powershell +python scripts/configure_total_message_ranking.py --database "已核实的数据库绝对路径" --apply --service-stopped +``` + +脚本先验证数据库和活动群集合,再通过 SQLite backup API 生成同目录带时间戳的完整备份。 +事务中补齐新列、只更新六群的三个配置字段,并检查完整性、外键和所有其他群字段。 +活动群集合或旧配置发生变化时拒绝执行。脚本不读写日报产物、不调用网络业务接口。 + +## 回滚 +配置切换后若尚无新业务写入,可在服务停止状态下使用已验证备份恢复。 +若已有新业务写入,禁止整库覆盖;从备份读取六群旧配置,仅事务恢复三个字段,保留其他业务数据。 +独立图片校验列可保留,新代码仍兼容旧排行榜。 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index dc54f9d..fcfa600 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -75,6 +75,7 @@ export interface LatestReport { // ================= V2 ================= export interface GroupV2 extends Group { + strict_image_fact_check?: boolean; schedule_rule: string; send_time: string; summary_provider: string; @@ -97,6 +98,7 @@ export interface GroupV2 extends Group { } export interface GroupPayload { + strict_image_fact_check?: boolean; display_name: string; wechat_group_id: string; wechat_group_name?: string; diff --git a/frontend/src/pages/v2/GroupDetail.tsx b/frontend/src/pages/v2/GroupDetail.tsx index c505a61..c8f8480 100644 --- a/frontend/src/pages/v2/GroupDetail.tsx +++ b/frontend/src/pages/v2/GroupDetail.tsx @@ -61,6 +61,7 @@ const EMPTY_FORM: GroupPayload = { prompt_provider: "", summary_model: "", prompt_model: "", + strict_image_fact_check: false, image_enabled: true, send_target: "", ranking_template: "default", @@ -91,6 +92,7 @@ function toForm(group: GroupV2): GroupPayload { prompt_provider: group.prompt_provider || "", summary_model: group.summary_model || "", prompt_model: group.prompt_model || "", + strict_image_fact_check: group.strict_image_fact_check ?? false, image_enabled: group.image_enabled, send_target: group.send_target || "", ranking_template: group.ranking_template || "default", @@ -436,6 +438,10 @@ export default function GroupDetail({ groupId, invalidGroupId }: GroupDetailProp setField("image_enabled", event.target.checked)} /> 启用 AI 图片启用后会进入最多 2 路的受控生图阶段 + setField("send_target", event.target.value)} placeholder="留空则自动跟随微信当前群名" /> diff --git a/frontend/src/pages/v2/rankingPolicy.test.ts b/frontend/src/pages/v2/rankingPolicy.test.ts index f0b23eb..41f43b2 100644 --- a/frontend/src/pages/v2/rankingPolicy.test.ts +++ b/frontend/src/pages/v2/rankingPolicy.test.ts @@ -21,7 +21,7 @@ describe("ranking policy display", () => { it("keeps legacy rankings compatible", () => { expect(isTextPrimaryRanking("all_messages")).toBe(false); - expect(formatRankingCount("all_messages", { count: 89 })).toBe("89 条"); + expect(formatRankingCount("all_messages", { count: 89, text_count: 55, interaction_count: 34 })).toBe("89 条"); }); it("falls back safely when an early strict record lacks extended counts", () => { diff --git a/scripts/configure_total_message_ranking.py b/scripts/configure_total_message_ranking.py new file mode 100644 index 0000000..297972c --- /dev/null +++ b/scripts/configure_total_message_ranking.py @@ -0,0 +1,113 @@ +"""六群切换总消息排行。默认只读;--apply 仅可在正式服务停止后使用。""" +from __future__ import annotations + +import argparse +from datetime import datetime +import json +from pathlib import Path +import socket +import sqlite3 +import sys + +GROUP_IDS = (23, 24, 25, 26, 27, 28) + + +def inspect_groups(connection: sqlite3.Connection) -> list[dict]: + connection.row_factory = sqlite3.Row + rows = [dict(row) for row in connection.execute( + "SELECT * FROM groups WHERE enabled = 1 AND deleted_at IS NULL ORDER BY id" + )] + if tuple(row["id"] for row in rows) != GROUP_IDS: + raise ValueError("活动群已变化:必须重新核对目标,当前脚本只接受群 23–28") + for row in rows: + old = (row["ranking_count_policy"], row["ranking_template"]) + if old not in { + ("text_primary_with_interactions", "text_interactions"), + ("all_messages", "default"), + } or row["sender_name_policy"] != "wechat_data_analysis": + raise ValueError(f"群 {row['id']} 配置与预期不符,停止更新") + return rows + + +def check_database(connection: sqlite3.Connection) -> None: + if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok": + raise ValueError("数据库完整性校验失败") + if connection.execute("PRAGMA foreign_key_check").fetchall(): + raise ValueError("数据库外键校验失败") + + +def configure(database: Path, *, apply: bool = False) -> dict: + database = database.resolve(strict=True) + with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as source: + check_database(source) + before = inspect_groups(source) + preview = [ + { + "id": row["id"], + "display_name": row["display_name"], + "before": {key: row.get(key, False) for key in ( + "ranking_count_policy", "ranking_template", "strict_image_fact_check" + )}, + "after": { + "ranking_count_policy": "all_messages", + "ranking_template": "default", + "strict_image_fact_check": True, + }, + } + for row in before + ] + if not apply: + return {"applied": False, "database": str(database), "groups": preview} + backup = database.with_name( + database.name + ".ranking-backup-" + datetime.now().strftime("%Y%m%d-%H%M%S-%f") + ) + with sqlite3.connect(backup) as target: + source.backup(target) + check_database(target) + with sqlite3.connect(database) as connection: + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("BEGIN IMMEDIATE") + if inspect_groups(connection) != before: + raise ValueError("备份后配置发生变化,停止更新") + columns = {row[1] for row in connection.execute("PRAGMA table_info(groups)")} + if "strict_image_fact_check" not in columns: + connection.execute( + "ALTER TABLE groups ADD COLUMN strict_image_fact_check BOOLEAN NOT NULL DEFAULT 0" + ) + connection.execute( + "UPDATE groups SET ranking_count_policy='all_messages', " + "ranking_template='default', strict_image_fact_check=1 " + "WHERE id IN (23,24,25,26,27,28)" + ) + after = inspect_groups(connection) + for old, new in zip(before, after): + expected = { + **old, "ranking_count_policy": "all_messages", + "ranking_template": "default", "strict_image_fact_check": 1, + } + if new != expected: + raise ValueError("更新后的群配置不符合预期") + check_database(connection) + return {"applied": True, "database": str(database), "backup": str(backup), "groups": preview} + + +def main() -> None: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--database", type=Path, required=True) + parser.add_argument("--apply", action="store_true") + parser.add_argument("--service-stopped", action="store_true") + args = parser.parse_args() + if args.apply: + if not args.service_stopped: + parser.error("应用前须授权并停止正式服务,再加 --service-stopped") + with socket.socket() as probe: + probe.settimeout(1) + if probe.connect_ex(("127.0.0.1", 8766)) == 0: + parser.error("8766 仍在监听,拒绝在正式服务运行时更新") + print(json.dumps(configure(args.database, apply=args.apply), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_generation_concurrency.py b/tests/test_generation_concurrency.py index ed034d0..66169e0 100644 --- a/tests/test_generation_concurrency.py +++ b/tests/test_generation_concurrency.py @@ -256,7 +256,10 @@ def test_prompt_ready_group_starts_image_before_other_prompts_finish(tmp_path, m assert all(item["status"] == "ready_to_send" for item in results) -def test_group_overrides_rebuild_loaded_group_with_live_sqlalchemy_state(tmp_path, monkeypatch): +@pytest.mark.parametrize("saved_fact_check", [None, True, False]) +def test_group_overrides_rebuild_loaded_group_with_live_sqlalchemy_state( + tmp_path, monkeypatch, saved_fact_check, +): engine = create_engine( f"sqlite:///{tmp_path / 'group-overrides.db'}", connect_args={"check_same_thread": False}, @@ -289,12 +292,17 @@ def test_group_overrides_rebuild_loaded_group_with_live_sqlalchemy_state(tmp_pat dry_run=True, ) + if saved_fact_check is not None: + pipeline.store.update( + "覆盖配置群", "2026-08-21", strict_image_fact_check=saved_fact_check, + ) results = pipeline.generate_all( run_date="2026-08-21", group_overrides={ group_id: { "image_enabled": False, "image_theme": "ai_free", + "strict_image_fact_check": True, } }, ) @@ -309,6 +317,9 @@ def test_group_overrides_rebuild_loaded_group_with_live_sqlalchemy_state(tmp_pat run = pipeline.store.load_run("覆盖配置群", "2026-08-21") assert run["image_enabled"] is False assert run["image_theme"] == "ai_free" + expected_fact_check = True if saved_fact_check is None else saved_fact_check + assert run["strict_image_fact_check"] is expected_fact_check + assert (run.get("image_fact_contract") == "strict_evidence_v1") is expected_fact_check def test_unexpected_worker_and_image_errors_are_isolated(tmp_path, monkeypatch): diff --git a/tests/test_image_fact_verification.py b/tests/test_image_fact_verification.py index a271d17..ab5c3b6 100644 --- a/tests/test_image_fact_verification.py +++ b/tests/test_image_fact_verification.py @@ -253,3 +253,18 @@ def test_strict_contract_removes_bmi_display_instructions(): assert "话题延伸到身高和婚后发福" in strict_prompt assert "猜体重" in strict_prompt assert "我的" not in strict_prompt + + +def test_independent_fact_check_survives_all_message_ranking(tmp_path): + import json + from app.image.fact_verification import strict_fact_verification_enabled + + prompt = tmp_path / "prompt.txt" + (tmp_path / "run.json").write_text(json.dumps({ + "ranking_count_policy": "all_messages", "strict_image_fact_check": True, + }), encoding="utf-8") + assert strict_fact_verification_enabled(prompt) is True + (tmp_path / "run.json").write_text(json.dumps({ + "ranking_count_policy": "all_messages", "strict_image_fact_check": False, + }), encoding="utf-8") + assert strict_fact_verification_enabled(prompt) is False diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 3c1e498..daa2d4a 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -918,3 +918,15 @@ def add_job(self, func, **kwargs): send_at = scheduler.jobs[scheduled[1]][1]["trigger"].run_date assert generation_at.isoformat() == "2026-08-29T08:45:00+08:00" assert send_at.isoformat() == "2026-08-29T08:46:00+08:00" + + +def test_expected_groups_snapshot_keeps_independent_fact_check(): + from datetime import date + from app.db.models import Group + from app.scheduler.task_manifest import build_expected_groups + + group = Group(id=23, display_name="快照群", strict_image_fact_check=True) + snapshot = build_expected_groups([group], date(2026, 9, 7), timezone="Asia/Shanghai")[0] + group.strict_image_fact_check = False + assert snapshot["strict_image_fact_check"] is True + assert snapshot["ranking_count_policy"] == "all_messages" diff --git a/tests/test_total_message_ranking_configuration.py b/tests/test_total_message_ranking_configuration.py new file mode 100644 index 0000000..67e91e6 --- /dev/null +++ b/tests/test_total_message_ranking_configuration.py @@ -0,0 +1,57 @@ +import sqlite3 + +import pytest + +from scripts.configure_total_message_ranking import configure + + +def make_database(path): + with sqlite3.connect(path) as conn: + conn.execute("""CREATE TABLE groups ( + id INTEGER PRIMARY KEY, display_name TEXT, enabled BOOLEAN, + deleted_at TEXT, ranking_count_policy TEXT, ranking_template TEXT, + sender_name_policy TEXT, wechat_send_enabled BOOLEAN)""") + for group_id in range(23, 30): + conn.execute( + "INSERT INTO groups VALUES (?, ?, ?, NULL, ?, ?, ?, 0)", + (group_id, f"群{group_id}", group_id < 29, + "text_primary_with_interactions", "text_interactions", + "wechat_data_analysis"), + ) + + +def test_preview_is_readonly_and_apply_preserves_other_fields_and_backup(tmp_path): + path = tmp_path / "groups.db" + make_database(path) + original = path.read_bytes() + assert configure(path)["applied"] is False + assert path.read_bytes() == original + result = configure(path, apply=True) + assert result["applied"] is True + with sqlite3.connect(result["backup"]) as backup: + assert "strict_image_fact_check" not in { + row[1] for row in backup.execute("PRAGMA table_info(groups)") + } + assert backup.execute("SELECT COUNT(*) FROM groups").fetchone()[0] == 7 + with sqlite3.connect(path) as conn: + assert conn.execute( + "SELECT COUNT(*) FROM groups WHERE ranking_count_policy='all_messages' " + "AND ranking_template='default' AND strict_image_fact_check=1" + ).fetchone()[0] == 6 + assert conn.execute("SELECT wechat_send_enabled FROM groups").fetchall() == [(0,)] * 7 + assert conn.execute( + "SELECT ranking_count_policy, strict_image_fact_check FROM groups WHERE id=29" + ).fetchone() == ("text_primary_with_interactions", 0) + assert configure(path, apply=True)["applied"] is True + + +def test_unexpected_active_group_is_rejected_without_writes(tmp_path): + path = tmp_path / "groups.db" + make_database(path) + with sqlite3.connect(path) as conn: + conn.execute("UPDATE groups SET enabled=1 WHERE id=29") + original = path.read_bytes() + with pytest.raises(ValueError, match="活动群已变化"): + configure(path, apply=True) + assert path.read_bytes() == original + assert not list(tmp_path.glob("*.ranking-backup-*")) diff --git a/tests/test_v2_group_migration.py b/tests/test_v2_group_migration.py index 18b26bd..8fefbc1 100644 --- a/tests/test_v2_group_migration.py +++ b/tests/test_v2_group_migration.py @@ -21,7 +21,7 @@ def test_group_prompt_and_wechat_columns_migrate_idempotently_with_safe_defaults with engine.connect() as connection: columns = [row[1] for row in connection.exec_driver_sql("PRAGMA table_info(groups)")] row = connection.exec_driver_sql( - "SELECT image_prompt_override, wechat_send_enabled, deleted_at FROM groups WHERE id = 1" + "SELECT image_prompt_override, wechat_send_enabled, deleted_at, strict_image_fact_check FROM groups WHERE id = 1" ).one() assert columns.count("image_prompt_override") == 1 assert columns.count("wechat_send_enabled") == 1 @@ -29,6 +29,8 @@ def test_group_prompt_and_wechat_columns_migrate_idempotently_with_safe_defaults assert row[0] == "" assert bool(row[1]) is False assert row[2] is None + assert columns.count("strict_image_fact_check") == 1 + assert bool(row[3]) is False def test_group_queries_hide_deleted_but_keep_history_lookup(tmp_path, monkeypatch): diff --git a/tests/test_v2_group_prompt_api.py b/tests/test_v2_group_prompt_api.py index ffd97cd..94f60e9 100644 --- a/tests/test_v2_group_prompt_api.py +++ b/tests/test_v2_group_prompt_api.py @@ -108,3 +108,24 @@ def test_named_theme_preview_only_replaces_canonical_theme_section(): assert "不透明水粉社论" in resolved["prompt"] assert "张三说今天完成 3 项工作。" in resolved["prompt"] assert resolved["prompt"].count("【漫画分镜】") == 1 + + +def test_independent_fact_check_api_defaults_and_patch_preservation(): + with client: + response = client.post("/api/groups", json={"display_name": "独立事实校验测试"}) + assert response.status_code == 200 + group_id = response.json()["id"] + try: + def current(): + return next(g for g in client.get("/api/groups").json() if g["id"] == group_id) + assert current()["strict_image_fact_check"] is False + assert client.put(f"/api/groups/{group_id}", json={ + "strict_image_fact_check": True, "ranking_count_policy": "all_messages", + }).status_code == 200 + assert client.put(f"/api/groups/{group_id}", json={ + "ranking_template": "default", + }).status_code == 200 + assert current()["strict_image_fact_check"] is True + assert current()["ranking_count_policy"] == "all_messages" + finally: + client.delete(f"/api/groups/{group_id}") diff --git a/tests/test_v2_ranking.py b/tests/test_v2_ranking.py index ef906bd..134c263 100644 --- a/tests/test_v2_ranking.py +++ b/tests/test_v2_ranking.py @@ -20,6 +20,8 @@ def test_strict_image_fact_contract_is_policy_driven_for_every_group(): assert uses_strict_image_fact_contract("text_primary_with_interactions") is True assert uses_strict_image_fact_contract("all_messages") is False + assert uses_strict_image_fact_contract("all_messages", True) is True + assert uses_strict_image_fact_contract("text_primary_with_interactions", False) is True def _msg( @@ -275,3 +277,27 @@ def test_ranking_json_structure(): assert d["top_speakers"][0]["interaction_count"] == 0 assert d["top_speakers"][0]["name_source"] == "resolved" assert len(d["top_speakers"][0]["identity_key"]) == 16 + + +def test_all_messages_ranks_media_only_members_and_renders_without_interactions(): + from app.ranking.renderer import RankingRenderer + + messages = [ + _msg("文字群友", i=1), _msg("文字群友", i=2), + *[_msg("媒体群友", kind, i=index) for index, kind in enumerate( + ["image", "emoji", "voice", "video", "file", "link", "quote", + "red_packet", "transfer", "other"], start=3)], + _msg("系统", "system", "有人加入群聊", i=20), + ] + result = engine.compute( + messages, "测试群", PERIOD_START, PERIOD_END, top_limit=1, + count_policy="all_messages", name_source="wechat_data_analysis", + ) + assert result.message_count == 12 + assert result.speaker_count == 2 + assert [(s.name, s.count) for s in result.top_speakers] == [("媒体群友", 10)] + assert result.top_speakers[0].text_count == 0 + text = RankingRenderer().render(result, "default") + assert "媒体群友【10】" in text + assert "总消息:12" in text + assert "互动" not in text diff --git a/tests/test_v2_ui_router_contract.py b/tests/test_v2_ui_router_contract.py index 477a48d..23dbfd6 100644 --- a/tests/test_v2_ui_router_contract.py +++ b/tests/test_v2_ui_router_contract.py @@ -2,6 +2,8 @@ import json from types import SimpleNamespace +import pytest + from fastapi import FastAPI from app.api import v2_ui, v2_ui_read @@ -233,11 +235,17 @@ def ranking_json_path(self, _group_name, _run_date): assert runtime_group["send"]["status"] == "success" +@pytest.mark.parametrize("current_policy, saved_policy, expected_policy", [ + ("all_messages", "text_primary_with_interactions", "text_primary_with_interactions"), + ("text_primary_with_interactions", "all_messages", "all_messages"), + ("text_primary_with_interactions", None, "all_messages"), +]) def test_dashboard_accepts_run_date_and_returns_top_five_ranking_preview( - tmp_path, monkeypatch + tmp_path, monkeypatch, current_policy, saved_policy, expected_policy ) -> None: group = SimpleNamespace( id=8, + ranking_count_policy=current_policy, display_name="排行测试群", wechat_group_name="排行测试群", send_time="08:30", @@ -254,7 +262,8 @@ def test_dashboard_accepts_run_date_and_returns_top_five_ranking_preview( "top_speakers": [ {"rank": index, "name": f"成员{index}", "count": 20 - index} for index in range(1, 8) - ] + ], + **({"count_policy": saved_policy} if saved_policy else {}), }, ensure_ascii=False, ), @@ -299,6 +308,7 @@ def ranking_json_path(self, _group_name, _run_date): ] assert result["runtime"]["groups"][0]["current_node"] == "prompt" assert result["daily_status"]["overall_status"] == result["runtime"]["overall_status"] + assert result["cards"][0]["ranking_count_policy"] == expected_policy assert len(result["cards"][0]["ranking_preview"]) == 5 assert result["cards"][0]["ranking_preview"][0] == { "rank": 1,