assistant/tests/test_autoreview.py

266 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""智能采纳:什么条件下才敢自动动手,以及自动驳回不能像人工驳回那样是终局。"""
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"]