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,