"""经验沉淀:内容判重、类别合并,以及走真实 PostgreSQL 的写入 / 合并 / 自动晋升生命周期。""" from __future__ import annotations import uuid import pytest from backend.db import database as db from backend.learning import _from_artifacts, _is_submit_step class TestSimilarity: def test_identical_strings(self): assert db._similarity("入口路径", "入口路径") == 1.0 def test_whitespace_is_ignored(self): assert db._similarity("入口 路径", "入口路径") == 1.0 def test_unrelated_strings_score_low(self): assert db._similarity("班级下拉框要先点开", "提交后跳转到详情页") < 0.5 @pytest.mark.parametrize("a,b", [("", "x"), ("x", ""), ("", "")]) def test_empty_input(self, a, b): assert db._similarity(a, b) == 0.0 class TestSameKnowledge: def test_same_content_different_key(self): # 模型每次给同一条知识起的 key 不同,判重必须看内容 assert db._same_knowledge( "班级下拉框", "班级是下拉框,要先点开再选,选项形如「计科2301班(计算机科学与技术)」", "班级选择控件", "班级是下拉框,要先点开再选,选项形如「计科2301班(计算机科学与技术)」", ) is True def test_similar_key_and_moderately_similar_content(self): assert db._same_knowledge( "日期控件", "听课时间是日期框,点进去直接键入日期再回车", "日期控件填写", "听课时间是日期选择框,点进去直接键入日期然后回车确认", ) is True def test_different_knowledge_is_not_merged(self): assert db._same_knowledge( "班级下拉框", "班级是下拉框,要先点开再选", "提交入口", "提交按钮在表单底部,叫「提交填报」不是「保存草稿」", ) is False def test_same_key_but_unrelated_content_is_not_merged(self): assert db._same_knowledge("控件技巧", "班级是下拉框", "控件技巧", "提交后会跳转到任务详情页面列表") is False class TestPickCategory: def test_prefers_a_specific_category_over_note(self): assert db._pick_category("note", "control_tip") == "control_tip" def test_first_specific_category_wins(self): assert db._pick_category("path", "control_tip") == "path" def test_all_notes_stay_note(self): assert db._pick_category("note", "note") == "note" def test_unknown_category_degrades_to_note(self): assert db._pick_category("模型编的类别", "note") == "note" class TestArtifactMapping: def test_submit_steps_do_not_sediment_form_values(self): # 填报步骤的 form_fields 是这次填的具体值(班级、人名),不是可复用的表单结构 step = {"step_id": "fill", "human_confirm_before": "submit"} entries = _from_artifacts({"form_fields": "班级=计科2301班;听课记录=还好还好"}, step) assert entries == [] def test_explore_steps_do_sediment_form_structure(self): step = {"step_id": "explore"} entries = _from_artifacts({"form_fields": "班级、听课时间、课程名称、授课教师"}, step) assert len(entries) == 1 and entries[0]["category"] == "path" def test_empty_artifacts_produce_nothing(self): assert _from_artifacts({}, {"step_id": "explore"}) == [] @pytest.mark.parametrize("step,expected", [ ({"human_confirm_before": "submit"}, True), ({"on_success_status": "submitted"}, True), ({"step_id": "explore"}, False), ]) def test_submit_step_detection(self, step, expected): assert _is_submit_step(step) is expected @pytest.mark.db class TestLearnedLifecycle: """写入 → 合并 → 自动晋升 → 人审,全部跑在一次性的测试学校里。""" KID, BID = "test_pack", "counselor_worklog" def _drafts(self, entries, task_id): return db.add_learned_drafts(self.KID, self.BID, entries, task_id) def test_first_draft_is_written(self, test_school): stats = self._drafts([{"key": "班级下拉框", "value": "班级是下拉框,要先点开再选", "category": "control_tip"}], "t1") assert stats["written"] == 1 and stats["merged"] == 0 rows = db.list_learned(knowledge_id=self.KID, business_id=self.BID) assert len(rows) == 1 and rows[0]["status"] == "draft" and rows[0]["observations"] == 1 def test_same_content_from_the_same_task_merges_without_counting_twice(self, test_school): self._drafts([{"key": "班级下拉框", "value": "班级是下拉框,要先点开再选", "category": "control_tip"}], "t1") stats = self._drafts([{"key": "班级选择", "value": "班级是下拉框,要先点开再选", "category": "control_tip"}], "t1") assert stats["merged"] == 1 and stats["written"] == 0 rows = db.list_learned(knowledge_id=self.KID, business_id=self.BID) assert len(rows) == 1 and rows[0]["observations"] == 1 # 同一个任务不重复计数 def test_second_task_promotes_a_control_tip_automatically(self, test_school, monkeypatch): from backend.config import settings monkeypatch.setattr(settings, "learned_auto_verify_min_obs", 2) value = "班级是下拉框,要先点开再从选项里选,选项形如「计科2301班(计算机科学与技术)」" self._drafts([{"key": "班级下拉框", "value": value, "category": "control_tip"}], "t1") stats = self._drafts([{"key": "班级控件", "value": value, "category": "control_tip"}], "t2") assert stats["promoted"] == 1 rows = db.list_learned(knowledge_id=self.KID, business_id=self.BID) assert rows[0]["status"] == "verified" and rows[0]["verified_by"] == "auto" assert rows[0]["observations"] == 2 def test_rules_are_never_promoted_automatically(self, test_school, monkeypatch): from backend.config import settings monkeypatch.setattr(settings, "learned_auto_verify_min_obs", 2) value = "同一天同一班级不能重复提交查课记录,提交前要先看历史列表" self._drafts([{"key": "重复提交规则", "value": value, "category": "rule"}], "t1") stats = self._drafts([{"key": "防重复", "value": value, "category": "rule"}], "t2") assert stats["promoted"] == 0 assert db.list_learned(knowledge_id=self.KID, business_id=self.BID)[0]["status"] == "draft" def test_merging_keeps_the_longer_value(self, test_school): self._drafts([{"key": "提交入口", "value": "提交按钮在底部", "category": "path"}], "t1") longer = "提交按钮在表单底部,叫「提交填报」,不要点旁边的「保存草稿」" self._drafts([{"key": "提交入口", "value": longer, "category": "path"}], "t2") assert db.list_learned(knowledge_id=self.KID, business_id=self.BID)[0]["value"] == longer def test_verified_knowledge_is_not_drafted_again(self, test_school): value = "提交按钮在表单底部,叫「提交填报」,不要点旁边的保存草稿" self._drafts([{"key": "提交入口", "value": value, "category": "rule"}], "t1") entry_id = db.list_learned(knowledge_id=self.KID, business_id=self.BID)[0]["id"] db.set_learned_status(entry_id, "verified", by="human") stats = self._drafts([{"key": "提交按钮位置", "value": value, "category": "rule"}], "t2") assert stats["skipped"] == 1 and stats["written"] == 0 def test_distinct_knowledge_creates_separate_rows(self, test_school): self._drafts([ {"key": "班级下拉框", "value": "班级是下拉框,要先点开再选", "category": "control_tip"}, {"key": "提交入口", "value": "提交按钮在表单底部叫「提交填报」", "category": "path"}, ], "t1") assert len(db.list_learned(knowledge_id=self.KID, business_id=self.BID)) == 2 def test_only_verified_entries_reach_the_prompt(self, test_school): self._drafts([ {"key": "已审核的", "value": "这条已经审过了,可以注入提示词", "category": "rule"}, {"key": "没审核的", "value": "这条还是草稿,不能注入提示词", "category": "rule"}, ], "t1") rows = db.list_learned(knowledge_id=self.KID, business_id=self.BID) db.set_learned_status(next(r["id"] for r in rows if r["key"] == "已审核的"), "verified") injected = db.verified_learned(self.KID, self.BID) assert "已审核的" in injected and "没审核的" not in injected def test_rejected_entries_stay_out_of_the_prompt(self, test_school): self._drafts([{"key": "错的经验", "value": "这条是错的,人工忽略掉", "category": "rule"}], "t1") entry_id = db.list_learned(knowledge_id=self.KID, business_id=self.BID)[0]["id"] db.set_learned_status(entry_id, "rejected") assert db.verified_learned(self.KID, self.BID) == {} def test_empty_entries_are_ignored(self, test_school): assert self._drafts([], "t1") == {"written": 0, "merged": 0, "promoted": 0, "skipped": 0} assert self._drafts([{"key": "", "value": "没有 key"}, {"key": "没有 value", "value": ""}], "t1")["written"] == 0 def test_another_school_cannot_see_these_entries(self, test_school, monkeypatch): from backend.config import settings self._drafts([{"key": "本校经验", "value": "只属于这所学校的经验条目", "category": "rule"}], "t1") other = f"pytest_other_{uuid.uuid4().hex[:8]}" monkeypatch.setattr(settings, "school_id", other) assert db.list_learned(knowledge_id=self.KID, business_id=self.BID) == [] @pytest.mark.db class TestAccountVault: def test_upsert_then_read_back(self, test_school): from backend import security from backend.config import settings key = security.generate_key() old, settings.account_enc_key = settings.account_enc_key, key try: db.upsert_account("s001", "xgxt", "t001", security.encrypt_secret("pw123"), role="辅导员") account = db.get_account("s001", "xgxt") assert account["username"] == "t001" and account["role"] == "辅导员" assert security.decrypt_secret(account["password_enc"]) == "pw123" finally: settings.account_enc_key = old def test_upsert_twice_updates_instead_of_duplicating(self, test_school): db.upsert_account("s001", "xgxt", "t001", "enc1", role="辅导员") db.upsert_account("s001", "xgxt", "t002", "enc2", role="辅导员") assert db.get_account("s001", "xgxt")["username"] == "t002" assert len(db.list_accounts("s001")) == 1 def test_one_person_can_hold_accounts_in_several_systems(self, test_school): db.upsert_account("s001", "xgxt", "t001", "enc1", role="辅导员") db.upsert_account("s001", "lib", "t002", "enc2", role="读者") assert {a["system_id"] for a in db.list_accounts("s001")} == {"xgxt", "lib"} def test_missing_account_returns_none(self, test_school): assert db.get_account("nobody", "xgxt") is None