"""试用制:草稿也注入(降一级),Agent 的回执当裁判,被证实的升级、被证伪的降级。""" from __future__ import annotations import pytest from backend.orchestrator import orchestrator as orc from backend.orchestrator.orchestrator import parse_trial_receipt class TestLearnedBlock: KNOWLEDGE = {"learned": {"门户结构": "有 8 个一级模块"}} TRIALS = [{"id": 42, "key": "提交入口", "value": "提交按钮在表单底部,叫「提交填报」"}] def test_verified_and_trial_are_separate_sections(self): block = orc._learned_block(self.KNOWLEDGE, {}, self.TRIALS) assert "已知信息(已确认" in block and "待验证线索" in block def test_trial_entries_carry_their_id_for_the_receipt(self): assert "[42]" in orc._learned_block(self.KNOWLEDGE, {}, self.TRIALS) def test_page_always_wins_over_a_trial_hint(self): # 错的线索会主动帮倒忙,所以必须写死「以页面为准」 block = orc._learned_block(self.KNOWLEDGE, {}, self.TRIALS) assert "一律以页面为准" in block def test_receipt_format_is_spelled_out(self): block = orc._learned_block(self.KNOWLEDGE, {}, self.TRIALS) assert "线索回执" in block and "相符" in block and "不符" in block and "没用上" in block def test_no_trials_means_no_trial_section(self): assert "待验证线索" not in orc._learned_block(self.KNOWLEDGE, {}, []) def test_no_knowledge_at_all_produces_nothing(self): assert orc._learned_block({}, {}, []) == "" def test_trials_alone_still_render(self): assert "待验证线索" in orc._learned_block({}, {}, self.TRIALS) class TestReceiptParsing: ALLOWED = {7, 42, 99} def test_parses_the_documented_format(self): text = "任务完成。\n\n线索回执:\n线索[42] 不符 提交按钮实际叫「保存并提交」\n线索[7] 相符 班级确实是下拉框" assert parse_trial_receipt(text, self.ALLOWED) == [ {"id": 42, "outcome": "contradicted", "note": "提交按钮实际叫「保存并提交」"}, {"id": 7, "outcome": "confirmed", "note": "班级确实是下拉框"}, ] @pytest.mark.parametrize("line,outcome", [ ("线索[7] 相符 对的", "confirmed"), ("线索[7]:相符,对的", "confirmed"), ("线索7 不符 错的", "contradicted"), ("线索[7] 没用上 没去到那页", "unused"), ("线索[7] 未用上 没去到那页", "unused"), ]) def test_tolerates_punctuation_and_wording_variants(self, line, outcome): parsed = parse_trial_receipt(line, self.ALLOWED) assert parsed and parsed[0]["outcome"] == outcome def test_ids_that_were_never_injected_are_ignored(self): # 防止模型凭空编一个编号出来,把没注入过的条目改掉 assert parse_trial_receipt("线索[500] 相符 编的", self.ALLOWED) == [] def test_later_lines_win_for_the_same_id(self): text = "线索[7] 相符 先说对\n线索[7] 不符 后来发现不对" assert parse_trial_receipt(text, self.ALLOWED)[0]["outcome"] == "contradicted" @pytest.mark.parametrize("text", ["", "任务完成,没有回执", "线索很有用但没按格式写"]) def test_no_receipt_is_not_an_error(self, text): assert parse_trial_receipt(text, self.ALLOWED) == [] def test_receipt_mixed_into_a_long_summary(self): text = "【任务结果】已填未提交。\n\n一、操作过程\n1. 打开系统\n2. 填表\n\n【线索回执】\n线索[42] 相符 确实如此\n" assert len(parse_trial_receipt(text, self.ALLOWED)) == 1 @pytest.mark.db class TestTrialLifecycle: KID, BID = "pytest_trial", "b1" def _draft(self, db, key: str, value: str, category: str = "control_tip"): db.add_learned_drafts(self.KID, self.BID, [{"key": key, "value": value, "category": category}], "t0") return db.list_learned(knowledge_id=self.KID, business_id=self.BID)[0] def test_drafts_are_offered_as_trials(self, test_school): from backend.db import database as db self._draft(db, "提交入口", "提交按钮在表单底部,叫「提交填报」,不要点保存草稿") trials = db.trial_learned(self.KID, self.BID, 8) assert len(trials) == 1 and trials[0]["status"] == "draft" def test_verified_entries_are_not_offered_again(self, test_school): from backend.db import database as db entry = self._draft(db, "提交入口", "提交按钮在表单底部,叫「提交填报」,不要点保存草稿") db.set_learned_status(entry["id"], "verified") assert db.trial_learned(self.KID, self.BID, 8) == [] # 内容必须彼此差异够大,否则会被内容判重合并成一条(那是 add_learned_drafts 的正常行为) DISTINCT = [ ("班级控件", "班级是下拉框,点开后从选项里选择对应班级"), ("提交入口", "表单底部有「提交填报」按钮,旁边是保存草稿"), ("日期输入", "听课时间支持直接键入 yyyy-mm-dd 后回车确认"), ("菜单路径", "门户左侧导航进入辅导员管理再到工作日志"), ("附件上传", "相关附件一栏支持拖拽,也可点击选择文件"), ("审批状态", "列表最右列展示当前审批进度和处理人"), ] def test_injection_is_capped(self, test_school): from backend.db import database as db for i, (key, value) in enumerate(self.DISTINCT): db.add_learned_drafts(self.KID, self.BID, [{"key": key, "value": value, "category": "path"}], f"t{i}") assert len(db.list_learned(knowledge_id=self.KID, business_id=self.BID)) == len(self.DISTINCT) assert len(db.trial_learned(self.KID, self.BID, 3)) == 3 def test_contradicted_entries_sort_last(self, test_school): from backend.db import database as db good = self._draft(db, "班级控件", self.DISTINCT[0][1]) db.add_learned_drafts(self.KID, self.BID, [{"key": "菜单路径", "value": self.DISTINCT[3][1], "category": "path"}], "t2") bad = next(r for r in db.list_learned(knowledge_id=self.KID, business_id=self.BID) if r["key"] == "菜单路径") db.record_trials("task_x", [bad["id"]]) db.apply_trial_outcomes("task_x", [{"id": bad["id"], "outcome": "contradicted", "note": "页面上不是这样"}], 2, 9) order = [r["id"] for r in db.trial_learned(self.KID, self.BID, 8)] assert order.index(good["id"]) < order.index(bad["id"]) def test_two_confirmations_promote(self, test_school): from backend.db import database as db entry = self._draft(db, "提交入口", "提交按钮在表单底部,叫「提交填报」,不要点保存草稿") for task in ("task_1", "task_2"): db.record_trials(task, [entry["id"]]) stats = db.apply_trial_outcomes(task, [{"id": entry["id"], "outcome": "confirmed", "note": "页面确实如此"}], 2, 1) assert stats["promoted"] == 1 row = db.get_learned(entry["id"]) assert row["status"] == "verified" and row["verified_by"] == "trial" and row["confirmed"] == 2 assert "提交入口" in db.verified_learned(self.KID, self.BID) def test_one_contradiction_demotes(self, test_school): from backend.db import database as db entry = self._draft(db, "错的经验", "这条说法与页面实际不符,应该被降级") db.record_trials("task_1", [entry["id"]]) stats = db.apply_trial_outcomes("task_1", [{"id": entry["id"], "outcome": "contradicted", "note": "实际叫别的名字"}], 2, 1) assert stats["demoted"] == 1 assert db.get_learned(entry["id"])["status"] == "rejected" assert db.trial_learned(self.KID, self.BID, 8) == [] # 不再注入 def test_a_contradiction_blocks_promotion_even_with_confirmations(self, test_school): from backend.db import database as db entry = self._draft(db, "时灵时不灵", "这条有时对有时不对,不该升级") db.apply_trial_outcomes("t1", [{"id": entry["id"], "outcome": "confirmed", "note": "对"}], 2, 9) db.apply_trial_outcomes("t2", [{"id": entry["id"], "outcome": "contradicted", "note": "不对"}], 2, 9) db.apply_trial_outcomes("t3", [{"id": entry["id"], "outcome": "confirmed", "note": "又对了"}], 2, 9) row = db.get_learned(entry["id"]) assert row["status"] == "draft" and row["confirmed"] == 2 and row["contradicted"] == 1 def test_unused_changes_nothing(self, test_school): from backend.db import database as db entry = self._draft(db, "没用上的", "这次任务没走到相关页面,不算证据") db.apply_trial_outcomes("t1", [{"id": entry["id"], "outcome": "unused", "note": "没去到那页"}], 2, 1) row = db.get_learned(entry["id"]) assert row["status"] == "draft" and row["confirmed"] == 0 and row["contradicted"] == 0 def test_trial_history_is_traceable(self, test_school): from backend.db import database as db entry = self._draft(db, "可追溯", "要能查出这条是被哪次任务证实的") db.apply_trial_outcomes("task_abc", [{"id": entry["id"], "outcome": "confirmed", "note": "页面确实如此"}], 2, 1) history = db.trial_history(entry["id"]) assert history and history[0]["task_id"] == "task_abc" and history[0]["outcome"] == "confirmed" def test_same_task_reporting_twice_does_not_double_count(self, test_school): from backend.db import database as db entry = self._draft(db, "重复回执", "同一个任务重复给回执不该算两次证据") db.apply_trial_outcomes("t1", [{"id": entry["id"], "outcome": "confirmed", "note": "对"}], 9, 9) db.apply_trial_outcomes("t1", [{"id": entry["id"], "outcome": "confirmed", "note": "对"}], 9, 9) history = db.trial_history(entry["id"]) assert len(history) == 1 def test_unknown_entry_ids_are_ignored(self, test_school): from backend.db import database as db stats = db.apply_trial_outcomes("t1", [{"id": 99999999, "outcome": "confirmed"}], 2, 1) assert stats["promoted"] == 0 and stats["confirmed"] == 0 def test_malformed_outcomes_are_ignored(self, test_school): from backend.db import database as db entry = self._draft(db, "格式乱的", "回执格式不对时不能改坏数据") db.apply_trial_outcomes("t1", [{"id": entry["id"], "outcome": "编的状态"}, {"id": "abc"}, {}], 2, 1) row = db.get_learned(entry["id"]) assert row["confirmed"] == 0 and row["contradicted"] == 0 class TestTrialSwitch: def test_can_be_turned_off(self, monkeypatch, knowledge, business): from backend.config import settings from backend.orchestrator.orchestrator import Orchestrator monkeypatch.setattr(settings, "trial_enabled", False) assert Orchestrator._pick_trials(knowledge, business) == [] def test_database_failure_does_not_break_execution(self, monkeypatch, knowledge, business): from backend.config import settings from backend.db import database as db from backend.orchestrator.orchestrator import Orchestrator monkeypatch.setattr(settings, "trial_enabled", True) monkeypatch.setattr(db, "trial_learned", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("库挂了"))) assert Orchestrator._pick_trials(knowledge, business) == []