Ctrl K
ComunidadGuías…

tests/test_category_classifier.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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()