Files
ComfyUI-Lora-Manager/tests/services/test_agent_enrichment_source.py
Will Miao e9e9ee20c6 perf(modelscope): fetch a repository's model card once per run
A collection repository publishes many model files under a single source id,
but enrichment re-read the README and the model-detail payload for every one
of them: eight checkpoints meant sixteen HTTP requests, each detail payload
being 10-22 KB of JSON.

Add `ModelSourceCache`, created by `execute_skill()` for the duration of a
run and passed to the provider through a new optional `cache` argument on
`fetch_model_card_context()`. The agent caches the README (repository-wide
and provider-agnostic), and ModelScope caches its detail payload under a
provider-namespaced key.

Only successful reads are memoised, so a transient failure is still retried
for the next file, and the per-file selection is redone from the cached
payload so a checkpoint never inherits a sibling's example images. Nothing
is retained across runs — a model card can change at any time — and download
URLs are not routed through the cache.

Measured over the eight checkpoints of one ModelScope repository: 16
requests before, 2 after.

To keep the two concerns separable, `_build_card_context()` now turns a
detail payload into a `ModelCardContext` as a pure function.
2026-09-14 21:24:57 +08:00

569 lines
22 KiB
Python

"""Tests for source-aware AI enrichment orchestration.
Covers the fast-fail gate (:meth:`AgentService._enrichment_skip_reason`) and
the prompt-context builder for non-Hugging Face model sources.
"""
from __future__ import annotations
from unittest import mock
import pytest
from py.services.agent.agent_service import AgentService
from py.services.model_sources import ModelCardContext
class TestEnrichmentSkipReason:
def test_skips_when_no_source_linked(self):
reason = AgentService._enrichment_skip_reason({})
assert "source_url" in reason
def test_allows_huggingface(self):
assert (
AgentService._enrichment_skip_reason(
{"hf_url": "https://huggingface.co/user/repo"}
)
== ""
)
def test_allows_modelscope(self):
assert (
AgentService._enrichment_skip_reason(
{
"source_platform": "modelscope",
"source_url": "https://modelscope.cn/models/user/repo",
}
)
== ""
)
def test_skips_tensorart_with_reason(self):
reason = AgentService._enrichment_skip_reason(
{
"source_platform": "tensorart",
"source_url": "https://tensor.art/models/827823520299086029",
}
)
assert "TensorArt" in reason
assert "not available" in reason
def test_skips_unknown_platform(self):
reason = AgentService._enrichment_skip_reason(
{"source_platform": "somewhere", "source_url": "https://somewhere.example/m/1"}
)
assert "somewhere" in reason
class TestBuildPromptContext:
@pytest.mark.asyncio
async def test_modelscope_card_populates_source_variables(self):
service = AgentService()
readme = "---\nbase_model: krea/Krea-2-Turbo\n---\n# krea\n"
card_context = ModelCardContext(
description="权重0.5-1.2。配合《krea2-Cc-MJ-风格滤镜》lora一起使用。",
base_model="krea/Krea-2-Turbo",
official_tags=["photography", "woman"],
example_images=["https://resources.modelscope.cn/cover-images/a.png"],
trigger_words=["kreamodel"],
)
with (
mock.patch(
"py.services.model_sources.modelscope.ModelScopeSource.fetch_model_card",
new=mock.AsyncMock(return_value=readme),
) as mock_fetch,
mock.patch(
"py.services.model_sources.modelscope.ModelScopeSource.fetch_model_card_context",
new=mock.AsyncMock(return_value=card_context),
) as mock_context,
mock.patch(
"py.metadata_ops.list_base_models",
new=mock.AsyncMock(return_value=["Krea 2 Turbo"]),
),
mock.patch(
"py.metadata_ops.identify_model_type",
new=mock.AsyncMock(return_value="lora"),
),
mock.patch(
"py.services.settings_manager.SettingsManager.get_priority_tag_config",
return_value={"lora": "style, subject"},
),
):
context = await service._build_prompt_context(
skill_name="enrich_hf_metadata",
model_path="/models/loras/krea.safetensors",
metadata={
"source_platform": "modelscope",
"source_url": "https://modelscope.cn/models/jj3550945163/Krea-2-LORA",
"file_name": "krea",
},
registry=mock.Mock(),
llm=mock.Mock(),
)
mock_fetch.assert_awaited_once_with("jj3550945163/Krea-2-LORA")
# The per-file lookup must receive the basename, not the full path.
mock_context.assert_awaited_once()
assert mock_context.call_args.args == (
"jj3550945163/Krea-2-LORA",
"krea.safetensors",
)
assert context["source_platform"] == "modelscope"
assert context["source_id"] == "jj3550945163/Krea-2-LORA"
assert context["source_label"] == "ModelScope"
assert (
context["asset_base_url"]
== "https://modelscope.cn/models/jj3550945163/Krea-2-LORA/resolve/master"
)
assert readme in context["readme_content_full"]
# Site-provided extras are rendered into their own prompt variables.
assert context["source_description"] == card_context.description
assert context["source_base_model"] == "krea/Krea-2-Turbo"
assert context["source_official_tags"] == "- photography\n- woman"
assert (
context["source_example_images"]
== "- https://resources.modelscope.cn/cover-images/a.png"
)
assert context["source_trigger_words"] == "kreamodel"
# The structured context is carried through for the post-processor.
assert context["source_context"] is card_context
# Hugging Face aliases stay empty for a non-HF source.
assert context["hf_url"] == ""
assert context["repo"] == "jj3550945163/Krea-2-LORA"
@pytest.mark.asyncio
async def test_huggingface_card_context_is_empty(self):
"""Sources without card extras contribute empty prompt variables."""
service = AgentService()
with (
mock.patch(
"py.services.model_sources.huggingface.HuggingFaceSource.fetch_model_card",
new=mock.AsyncMock(return_value="# card\n"),
),
mock.patch(
"py.metadata_ops.list_base_models",
new=mock.AsyncMock(return_value=[]),
),
mock.patch(
"py.metadata_ops.identify_model_type",
new=mock.AsyncMock(return_value="lora"),
),
mock.patch(
"py.services.settings_manager.SettingsManager.get_priority_tag_config",
return_value={},
),
):
context = await service._build_prompt_context(
skill_name="enrich_hf_metadata",
model_path="/models/loras/thing.safetensors",
metadata={"hf_url": "https://huggingface.co/user/repo"},
registry=mock.Mock(),
llm=mock.Mock(),
)
assert context["source_description"] == ""
assert context["source_official_tags"] == ""
assert context["source_example_images"] == ""
assert context["source_context"].is_empty()
@pytest.mark.asyncio
async def test_huggingface_keeps_legacy_aliases(self):
service = AgentService()
readme = "# card\n"
with (
mock.patch(
"py.services.model_sources.huggingface.HuggingFaceSource.fetch_model_card",
new=mock.AsyncMock(return_value=readme),
) as mock_fetch,
mock.patch(
"py.metadata_ops.list_base_models",
new=mock.AsyncMock(return_value=[]),
),
mock.patch(
"py.metadata_ops.identify_model_type",
new=mock.AsyncMock(return_value="lora"),
),
mock.patch(
"py.services.settings_manager.SettingsManager.get_priority_tag_config",
return_value={},
),
):
context = await service._build_prompt_context(
skill_name="enrich_hf_metadata",
model_path="/models/loras/thing.safetensors",
metadata={"hf_url": "https://huggingface.co/user/repo"},
registry=mock.Mock(),
llm=mock.Mock(),
)
mock_fetch.assert_awaited_once_with("user/repo")
assert context["source_platform"] == "huggingface"
assert context["hf_url"] == "https://huggingface.co/user/repo"
assert context["repo"] == "user/repo"
@pytest.mark.asyncio
async def test_tensorart_never_fetches_a_card(self):
service = AgentService()
with (
mock.patch(
"py.services.model_sources.huggingface.HuggingFaceSource.fetch_model_card",
new=mock.AsyncMock(),
) as hf_fetch,
mock.patch(
"py.services.model_sources.modelscope.ModelScopeSource.fetch_model_card",
new=mock.AsyncMock(),
) as ms_fetch,
mock.patch(
"py.metadata_ops.list_base_models",
new=mock.AsyncMock(return_value=[]),
),
mock.patch(
"py.metadata_ops.identify_model_type",
new=mock.AsyncMock(return_value="lora"),
),
mock.patch(
"py.services.settings_manager.SettingsManager.get_priority_tag_config",
return_value={},
),
):
context = await service._build_prompt_context(
skill_name="enrich_hf_metadata",
model_path="/models/loras/thing.safetensors",
metadata={
"source_platform": "tensorart",
"source_url": "https://tensor.art/models/827823520299086029",
},
registry=mock.Mock(),
llm=mock.Mock(),
)
hf_fetch.assert_not_awaited()
ms_fetch.assert_not_awaited()
assert context["readme_content"] == ""
assert context["source_platform"] == "tensorart"
class TestExecuteSkillThreadsSourceContext:
"""The structured site context must reach the post-processor intact."""
@pytest.mark.asyncio
async def test_source_context_reaches_the_post_processor(self):
service = AgentService()
card_context = ModelCardContext(
example_images=["https://resources.modelscope.cn/cover-images/a.png"]
)
skill = mock.Mock(llm_required=True, input_schema={})
registry = mock.Mock()
registry.get_skill.return_value = skill
registry.load_prompt.return_value = "{{model_path}}"
llm = mock.Mock()
llm.is_configured.return_value = True
llm.chat_completion_json = mock.AsyncMock(return_value={"base_model": "Krea 2"})
source_vars = {
"readme_content_full": "# card",
"source_description": "",
"source_base_model": "",
"source_official_tags": "",
"source_example_images": "",
"source_trigger_words": "",
"asset_base_url": "",
"readme_content": "# card",
}
with (
mock.patch.object(
service, "_ensure_registry", new=mock.AsyncMock(return_value=registry)
),
mock.patch.object(
service, "_ensure_llm", new=mock.AsyncMock(return_value=llm)
),
mock.patch.object(
service,
"_load_source_card",
new=mock.AsyncMock(return_value=(source_vars, card_context)),
),
mock.patch.object(
service,
"_build_prompt_context",
new=mock.AsyncMock(
return_value={
"model_path": "/p.safetensors",
"readme_content_full": "# card",
"source_context": card_context,
}
),
),
mock.patch(
"py.metadata_ops.read_metadata",
new=mock.AsyncMock(
return_value={
"source_platform": "modelscope",
"source_url": "https://modelscope.cn/models/u/r",
}
),
),
mock.patch(
"py.services.agent.agent_service.PostProcessor.process",
new=mock.AsyncMock(
return_value={"success": True, "updated_fields": []}
),
) as mock_process,
):
result = await service.execute_skill(
skill_name="enrich_hf_metadata",
input_data={"model_paths": ["/p.safetensors"]},
)
assert result.success is True
assert mock_process.call_args.kwargs["source_context"] is card_context
assert mock_process.call_args.kwargs["readme_content"] == "# card"
class TestSiteDataAppliedWithoutLlm:
"""A: the site's deterministic data must land even with no LLM available."""
@staticmethod
def _run(service, *, llm_configured: bool, card_context: ModelCardContext):
skill = mock.Mock(llm_required=True, input_schema={})
registry = mock.Mock()
registry.get_skill.return_value = skill
registry.load_prompt.return_value = "{{model_path}}"
llm = mock.Mock()
llm.is_configured.return_value = llm_configured
llm.chat_completion_json = mock.AsyncMock(return_value={"base_model": "Krea 2"})
source_vars = {
"readme_content_full": "# card",
"source_description": card_context.description,
"source_base_model": card_context.base_model,
"source_official_tags": "",
"source_example_images": "",
"source_trigger_words": "",
"asset_base_url": "",
"readme_content": "# card",
}
return skill, registry, llm, source_vars
@pytest.mark.asyncio
async def test_unconfigured_llm_still_applies_site_data(self):
service = AgentService()
card_context = ModelCardContext(
description="作者说明",
base_model="krea/Krea-2-Turbo",
official_tags=["photography"],
example_images=["https://cdn.example/a.png"],
)
skill, registry, llm, source_vars = self._run(
service, llm_configured=False, card_context=card_context
)
with (
mock.patch.object(
service, "_ensure_registry", new=mock.AsyncMock(return_value=registry)
),
mock.patch.object(
service, "_ensure_llm", new=mock.AsyncMock(return_value=llm)
),
mock.patch.object(
service,
"_load_source_card",
new=mock.AsyncMock(return_value=(source_vars, card_context)),
),
mock.patch.object(
service, "_resolve_site_base_model", new=mock.AsyncMock(return_value="Krea 2")
),
mock.patch(
"py.metadata_ops.read_metadata",
new=mock.AsyncMock(
return_value={
"source_platform": "modelscope",
"source_url": "https://modelscope.cn/models/u/r",
}
),
),
mock.patch(
"py.services.agent.agent_service.PostProcessor.process",
new=mock.AsyncMock(
return_value={"success": True, "updated_fields": []}
),
) as mock_process,
):
result = await service.execute_skill(
skill_name="enrich_hf_metadata",
input_data={"model_paths": ["/p.safetensors"]},
)
assert result.success is True
# No LLM call, but the deterministic payload reached the post-processor.
llm.chat_completion_json.assert_not_awaited()
kwargs = mock_process.call_args.kwargs
assert kwargs["source_context"] is card_context
assert kwargs["readme_content"] == "# card"
assert kwargs["resolved_base_model"] == "Krea 2"
assert kwargs["llm_output"] == {}
class TestLoadSourceCard:
@pytest.mark.asyncio
async def test_returns_empty_variables_without_a_source(self):
service = AgentService()
variables, context = await service._load_source_card("/p.safetensors", {})
assert context.is_empty() is True
assert variables["readme_content_full"] == ""
assert variables["readme_content"] == "(README not available)"
@pytest.mark.asyncio
async def test_collects_readme_and_site_extras(self):
service = AgentService()
card = ModelCardContext(
description="作者说明",
base_model="krea/Krea-2-Turbo",
official_tags=["photography", "woman"],
example_images=["https://cdn.example/a.png"],
trigger_words=["kreaface"],
)
with (
mock.patch(
"py.services.model_sources.modelscope.ModelScopeSource.fetch_model_card",
new=mock.AsyncMock(return_value="# card"),
),
mock.patch(
"py.services.model_sources.modelscope.ModelScopeSource.fetch_model_card_context",
new=mock.AsyncMock(return_value=card),
) as mock_ctx,
):
variables, context = await service._load_source_card(
"/models/loras/krea.safetensors",
{
"source_platform": "modelscope",
"source_url": "https://modelscope.cn/models/u/r",
},
)
mock_ctx.assert_awaited_once()
assert mock_ctx.call_args.args == ("u/r", "krea.safetensors")
assert context is card
assert variables["readme_content_full"] == "# card"
assert variables["source_description"] == "作者说明"
assert variables["source_official_tags"] == "- photography\n- woman"
assert variables["source_example_images"] == "- https://cdn.example/a.png"
assert variables["source_trigger_words"] == "kreaface"
assert variables["asset_base_url"].endswith("/resolve/master")
@pytest.mark.asyncio
async def test_resolve_site_base_model_uses_the_canonical_vocabulary(self):
service = AgentService()
with mock.patch(
"py.metadata_ops.list_base_models",
new=mock.AsyncMock(return_value=["Krea 2", "Flux.1 D"]),
):
resolved = await service._resolve_site_base_model(
ModelCardContext(
base_model="krea/Krea-2-Turbo",
base_model_aliases=["KREA_2", "KREA_2_TURBO"],
)
)
assert resolved == "Krea 2"
@pytest.mark.asyncio
async def test_resolve_site_base_model_is_empty_without_hints(self):
service = AgentService()
assert await service._resolve_site_base_model(ModelCardContext()) == ""
class TestLlmAlwaysRunsWhenConfigured:
"""Clicking "Enrich Metadata with AI" must always consult the LLM.
The site-provided data is applied deterministically either way, but it is
never treated as a reason to skip the call — the LLM's summary and notes
are richer than the raw site fields, and silently not calling out to the
provider would make the menu action unpredictable.
"""
@pytest.mark.asyncio
async def test_llm_runs_even_when_the_site_supplies_everything(self):
service = AgentService()
card_context = ModelCardContext(
description="作者说明",
base_model="krea/Krea-2-Turbo",
base_model_aliases=["KREA_2"],
official_tags=["photography", "woman"],
example_images=["https://cdn.example/a.png"],
trigger_words=["kreaface"],
)
skill = mock.Mock(llm_required=True, input_schema={})
registry = mock.Mock()
registry.get_skill.return_value = skill
registry.load_prompt.return_value = "{{model_path}}"
llm = mock.Mock()
llm.is_configured.return_value = True
llm.chat_completion_json = mock.AsyncMock(
return_value={"base_model": "Krea 2", "short_description": "llm summary"}
)
source_vars = {
"readme_content_full": "# card",
"source_description": card_context.description,
"source_base_model": card_context.base_model,
"source_official_tags": "- photography\n- woman",
"source_example_images": "- https://cdn.example/a.png",
"source_trigger_words": "kreaface",
"asset_base_url": "",
"readme_content": "# card",
}
with (
mock.patch.object(
service, "_ensure_registry", new=mock.AsyncMock(return_value=registry)
),
mock.patch.object(
service, "_ensure_llm", new=mock.AsyncMock(return_value=llm)
),
mock.patch.object(
service,
"_load_source_card",
new=mock.AsyncMock(return_value=(source_vars, card_context)),
),
mock.patch.object(
service,
"_build_prompt_context",
new=mock.AsyncMock(
return_value={"model_path": "/p.safetensors", "system_prompt": "sys"}
),
) as mock_prompt,
mock.patch(
"py.metadata_ops.read_metadata",
new=mock.AsyncMock(
return_value={
"source_platform": "modelscope",
"source_url": "https://modelscope.cn/models/u/r",
"base_model": "Krea 2",
}
),
),
mock.patch(
"py.services.agent.agent_service.PostProcessor.process",
new=mock.AsyncMock(
return_value={"success": True, "updated_fields": []}
),
),
):
result = await service.execute_skill(
skill_name="enrich_hf_metadata",
input_data={"model_paths": ["/p.safetensors"]},
)
assert result.success is True
llm.chat_completion_json.assert_awaited_once()
# The prompt is built from the already-fetched card, not re-fetched.
assert mock_prompt.call_args.kwargs["source_context"] is card_context
assert mock_prompt.call_args.kwargs["source_vars"] is source_vars