213 lines
11 KiB
Python
213 lines
11 KiB
Python
"""经验沉淀:内容判重、类别合并,以及走真实 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
|