"""Tests for the PostProcessor (py/services/agent/post_processor.py). PostProcessor delegates all I/O to AgentCLI — these tests mock AgentCLI functions and verify the business logic (conditions, merges, dispatch). """ from __future__ import annotations import json from datetime import datetime, timezone from unittest import mock import pytest from py.services.agent.post_processor import PostProcessor from py.services.model_sources import ModelCardContext @pytest.fixture def processor(): return PostProcessor() # ====================================================================== # process() — routing # ====================================================================== class TestProcessDispatch: @pytest.mark.asyncio async def test_unknown_skill_returns_error(self, processor): result = await processor.process( skill_name="nonexistent", model_path="/p.safetensors", llm_output={}, metadata={}, ) assert result["success"] is False assert "nonexistent" in result["errors"][0] @pytest.mark.asyncio async def test_enrich_hf_metadata_routes_correctly(self, processor): with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview") as mock_dl, mock.patch("py.metadata_ops.refresh_cache") as mock_ref, ): mock_apply.return_value = ["metadata_source"] mock_dl.return_value = None result = await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output={}, metadata={"from_civitai": True}, ) assert result["success"] is True # ====================================================================== # enrich_hf_metadata — field-level logic # ====================================================================== class TestEnrichHfMetadata: """Business logic tests for the enrich_hf_metadata post-processor.""" MIN_LLM_OUTPUT = { "base_model": "", "trigger_words": [], "short_description": "", "tags": [], "recommended_width": 0, "recommended_height": 0, "preview_url": "", "confidence": "low", } # -- base_model ------------------------------------------------------ @pytest.mark.asyncio async def test_base_model_overwrites_empty(self, processor): """Empty current base_model → new value is applied.""" llm = {**self.MIN_LLM_OUTPUT, "base_model": "Flux.1 D"} with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=False), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=llm, metadata={"base_model": ""}, ) applied = mock_apply.call_args[0][1] assert applied["base_model"] == "Flux.1 D" @pytest.mark.asyncio async def test_base_model_does_not_overwrite_existing_civitai(self, processor): """Existing base_model from CivitAI → not overwritten.""" llm = {**self.MIN_LLM_OUTPUT, "base_model": "Flux.1 D"} with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=False), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=llm, metadata={"base_model": "SDXL 1.0", "from_civitai": True}, ) # apply IS called (metadata_source, llm_enriched_at) but base_model not in it applied = mock_apply.call_args[0][1] assert "base_model" not in applied @pytest.mark.asyncio async def test_base_model_overwrites_existing_hf_model(self, processor): """Existing base_model from HF → overwritten (LLM is more reliable).""" llm = {**self.MIN_LLM_OUTPUT, "base_model": "Flux.1 D"} with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=False), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=llm, metadata={ "base_model": "SD 1.5", "from_civitai": False, "hf_url": "https://huggingface.co/user/repo", }, ) applied = mock_apply.call_args[0][1] assert applied["base_model"] == "Flux.1 D" @pytest.mark.asyncio async def test_base_model_skipped_when_llm_empty(self, processor): """LLM returns empty base_model → nothing written.""" with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=False), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.MIN_LLM_OUTPUT, metadata={"base_model": ""}, ) applied = mock_apply.call_args[0][1] assert "base_model" not in applied # -- trigger_words --------------------------------------------------- @pytest.mark.asyncio async def test_trigger_words_merged(self, processor): """New trigger words written when current list is empty.""" llm = {**self.MIN_LLM_OUTPUT, "trigger_words": ["trigger1", "trigger2"]} with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=llm, metadata={}, ) applied = mock_apply.call_args[0][1] assert applied["civitai"]["trainedWords"] == ["trigger1", "trigger2"] # -- short_description → civitai.description ------------------------- @pytest.mark.asyncio async def test_short_description_written_to_civitai(self, processor): """short_description written to civitai.description for HF models.""" llm = {**self.MIN_LLM_OUTPUT, "short_description": "A short summary"} with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=llm, metadata={ "from_civitai": False, "hf_url": "https://huggingface.co/user/repo", }, ) applied = mock_apply.call_args[0][1] assert applied["civitai"]["description"] == "A short summary" @pytest.mark.asyncio async def test_short_description_skipped_without_hf_url(self, processor): """short_description NOT written when the model has no HF source.""" llm = {**self.MIN_LLM_OUTPUT, "short_description": "A short summary"} with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=llm, metadata={"from_civitai": True}, ) applied = mock_apply.call_args[0][1] assert "civitai" not in applied or "description" not in applied.get("civitai", {}) # -- readme_content → modelDescription ------------------------------- @pytest.mark.asyncio async def test_readme_content_converted_to_model_description(self, processor): """Raw README converted to HTML and stored as modelDescription.""" with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.MIN_LLM_OUTPUT, metadata={ "from_civitai": False, "hf_url": "https://huggingface.co/user/repo", }, readme_content="# Hello\n\nThis is **bold**.", ) applied = mock_apply.call_args[0][1] assert "
{context.description}
") assert "a < b & c
" @pytest.mark.asyncio async def test_site_trigger_words_fill_in_when_llm_finds_none(self, processor): context = ModelCardContext(trigger_words=["kreaface", "kreamodel"]) readme = "---\ninstance_prompt: yamlword\n---\nbody\n" with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.LLM_OUTPUT, metadata=dict(self.MODELSCOPE_METADATA), readme_content=readme, source_context=context, ) # The per-file site value wins over the repo-wide YAML instance_prompt. assert mock_apply.call_args[0][1]["civitai"]["trainedWords"] == [ "kreaface", "kreamodel", ] @pytest.mark.asyncio async def test_yaml_instance_prompt_still_used_when_site_has_none(self, processor): context = ModelCardContext(description="summary only") readme = "---\ninstance_prompt: yamlword\n---\nbody\n" with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.LLM_OUTPUT, metadata=dict(self.MODELSCOPE_METADATA), readme_content=readme, source_context=context, ) assert mock_apply.call_args[0][1]["civitai"]["trainedWords"] == ["yamlword"] @pytest.mark.asyncio async def test_site_images_are_skipped_for_a_model_with_no_external_source( self, processor ): """A CivitAI-only model must not pick up ModelScope images.""" context = ModelCardContext( example_images=["https://resources.modelscope.cn/cover-images/a.png"] ) with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.LLM_OUTPUT, metadata={"from_civitai": True}, readme_content="", source_context=context, ) assert "images" not in mock_apply.call_args[0][1].get("civitai", {}) @pytest.mark.asyncio async def test_site_images_deduplicate_against_readme_images(self, processor): """A URL present in both the site data and the README appears once.""" shared = "https://modelscope.cn/models/user/repo/resolve/master/sample.png" context = ModelCardContext(example_images=[shared]) readme = f"\n" with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.LLM_OUTPUT, metadata=dict(self.MODELSCOPE_METADATA), readme_content=readme, source_context=context, ) images = mock_apply.call_args[0][1]["civitai"]["images"] assert [img["url"] for img in images] == [shared] @pytest.mark.asyncio async def test_empty_context_keeps_readme_only_behaviour(self, processor): """An empty site context must not change existing HF behaviour.""" readme = "---\nwidget:\n- text: a cat\n output:\n url: images/cat.png\n---\n" with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.LLM_OUTPUT, metadata={ "from_civitai": False, "hf_url": "https://huggingface.co/user/repo", }, readme_content=readme, source_context=ModelCardContext(), ) images = mock_apply.call_args[0][1]["civitai"]["images"] assert [img["url"] for img in images] == [ "https://huggingface.co/user/repo/resolve/main/images/cat.png" ] # ====================================================================== # enrich_hf_metadata — deterministic fallbacks used when the LLM is skipped # ====================================================================== class TestDeterministicFallbacks: """With the LLM skipped, these fields must still be produced from the API.""" MODELSCOPE_METADATA = { "from_civitai": False, "source_platform": "modelscope", "source_url": "https://modelscope.cn/models/user/repo", } EMPTY_LLM = { "base_model": "", "trigger_words": [], "short_description": "", "tags": [], "recommended_width": 0, "recommended_height": 0, "preview_url": "", "notes": "", "usage_tips": "{}", "confidence": "", } @pytest.mark.asyncio async def test_resolved_base_model_used_when_llm_gave_none(self, processor): with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.EMPTY_LLM, metadata=dict(self.MODELSCOPE_METADATA), source_context=ModelCardContext(base_model="krea/Krea-2-Turbo"), resolved_base_model="Krea 2", ) assert mock_apply.call_args[0][1]["base_model"] == "Krea 2" @pytest.mark.asyncio async def test_llm_base_model_still_wins_over_the_resolver(self, processor): llm = {**self.EMPTY_LLM, "base_model": "Flux.1 D"} with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=llm, metadata=dict(self.MODELSCOPE_METADATA), resolved_base_model="Krea 2", ) assert mock_apply.call_args[0][1]["base_model"] == "Flux.1 D" @pytest.mark.asyncio async def test_site_description_fills_civitai_description(self, processor): context = ModelCardContext(description="一个 Krea 2 人像 LoRA。") with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.EMPTY_LLM, metadata=dict(self.MODELSCOPE_METADATA), source_context=context, ) assert ( mock_apply.call_args[0][1]["civitai"]["description"] == "一个 Krea 2 人像 LoRA。" ) @pytest.mark.asyncio async def test_llm_short_description_wins_over_site_description(self, processor): llm = {**self.EMPTY_LLM, "short_description": "from the LLM"} with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=llm, metadata=dict(self.MODELSCOPE_METADATA), source_context=ModelCardContext(description="from the site"), ) assert mock_apply.call_args[0][1]["civitai"]["description"] == "from the LLM" @pytest.mark.asyncio async def test_official_tags_are_applied_without_the_llm(self, processor): context = ModelCardContext( official_tags=["photography", "character-enhancement", "woman"] ) with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.EMPTY_LLM, metadata=dict(self.MODELSCOPE_METADATA), source_context=context, ) assert mock_apply.call_args[0][1]["tags"] == [ "photography", "character-enhancement", "woman", ] @pytest.mark.asyncio async def test_official_tags_are_kept_alongside_llm_tags(self, processor): context = ModelCardContext(official_tags=["photography", "woman"]) llm = {**self.EMPTY_LLM, "tags": ["portrait", "photography"]} with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=llm, metadata=dict(self.MODELSCOPE_METADATA), source_context=context, ) # Site tags first, then the LLM's extra ones, no duplicates. assert mock_apply.call_args[0][1]["tags"] == [ "photography", "woman", "portrait", ] @pytest.mark.asyncio async def test_usage_tips_recovered_from_the_author_summary(self, processor): context = ModelCardContext( description="权重0.5-1.2。2个一起时,权重建议都用1.0-1.1。" ) with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.EMPTY_LLM, metadata=dict(self.MODELSCOPE_METADATA), source_context=context, ) tips = json.loads(mock_apply.call_args[0][1]["usage_tips"]) assert tips == { "strength_min": 0.5, "strength_max": 1.2, "strength_range": "0.5-1.2", } @pytest.mark.asyncio async def test_llm_usage_tips_win_over_the_regex(self, processor): llm = {**self.EMPTY_LLM, "usage_tips": '{"strength": 0.9}'} with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=llm, metadata=dict(self.MODELSCOPE_METADATA), source_context=ModelCardContext(description="权重0.5-1.2"), ) assert mock_apply.call_args[0][1]["usage_tips"] == '{"strength": 0.9}' @pytest.mark.asyncio async def test_notes_are_not_rewritten_when_the_llm_is_skipped(self, processor): """Notes are LLM-only; skipping must not clobber or duplicate them.""" with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.EMPTY_LLM, metadata={**self.MODELSCOPE_METADATA, "notes": "existing notes"}, source_context=ModelCardContext(description="权重0.5-1.2"), ) assert "notes" not in mock_apply.call_args[0][1] @pytest.mark.asyncio async def test_no_site_data_leaves_llm_only_fields_untouched(self, processor): """An empty context must behave exactly like the pre-existing pipeline.""" with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.EMPTY_LLM, metadata=dict(self.MODELSCOPE_METADATA), source_context=ModelCardContext(), resolved_base_model="", ) applied = mock_apply.call_args[0][1] assert "base_model" not in applied assert "tags" not in applied assert "notes" not in applied assert "usage_tips" not in applied class TestPlaceholderCardDescription: """A site-generated placeholder card must not become the description.""" MODELSCOPE_METADATA = { "from_civitai": False, "source_platform": "modelscope", "source_url": "https://modelscope.cn/models/user/repo", } PLACEHOLDER_README = """--- base_model: krea/Krea-2-Turbo --- ### 当前模型的贡献者未提供更加详细的模型介绍。模型文件和权重,可浏览“模型文件”页面获取。 #### 您可以通过如下git clone命令,或者ModelScope SDK来下载模型 SDK下载 ```bash pip install modelscope ```如果您是本模型的贡献者,我们邀请您根据文档及时完善模型卡片内容。
""" LLM_OUTPUT = { "base_model": "Krea 2", "trigger_words": [], "short_description": "一个 Krea 2 人像 LoRA。", "tags": [], "recommended_width": 0, "recommended_height": 0, "preview_url": "", "notes": "", "usage_tips": "{}", "confidence": "medium", } @pytest.mark.asyncio async def test_description_holds_only_the_author_summary(self, processor): context = ModelCardContext(description="权重0.5-1.2。") with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.LLM_OUTPUT, metadata=dict(self.MODELSCOPE_METADATA), readme_content=self.PLACEHOLDER_README, source_context=context, ) description = mock_apply.call_args[0][1]["modelDescription"] assert description == "权重0.5-1.2。
" assert "pip install modelscope" not in description assert "git clone" not in description assert "贡献者" not in description @pytest.mark.asyncio async def test_placeholder_card_alone_writes_no_description(self, processor): """Without an author summary there is nothing worth storing.""" with ( mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, mock.patch("py.metadata_ops.download_preview", return_value=None), mock.patch("py.metadata_ops.refresh_cache"), ): await processor.process( skill_name="enrich_hf_metadata", model_path="/p.safetensors", llm_output=self.LLM_OUTPUT, metadata=dict(self.MODELSCOPE_METADATA), readme_content=self.PLACEHOLDER_README, source_context=ModelCardContext(), ) assert "modelDescription" not in mock_apply.call_args[0][1]