tests/test_category_classifier.py
Rama main · réplica de lectura del repositorio original.
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import category_classifier as cc # noqa: E402
class TaxonomyTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.path = Path(self.tmp.name) / "category-taxonomy.json"
patcher = mock.patch.object(cc, "RUNTIME_TAXONOMY_PATH", self.path)
patcher.start()
self.addCleanup(patcher.stop)
def test_runtime_categories_are_not_counted_twice(self) -> None:
self.path.write_text(json.dumps({"categories": ["07-esg"]}), encoding="utf-8")
cats = cc.taxonomy()
self.assertEqual(cats.count("07-esg"), 1)
self.assertEqual(len(cats), len(set(cats)))
def test_new_category_lands_in_data_not_in_skill_code(self) -> None:
"""分類是使用者的知識結構,不該寫進工具的程式碼。"""
self.assertTrue(cc.register_category("07-esg-reporting", reason="測試"))
stored = json.loads(self.path.read_text(encoding="utf-8"))
self.assertIn("07-esg-reporting", stored["categories"])
self.assertIn("測試", stored["added"]["07-esg-reporting"]["reason"])
self.assertIn("at", stored["added"]["07-esg-reporting"])
def test_existing_category_is_not_registered_again(self) -> None:
self.assertFalse(cc.register_category("99-general"))
self.assertFalse(self.path.exists())
def test_slug_rejects_free_form_names(self) -> None:
self.assertEqual(cc._slug("ESG Reporting!!"), "esg-reporting")
self.assertEqual(cc._slug(" "), "")
class ClassifyTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
# 兩個檔都要隔離。只隔離 taxonomy 的話,提案會寫進真實資料目錄——
# 跑過幾次之後真的在 memory/x-knowledge-base/ 留下一筆 count=5 的 07-esg,
# 而且票數跨測試累積,測試結果開始取決於它之前跑過幾次。
for name, attr in (("category-taxonomy.json", "RUNTIME_TAXONOMY_PATH"),
("category-proposals.json", "PROPOSALS_PATH")):
patcher = mock.patch.object(cc, attr, Path(self.tmp.name) / name)
patcher.start()
self.addCleanup(patcher.stop)
def _llm(self, payload: dict):
return mock.patch.object(cc, "_llm_call", return_value=json.dumps(payload, ensure_ascii=False))
def test_llm_failure_falls_back_and_never_blocks_ingest(self) -> None:
"""LLM 掛掉時攝取仍要能繼續——擋住攝取比分錯類更糟。"""
with mock.patch.object(cc, "_llm_call", side_effect=RuntimeError("provider down")):
result = cc.classify_content("gpt-image 出圖提示詞")
self.assertFalse(result["llm"])
self.assertEqual(result["confidence"], "low")
self.assertTrue(result["category"])
def test_existing_category_is_used_as_returned(self) -> None:
with self._llm({"category": "06-visual-ai-prompts", "confidence": "high", "tags": ["a"]}):
result = cc.classify_content("gpt-image")
self.assertEqual(result["category"], "06-visual-ai-prompts")
self.assertFalse(result["new_category"])
def test_new_category_needs_high_confidence(self) -> None:
"""低信心時不新增分類——分類一旦長出來就會留著。"""
with self._llm({"category": "NEW_CATEGORY", "new_category": "07-esg",
"confidence": "medium", "tags": []}):
result = cc.classify_content("碳盤查報告")
self.assertNotEqual(result["category"], "07-esg")
self.assertNotIn("07-esg", cc.taxonomy())
def test_high_confidence_new_category_is_only_a_proposal(self) -> None:
"""一票不開。規則是同一個名字累積到門檻才開。
這個測試原本釘的是相反的行為(一筆高信心就 register 成永久分類)。而
wiki topic 那一層早就是「被提議到門檻才值得開一頁」,分類這一層不該不一樣
——門檻機制與完整理由見 tests/test_new_categories_wait_for_a_quorum.py。
"""
with self._llm({"category": "NEW_CATEGORY", "new_category": "07-esg",
"confidence": "high", "reason": "碳盤查", "tags": []}):
result = cc.classify_content("碳盤查報告")
self.assertNotEqual(result["category"], "07-esg")
self.assertIn(result["category"], cc.taxonomy())
self.assertEqual(result["proposed_category"], "07-esg")
self.assertNotIn("07-esg", cc.taxonomy())
def test_the_quorum_opens_it_exactly_once(self) -> None:
with self._llm({"category": "NEW_CATEGORY", "new_category": "07-esg",
"confidence": "high", "reason": "碳盤查", "tags": []}):
for _ in range(cc.PROMOTE_AFTER):
result = cc.classify_content("碳盤查報告")
self.assertEqual(result["category"], "07-esg")
self.assertEqual(cc.taxonomy().count("07-esg"), 1)
def test_allow_new_false_blocks_invention(self) -> None:
with self._llm({"category": "NEW_CATEGORY", "new_category": "07-esg",
"confidence": "high", "tags": []}):
result = cc.classify_content("碳盤查報告", allow_new=False)
self.assertNotEqual(result["category"], "07-esg")
class ApplyCategoryTests(unittest.TestCase):
def test_replaces_existing_frontmatter_field_only(self) -> None:
card = "---\nid: 1\ncategory: 99-general\n---\n\n# T\n\ncategory: 不該被改\n"
out = cc.apply_category(card, "06-visual-ai-prompts")
self.assertIn("category: 06-visual-ai-prompts", out)
self.assertIn("category: 不該被改", out)
self.assertNotIn("category: 99-general", out)
def test_inserts_when_absent(self) -> None:
out = cc.apply_category("---\nid: 1\n---\n\n# T\n", "06-visual-ai-prompts")
self.assertIn("category: 06-visual-ai-prompts", out)
def test_empty_category_leaves_card_untouched(self) -> None:
card = "---\nid: 1\n---\n"
self.assertEqual(cc.apply_category(card, ""), card)
if __name__ == "__main__":
unittest.main()