assistant/tests/test_learned.py

213 lines
11 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.

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