"""智能采纳:什么条件下才敢自动动手,以及自动驳回不能像人工驳回那样是终局。""" from __future__ import annotations import uuid import pytest from server import autoreview, review from server.autoreview import Policy from server.db import database as hub def entry(**over) -> dict: return { "id": 1, "knowledge_id": "k", "business_id": "b", "key": "班级下拉框", "value": "班级是下拉框,要先点开再从选项里选", "category": "control_tip", "school_count": 1, "observations": 1, **over, } ADOPT = ("adopt", "跨校成立") REVIEW = ("review", "证据不足") REJECT = ("reject", "内容太短") @pytest.fixture def no_llm(monkeypatch): """没配模型 Key:只有规则层说话。""" from server.config import settings monkeypatch.setattr(settings, "llm_api_key", "") @pytest.fixture def with_llm(monkeypatch): from server.config import settings monkeypatch.setattr(settings, "llm_api_key", "sk-test") class TestCategoryGate: """系统规则和其他类只有一条路能过:Agent 在真实页面上验过。靠猜的一律不放行。""" @pytest.mark.parametrize("category", ["rule", "note"]) def test_guessing_never_unlocks_a_high_risk_category(self, with_llm, category): # 跨校观察 + 模型点头都只是「反复想到」,不是「去页面上对过」 plan = autoreview.decide(entry(category=category, school_count=5, observations=99), ADOPT, ("adopt", "模型很确定"), Policy()) assert plan["action"] == "hold" and "必须人工确认" in plan["reason"] @pytest.mark.parametrize("category", ["rule", "note"]) def test_real_page_verification_unlocks_it(self, no_llm, category): plan = autoreview.decide(entry(category=category, confirmed=2), REVIEW, None, Policy()) assert plan["action"] == "verify" assert plan["scope"] == "origin" # 实测只在本校做过,别急着推给别人 assert "真实页面上验证相符" in plan["reason"] def test_one_verification_is_not_enough(self, no_llm): plan = autoreview.decide(entry(category="rule", confirmed=1), REVIEW, None, Policy()) assert plan["action"] == "hold" def test_a_single_contradiction_cancels_all_verifications(self, no_llm): plan = autoreview.decide(entry(category="rule", confirmed=5, contradicted=1), REVIEW, None, Policy()) assert plan["action"] == "hold" class TestTrialEvidence: """实测证据是最硬的,排在跨校观察之前。""" def test_trial_beats_thin_observation_counts(self, no_llm): plan = autoreview.decide(entry(school_count=1, observations=1, confirmed=2), REVIEW, None, Policy()) assert plan["action"] == "verify" and "真实页面上验证相符 2 次" in plan["reason"] def test_trial_proven_helper(self): assert autoreview.trial_proven({"confirmed": 2, "contradicted": 0}, Policy()) is True assert autoreview.trial_proven({"confirmed": 2, "contradicted": 1}, Policy()) is False assert autoreview.trial_proven({"confirmed": 1, "contradicted": 0}, Policy()) is False def test_threshold_is_configurable(self): assert autoreview.trial_proven({"confirmed": 1, "contradicted": 0}, Policy(min_trials=1)) is True @pytest.mark.parametrize("category", ["control_tip", "path"]) def test_low_risk_categories_can_be_adopted(self, no_llm, category): plan = autoreview.decide(entry(category=category, school_count=2), ADOPT, None, Policy()) assert plan["action"] == "verify" class TestEvidencePath: """A 路:证据够硬。""" def test_cross_school_adopts_and_publishes_to_everyone(self, no_llm): plan = autoreview.decide(entry(school_count=2), ADOPT, None, Policy()) assert plan["action"] == "verify" and plan["scope"] == "all" def test_single_school_with_enough_observations_stays_local(self, no_llm): plan = autoreview.decide(entry(observations=3), ADOPT, None, Policy()) assert plan["action"] == "verify" and plan["scope"] == "origin" def test_thin_evidence_without_a_model_is_held(self, no_llm): plan = autoreview.decide(entry(), REVIEW, None, Policy()) assert plan["action"] == "hold" and "未配模型 Key" in plan["reason"] def test_the_model_can_veto_strong_evidence(self, with_llm): plan = autoreview.decide(entry(school_count=5), ADOPT, ("review", "表述含糊"), Policy(require_llm=True)) assert plan["action"] == "hold" and "模型不认可" in plan["reason"] def test_veto_can_be_turned_off(self, with_llm): plan = autoreview.decide(entry(school_count=5), ADOPT, ("review", "表述含糊"), Policy(require_llm=False)) assert plan["action"] == "verify" class TestModelEndorsementPath: """B 路:证据薄但模型背书,只发回来源学校。""" def test_model_endorsement_substitutes_for_observations(self, with_llm): plan = autoreview.decide(entry(), REVIEW, ("adopt", "可复用的操作知识"), Policy()) assert plan["action"] == "verify" assert plan["scope"] == "origin" # 没有跨校证据就不往别的学校推 assert "证据尚薄" in plan["reason"] def test_model_endorsement_cannot_bypass_the_category_gate(self, with_llm): plan = autoreview.decide(entry(category="rule"), REVIEW, ("adopt", "很有用"), Policy()) assert plan["action"] == "hold" def test_a_rewrite_suggestion_holds_instead_of_adopting_the_original(self, with_llm): reason = f"内容可复用\n{review.REWRITE_MARKER}改写后的更清晰表述" plan = autoreview.decide(entry(), REVIEW, ("adopt", reason), Policy()) assert plan["action"] == "hold" and "建议改写" in plan["reason"] def test_model_silence_holds(self, with_llm): plan = autoreview.decide(entry(), REVIEW, ("review", "拿不准"), Policy()) assert plan["action"] == "hold" class TestAutoReject: def test_both_layers_agree_it_is_junk(self, no_llm): plan = autoreview.decide(entry(value="太短"), REJECT, None, Policy()) assert plan["action"] == "reject" def test_model_alone_can_flag_one_off_data(self, with_llm): # 规则层漏掉的:长 URL 里带本次任务的 UUID,看起来像入口路径 plan = autoreview.decide(entry(category="path", value="https://example.edu/x/ff6be301-0fa3-4fc7"), REVIEW, ("reject", "带 UUID 的一次性链接"), Policy()) assert plan["action"] == "reject" def test_rule_adopt_blocks_a_model_rejection(self, with_llm): # 两层意见相反时不动手 plan = autoreview.decide(entry(school_count=3), ADOPT, ("reject", "我觉得是重复"), Policy()) assert plan["action"] == "hold" and "模型不认可" in plan["reason"] def test_auto_reject_can_be_turned_off(self, no_llm): plan = autoreview.decide(entry(value="太短"), REJECT, None, Policy(auto_reject=False)) assert plan["action"] == "hold" and "自动驳回已关闭" in plan["reason"] class TestPolicyThresholds: def test_raising_the_cross_school_bar(self, no_llm): assert autoreview.decide(entry(school_count=2), ADOPT, None, Policy(min_schools=3))["action"] == "hold" def test_lowering_the_observation_bar(self, no_llm): assert autoreview.decide(entry(observations=1), ADOPT, None, Policy(min_observations=1))["action"] == "verify" def test_policy_reads_settings(self, monkeypatch): from server.config import settings monkeypatch.setattr(settings, "auto_min_schools", 4) monkeypatch.setattr(settings, "auto_max_actions", 7) policy = Policy.from_settings() assert policy.min_schools == 4 and policy.max_actions == 7 @pytest.fixture def hub_scope(): knowledge_id = f"pytest_{uuid.uuid4().hex[:8]}" yield knowledge_id with hub.get_conn() as conn: hub._execute(conn, "DELETE FROM hub_entries WHERE knowledge_id = %s", (knowledge_id,)) hub._execute(conn, "DELETE FROM sync_log WHERE school_id LIKE 'pytest_%%'") def draft(knowledge_id: str, key: str, value: str, category: str = "control_tip") -> dict: return {"knowledge_id": knowledge_id, "business_id": "b1", "key": key, "value": value, "category": category, "observations": 1, "source_task_id": "t1"} @pytest.mark.db class TestAutoRejectIsProvisional: """人工驳回是终局,自动驳回只是暂缓——机器误判不能永久吃掉一条知识。""" VALUE = "这条内容会被驳回,用来验证自动驳回和人工驳回的区别" def test_human_rejection_is_final(self, hub_scope): hub.receive_drafts("pytest_a", [draft(hub_scope, "k", self.VALUE)]) entry_id = hub.list_entries(knowledge_id=hub_scope)[0]["id"] hub.review_entry(entry_id, "reject", by="human") stats = hub.receive_drafts("pytest_a", [draft(hub_scope, "k", self.VALUE)]) assert stats["skipped"] == 1 and stats["revived"] == 0 assert hub.get_entry(entry_id)["status"] == "rejected" def test_auto_rejection_is_revived_when_observed_again(self, hub_scope): hub.receive_drafts("pytest_a", [draft(hub_scope, "k", self.VALUE)]) entry_id = hub.list_entries(knowledge_id=hub_scope)[0]["id"] hub.review_entry(entry_id, "reject", by="auto") stats = hub.receive_drafts("pytest_b", [draft(hub_scope, "k", self.VALUE)]) assert stats["revived"] == 1 and stats["skipped"] == 0 row = hub.get_entry(entry_id) assert row["status"] == "draft" and row["reviewed_by"] is None and row["version"] == 0 def test_a_revived_entry_keeps_its_cross_school_evidence(self, hub_scope): hub.receive_drafts("pytest_a", [draft(hub_scope, "k", self.VALUE)]) entry_id = hub.list_entries(knowledge_id=hub_scope)[0]["id"] hub.review_entry(entry_id, "reject", by="auto") hub.receive_drafts("pytest_b", [draft(hub_scope, "k", self.VALUE)]) assert hub.get_entry(entry_id)["school_count"] == 2 @pytest.mark.db class TestAutoReviewRun: def test_dry_run_changes_nothing(self, hub_scope, monkeypatch): from server.config import settings monkeypatch.setattr(settings, "llm_api_key", "") hub.receive_drafts("pytest_a", [draft(hub_scope, "菜单结构", "门户有 8 个一级模块,入口在左侧导航栏")]) hub.receive_drafts("pytest_b", [draft(hub_scope, "菜单结构", "门户有 8 个一级模块,入口在左侧导航栏")]) import asyncio before = hub.list_entries(knowledge_id=hub_scope)[0]["status"] result = asyncio.run(autoreview.run(dry_run=True, use_llm=False)) assert result["dry_run"] is True assert hub.list_entries(knowledge_id=hub_scope)[0]["status"] == before == "draft" def test_applying_marks_entries_as_auto(self, hub_scope, monkeypatch): from server.config import settings monkeypatch.setattr(settings, "llm_api_key", "") value = "门户有 8 个一级模块,入口在左侧导航栏,点开后是二级菜单" hub.receive_drafts("pytest_a", [draft(hub_scope, "菜单结构", value, category="path")]) hub.receive_drafts("pytest_b", [draft(hub_scope, "菜单结构", value, category="path")]) entry_id = hub.list_entries(knowledge_id=hub_scope)[0]["id"] import asyncio asyncio.run(autoreview.run(dry_run=False, ids=[entry_id], use_llm=False)) row = hub.get_entry(entry_id) assert row["status"] == "verified" and row["reviewed_by"] == "auto" and row["scope"] == "all" assert "自动采纳" in row["suggestion_note"] def test_held_entries_get_a_reason_the_reviewer_can_read(self, hub_scope, monkeypatch): from server.config import settings monkeypatch.setattr(settings, "llm_api_key", "") hub.receive_drafts("pytest_a", [draft(hub_scope, "系统规则", "同一天同一班级不能重复提交", category="rule")]) entry_id = hub.list_entries(knowledge_id=hub_scope)[0]["id"] import asyncio asyncio.run(autoreview.run(dry_run=False, ids=[entry_id], use_llm=False)) row = hub.get_entry(entry_id) assert row["status"] == "draft" assert "留给人工" in row["suggestion_note"] and "必须人工确认" in row["suggestion_note"]