mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
feat(modelscope): read the model-detail API for card extras
ModelScope's model card is not just README.md: the author's summary (Description), the site-curated tags (OfficialTags), the internal architecture enums (VisionFoundation/SubVisionFoundation) and — per published version — the model filenames with that file's example images (coverImages) and trigger words all live in the model-detail API. AIGC repositories there frequently ship an auto-generated boilerplate README and put the only useful text in Description, so reading just the README yielded almost nothing. Add `ModelSource.fetch_model_card_context()` returning a new `ModelCardContext`, implemented by ModelScopeSource against the public (no API key) detail endpoint. Example images are matched to the model's basename through each version's `stats.fileList`, so every checkpoint in a collection repository gets its own images rather than a sibling's. Consume the context in the post-processor: * example images seed `civitai.images` and, being per-file, take priority in the preview fallback chain * the author summary becomes a paragraph in `modelDescription` and fills `civitai.description` when the LLM returns no short description * site-curated tags are always merged in, which also fixes the official `character-enhancement` being dropped by the prompt's no-hyphen rule * per-file trigger words are used before the repo-wide YAML `instance_prompt` * an explicitly stated strength range is recovered by regex so `usage_tips` is populated even without an LLM The prompt gains a Site-Provided Metadata section so the LLM can prefer the site's first-hand data over its own guesses.
This commit is contained in:
@@ -10,12 +10,16 @@ refresh cache). All actual I/O is delegated to :mod:`~py.metadata_ops`.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||||
|
from ..model_sources import ModelCardContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,6 +46,8 @@ class PostProcessor:
|
|||||||
llm_output: Dict[str, Any],
|
llm_output: Dict[str, Any],
|
||||||
metadata: Dict[str, Any],
|
metadata: Dict[str, Any],
|
||||||
readme_content: str = "",
|
readme_content: str = "",
|
||||||
|
source_context: Optional["ModelCardContext"] = None,
|
||||||
|
resolved_base_model: str = "",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Route *llm_output* to the correct skill post-processor.
|
"""Route *llm_output* to the correct skill post-processor.
|
||||||
|
|
||||||
@@ -49,12 +55,21 @@ class PostProcessor:
|
|||||||
that is converted to HTML and stored as ``modelDescription`` for
|
that is converted to HTML and stored as ``modelDescription`` for
|
||||||
the description tab.
|
the description tab.
|
||||||
|
|
||||||
|
*source_context* carries the extras the model site publishes outside
|
||||||
|
the README (author description, per-file example images, trigger
|
||||||
|
words). It is ``None`` for callers that have none.
|
||||||
|
|
||||||
|
*resolved_base_model* is the canonical base-model name the site's own
|
||||||
|
hints resolve to, used when the LLM did not supply one (which is the
|
||||||
|
normal case when the LLM was skipped).
|
||||||
|
|
||||||
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
|
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
|
||||||
``preview_downloaded`` (bool), and ``errors`` (list).
|
``preview_downloaded`` (bool), and ``errors`` (list).
|
||||||
"""
|
"""
|
||||||
if skill_name == "enrich_hf_metadata":
|
if skill_name == "enrich_hf_metadata":
|
||||||
return await self._process_enrich_hf_metadata(
|
return await self._process_enrich_hf_metadata(
|
||||||
model_path, llm_output, metadata, readme_content,
|
model_path, llm_output, metadata, readme_content, source_context,
|
||||||
|
resolved_base_model,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -72,6 +87,8 @@ class PostProcessor:
|
|||||||
llm_output: Dict[str, Any],
|
llm_output: Dict[str, Any],
|
||||||
metadata: Dict[str, Any],
|
metadata: Dict[str, Any],
|
||||||
readme_content: str = "",
|
readme_content: str = "",
|
||||||
|
source_context: Optional["ModelCardContext"] = None,
|
||||||
|
resolved_base_model: str = "",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
from ...metadata_ops import (
|
from ...metadata_ops import (
|
||||||
apply_metadata_updates,
|
apply_metadata_updates,
|
||||||
@@ -109,8 +126,11 @@ class PostProcessor:
|
|||||||
# -- Collect updates -----------------------------------------------
|
# -- Collect updates -----------------------------------------------
|
||||||
updates: Dict[str, Any] = {}
|
updates: Dict[str, Any] = {}
|
||||||
|
|
||||||
# base_model
|
# base_model — the LLM's mapping wins; when it returned nothing usable,
|
||||||
|
# fall back to the canonical name the site's own hints resolve to.
|
||||||
new_base = (llm_output.get("base_model") or "").strip()
|
new_base = (llm_output.get("base_model") or "").strip()
|
||||||
|
if not new_base:
|
||||||
|
new_base = (resolved_base_model or "").strip()
|
||||||
current_base = metadata.get("base_model", "") or ""
|
current_base = metadata.get("base_model", "") or ""
|
||||||
if new_base and self._should_overwrite(current_base, is_source_model):
|
if new_base and self._should_overwrite(current_base, is_source_model):
|
||||||
updates["base_model"] = new_base
|
updates["base_model"] = new_base
|
||||||
@@ -131,14 +151,29 @@ class PostProcessor:
|
|||||||
trig_civitai["trainedWords"] = cleaned
|
trig_civitai["trainedWords"] = cleaned
|
||||||
updates["civitai"] = trig_civitai
|
updates["civitai"] = trig_civitai
|
||||||
|
|
||||||
# modelDescription — from raw README content (converted to HTML)
|
# modelDescription — the author's own summary (when the site keeps one
|
||||||
if readme_content and is_source_model:
|
# outside the README, e.g. ModelScope's ``Description``) followed by the
|
||||||
|
# README converted to HTML.
|
||||||
|
site_description = (
|
||||||
|
(source_context.description if source_context else "") or ""
|
||||||
|
).strip()
|
||||||
|
if is_source_model and (site_description or readme_content):
|
||||||
|
parts: List[str] = []
|
||||||
|
if site_description:
|
||||||
|
parts.append(f"<p>{html.escape(site_description)}</p>")
|
||||||
|
if readme_content:
|
||||||
converted = convert_readme_to_html(readme_content)
|
converted = convert_readme_to_html(readme_content)
|
||||||
if converted:
|
if converted:
|
||||||
updates["modelDescription"] = converted
|
parts.append(converted)
|
||||||
|
if parts:
|
||||||
|
updates["modelDescription"] = "\n".join(parts)
|
||||||
|
|
||||||
# short_description → civitai.description (for "About this version")
|
# short_description → civitai.description (for "About this version").
|
||||||
|
# Falls back to the site's author summary, which for ModelScope AIGC
|
||||||
|
# models is frequently the only human-written text available.
|
||||||
short_desc = (llm_output.get("short_description") or "").strip()
|
short_desc = (llm_output.get("short_description") or "").strip()
|
||||||
|
if not short_desc:
|
||||||
|
short_desc = site_description
|
||||||
if short_desc and is_source_model:
|
if short_desc and is_source_model:
|
||||||
current_civitai = metadata.get("civitai") or {}
|
current_civitai = metadata.get("civitai") or {}
|
||||||
desc_civitai = dict(current_civitai)
|
desc_civitai = dict(current_civitai)
|
||||||
@@ -147,19 +182,31 @@ class PostProcessor:
|
|||||||
desc_civitai["description"] = short_desc
|
desc_civitai["description"] = short_desc
|
||||||
updates["civitai"] = desc_civitai
|
updates["civitai"] = desc_civitai
|
||||||
|
|
||||||
# gallery images → civitai.images (from YAML frontmatter widget entries
|
# gallery images → civitai.images (site example images, YAML frontmatter
|
||||||
# and Sample Gallery markdown tables in the README body)
|
# widget entries, and Sample Gallery markdown tables in the README body)
|
||||||
gallery_images: List[Dict[str, Any]] = []
|
rec_width = llm_output.get("recommended_width") or 0
|
||||||
if readme_content and is_source_model:
|
rec_height = llm_output.get("recommended_height") or 0
|
||||||
repo = source_id
|
|
||||||
if repo:
|
|
||||||
rec_w = llm_output.get("recommended_width") or 0
|
|
||||||
rec_h = llm_output.get("recommended_height") or 0
|
|
||||||
|
|
||||||
|
# Example images the site publishes for *this* file. They are matched
|
||||||
|
# by filename, so they are the most precise preview source available
|
||||||
|
# and the only one for repositories whose README carries no images.
|
||||||
|
site_images: List[Dict[str, Any]] = []
|
||||||
|
if is_source_model and source_context is not None:
|
||||||
|
site_images = [
|
||||||
|
_example_image(url, rec_width, rec_height)
|
||||||
|
for url in source_context.example_images
|
||||||
|
if url
|
||||||
|
]
|
||||||
|
|
||||||
|
gallery_images: List[Dict[str, Any]] = []
|
||||||
|
if (readme_content or site_images) and is_source_model:
|
||||||
|
repo = source_id
|
||||||
|
readme_images: List[Dict[str, Any]] = []
|
||||||
|
if readme_content and repo:
|
||||||
# 1. Widget images (YAML frontmatter)
|
# 1. Widget images (YAML frontmatter)
|
||||||
gallery = extract_gallery_images(
|
gallery = extract_gallery_images(
|
||||||
readme_content, repo,
|
readme_content, repo,
|
||||||
default_width=rec_w, default_height=rec_h,
|
default_width=rec_width, default_height=rec_height,
|
||||||
base_url=asset_base_url,
|
base_url=asset_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,7 +215,7 @@ class PostProcessor:
|
|||||||
table_images = extract_gallery_table_images(
|
table_images = extract_gallery_table_images(
|
||||||
readme_content, repo,
|
readme_content, repo,
|
||||||
existing_urls=existing_urls,
|
existing_urls=existing_urls,
|
||||||
default_width=rec_w, default_height=rec_h,
|
default_width=rec_width, default_height=rec_height,
|
||||||
base_url=asset_base_url,
|
base_url=asset_base_url,
|
||||||
)
|
)
|
||||||
existing_urls.update(img["url"] for img in table_images if img.get("url"))
|
existing_urls.update(img["url"] for img in table_images if img.get("url"))
|
||||||
@@ -177,7 +224,7 @@ class PostProcessor:
|
|||||||
simple_images = extract_simple_markdown_images(
|
simple_images = extract_simple_markdown_images(
|
||||||
readme_content, repo,
|
readme_content, repo,
|
||||||
existing_urls=existing_urls,
|
existing_urls=existing_urls,
|
||||||
default_width=rec_w, default_height=rec_h,
|
default_width=rec_width, default_height=rec_height,
|
||||||
base_url=asset_base_url,
|
base_url=asset_base_url,
|
||||||
)
|
)
|
||||||
existing_urls.update(img["url"] for img in simple_images if img.get("url"))
|
existing_urls.update(img["url"] for img in simple_images if img.get("url"))
|
||||||
@@ -186,11 +233,15 @@ class PostProcessor:
|
|||||||
html_images = extract_html_img_tags(
|
html_images = extract_html_img_tags(
|
||||||
readme_content, repo,
|
readme_content, repo,
|
||||||
existing_urls=existing_urls,
|
existing_urls=existing_urls,
|
||||||
default_width=rec_w, default_height=rec_h,
|
default_width=rec_width, default_height=rec_height,
|
||||||
base_url=asset_base_url,
|
base_url=asset_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
all_images = gallery + table_images + simple_images + html_images
|
readme_images = gallery + table_images + simple_images + html_images
|
||||||
|
|
||||||
|
# Site images come first so the preview fallback below prefers an
|
||||||
|
# image that is known to belong to this exact file.
|
||||||
|
all_images = _dedupe_images(site_images + readme_images)
|
||||||
if all_images:
|
if all_images:
|
||||||
gallery_images = all_images
|
gallery_images = all_images
|
||||||
current_civitai = metadata.get("civitai") or {}
|
current_civitai = metadata.get("civitai") or {}
|
||||||
@@ -200,11 +251,21 @@ class PostProcessor:
|
|||||||
gallery_civitai["images"] = all_images
|
gallery_civitai["images"] = all_images
|
||||||
updates["civitai"] = gallery_civitai
|
updates["civitai"] = gallery_civitai
|
||||||
|
|
||||||
# tags
|
# tags — the site's curated tags are authoritative content vocabulary, so
|
||||||
|
# they are kept alongside whatever the LLM proposed (the LLM is skipped
|
||||||
|
# entirely when the site data is complete, which is why this cannot rely
|
||||||
|
# on ``llm_output`` alone).
|
||||||
new_tags = llm_output.get("tags", [])
|
new_tags = llm_output.get("tags", [])
|
||||||
if isinstance(new_tags, list) and new_tags:
|
candidate_tags: List[str] = []
|
||||||
|
if is_source_model and source_context is not None:
|
||||||
|
candidate_tags.extend(source_context.official_tags)
|
||||||
|
if isinstance(new_tags, list):
|
||||||
|
candidate_tags.extend(
|
||||||
|
tag for tag in new_tags if tag not in candidate_tags
|
||||||
|
)
|
||||||
|
if candidate_tags:
|
||||||
existing_tags = metadata.get("tags") or []
|
existing_tags = metadata.get("tags") or []
|
||||||
merged = self._merge_tags(existing_tags, new_tags)
|
merged = self._merge_tags(existing_tags, candidate_tags)
|
||||||
if len(merged) > len(existing_tags) or is_source_model:
|
if len(merged) > len(existing_tags) or is_source_model:
|
||||||
updates["tags"] = merged
|
updates["tags"] = merged
|
||||||
|
|
||||||
@@ -217,16 +278,22 @@ class PostProcessor:
|
|||||||
if raw_confidence:
|
if raw_confidence:
|
||||||
updates["_llm_confidence"] = raw_confidence
|
updates["_llm_confidence"] = raw_confidence
|
||||||
|
|
||||||
# Fallback: extract instance_prompt from YAML frontmatter when the LLM
|
# Fallback: use the trigger words the site records for this exact file,
|
||||||
# returned empty trigger words but the README has instance_prompt.
|
# then the README's YAML `instance_prompt`, when the LLM returned none.
|
||||||
if trigger_words_empty:
|
if trigger_words_empty:
|
||||||
|
site_triggers = (
|
||||||
|
list(source_context.trigger_words) if source_context else []
|
||||||
|
)
|
||||||
|
if not site_triggers:
|
||||||
instance_prompt = _extract_yaml_instance_prompt(readme_content)
|
instance_prompt = _extract_yaml_instance_prompt(readme_content)
|
||||||
if instance_prompt:
|
if instance_prompt:
|
||||||
|
site_triggers = [instance_prompt]
|
||||||
|
if site_triggers:
|
||||||
current_civitai = metadata.get("civitai") or {}
|
current_civitai = metadata.get("civitai") or {}
|
||||||
trig_civitai = dict(current_civitai)
|
trig_civitai = dict(current_civitai)
|
||||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
||||||
trig_civitai.update(updates["civitai"])
|
trig_civitai.update(updates["civitai"])
|
||||||
trig_civitai["trainedWords"] = [instance_prompt]
|
trig_civitai["trainedWords"] = site_triggers
|
||||||
updates["civitai"] = trig_civitai
|
updates["civitai"] = trig_civitai
|
||||||
|
|
||||||
preview_remote_url = (llm_output.get("preview_url") or "").strip()
|
preview_remote_url = (llm_output.get("preview_url") or "").strip()
|
||||||
@@ -260,8 +327,12 @@ class PostProcessor:
|
|||||||
if new_notes:
|
if new_notes:
|
||||||
updates["notes"] = new_notes
|
updates["notes"] = new_notes
|
||||||
|
|
||||||
# usage_tips — JSON string (e.g. {"strength_min":0.85,"strength_max":1.4})
|
# usage_tips — JSON string (e.g. {"strength_min":0.85,"strength_max":1.4}).
|
||||||
|
# When the LLM returned nothing, recover an explicitly stated strength
|
||||||
|
# range from the author summary so the value is not lost.
|
||||||
raw_tips = (llm_output.get("usage_tips") or "").strip()
|
raw_tips = (llm_output.get("usage_tips") or "").strip()
|
||||||
|
if not raw_tips or raw_tips == "{}":
|
||||||
|
raw_tips = _extract_usage_tips(site_description)
|
||||||
if raw_tips and raw_tips != "{}":
|
if raw_tips and raw_tips != "{}":
|
||||||
try:
|
try:
|
||||||
json.loads(raw_tips)
|
json.loads(raw_tips)
|
||||||
@@ -324,6 +395,129 @@ class PostProcessor:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
#: Separator between a label and its value. Published model cards routinely
|
||||||
|
#: wrap the numbers in markdown emphasis or quotes (``strength: **0.85 - 1.4**``,
|
||||||
|
#: ``CLIP 强度「0.5」``), so those are absorbed rather than treated as a break.
|
||||||
|
_EMPHASIS = "[\"'\u201c\u201d\u300c\u300d*_`\\s]*"
|
||||||
|
|
||||||
|
#: An explicitly stated strength/weight range, e.g. ``权重0.5-1.2``,
|
||||||
|
#: ``强度 0.8 ~ 1.2``, ``strength: **0.85 - 1.4**``.
|
||||||
|
_RANGE_DASH = "(?:-|\u2010|\u2011|\u2012|\u2013|\u2014|\uff0d|~|\uff5e|\u81f3|\u5230|to)"
|
||||||
|
|
||||||
|
_STRENGTH_RANGE_RE = re.compile(
|
||||||
|
"(?:\u6743\u91cd|\u5f3a\u5ea6|strength|weight)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
|
||||||
|
+ r"(\d+(?:\.\d+)?)" + _EMPHASIS + _RANGE_DASH + _EMPHASIS
|
||||||
|
+ r"(\d+(?:\.\d+)?)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: A single strength/weight value, e.g. ``strength: 0.6``, ``权重 0.8``.
|
||||||
|
_STRENGTH_VALUE_RE = re.compile(
|
||||||
|
"(?:\u6743\u91cd|\u5f3a\u5ea6|strength|weight)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
|
||||||
|
+ r"(\d+(?:\.\d+)?)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: ``clip strength: 0.5`` / ``CLIP 强度 0.5``.
|
||||||
|
_CLIP_STRENGTH_RE = re.compile(
|
||||||
|
"clip" + _EMPHASIS + "(?:\u5f3a\u5ea6|strength)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
|
||||||
|
+ r"(\d+(?:\.\d+)?)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: ``clip skip: 2`` / ``CLIP 跳过 2``.
|
||||||
|
_CLIP_SKIP_RE = re.compile(
|
||||||
|
"clip" + _EMPHASIS + "(?:skip|\u8df3\u8fc7)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
|
||||||
|
+ r"(\d+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_usage_tips(text: str) -> str:
|
||||||
|
"""Extract stated strength/CLIP recommendations from prose.
|
||||||
|
|
||||||
|
This is the deterministic counterpart to the LLM's ``usage_tips`` output,
|
||||||
|
used when the LLM was skipped. It only recognises explicitly written
|
||||||
|
values — it never infers a range — and returns ``""`` when it finds none.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A JSON string matching the skill's ``usage_tips`` schema, or ``""``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
tips: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# CLIP strength is resolved first and then blanked out, so the generic
|
||||||
|
# strength patterns cannot mistake `CLIP 强度 0.5` for the LoRA strength.
|
||||||
|
text_for_strength = text
|
||||||
|
clip_strength = _CLIP_STRENGTH_RE.search(text_for_strength)
|
||||||
|
if clip_strength:
|
||||||
|
tips["clip_strength"] = float(clip_strength.group(1))
|
||||||
|
text_for_strength = (
|
||||||
|
text_for_strength[: clip_strength.start()]
|
||||||
|
+ " "
|
||||||
|
+ text_for_strength[clip_strength.end() :]
|
||||||
|
)
|
||||||
|
|
||||||
|
range_match = _STRENGTH_RANGE_RE.search(text_for_strength)
|
||||||
|
if range_match:
|
||||||
|
low = float(range_match.group(1))
|
||||||
|
high = float(range_match.group(2))
|
||||||
|
if low > high:
|
||||||
|
low, high = high, low
|
||||||
|
tips["strength_min"] = low
|
||||||
|
tips["strength_max"] = high
|
||||||
|
tips["strength_range"] = f"{low:g}-{high:g}"
|
||||||
|
else:
|
||||||
|
value_match = _STRENGTH_VALUE_RE.search(text_for_strength)
|
||||||
|
if value_match:
|
||||||
|
tips["strength"] = float(value_match.group(1))
|
||||||
|
|
||||||
|
clip_skip = _CLIP_SKIP_RE.search(text)
|
||||||
|
if clip_skip:
|
||||||
|
tips["clip_skip"] = int(clip_skip.group(1))
|
||||||
|
|
||||||
|
if not tips:
|
||||||
|
return ""
|
||||||
|
return json.dumps(tips, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _example_image(url: str, width: int, height: int) -> Dict[str, Any]:
|
||||||
|
"""Build a ``civitai.images`` entry for a site-provided example image.
|
||||||
|
|
||||||
|
The site publishes no prompt alongside these images, so the entry carries
|
||||||
|
empty prompt metadata and the LLM's recommended dimensions when it found
|
||||||
|
any (falling back to the same 512px placeholder the README extractors use).
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"type": "image",
|
||||||
|
"nsfwLevel": 0,
|
||||||
|
"width": width or 512,
|
||||||
|
"height": height or 512,
|
||||||
|
"meta": {"prompt": "", "negativePrompt": ""},
|
||||||
|
"hasMeta": False,
|
||||||
|
"hasPositivePrompt": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_images(images: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""Drop later entries that repeat an earlier image URL, keeping order."""
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique: List[Dict[str, Any]] = []
|
||||||
|
for image in images:
|
||||||
|
url = image.get("url") or ""
|
||||||
|
if not url or url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(url)
|
||||||
|
unique.append(image)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
def _extract_yaml_instance_prompt(readme_content: str) -> str:
|
def _extract_yaml_instance_prompt(readme_content: str) -> str:
|
||||||
"""Extract ``instance_prompt`` from the YAML frontmatter of a HF README.
|
"""Extract ``instance_prompt`` from the YAML frontmatter of a HF README.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,34 @@ You are an expert assistant for AI image generation models. Your task is to extr
|
|||||||
{{current_metadata}}
|
{{current_metadata}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Site-Provided Metadata (any field may be empty)
|
||||||
|
|
||||||
|
The model site publishes the following **alongside** the README. It is
|
||||||
|
first-hand information recorded by the site itself, so it outranks anything
|
||||||
|
you would otherwise guess:
|
||||||
|
|
||||||
|
- **Author description**: {{source_description}}
|
||||||
|
- **Base model reported by the site**: {{source_base_model}}
|
||||||
|
- **Trigger words recorded for this file**: {{source_trigger_words}}
|
||||||
|
- **Site-curated tags**:
|
||||||
|
{{source_official_tags}}
|
||||||
|
- **Example image URLs for this file**:
|
||||||
|
{{source_example_images}}
|
||||||
|
|
||||||
|
Use it as follows:
|
||||||
|
|
||||||
|
- A weight or strength range stated in the **author description** belongs in
|
||||||
|
``usage_tips`` (and in ``notes``); do not leave ``usage_tips`` empty when the
|
||||||
|
description states one.
|
||||||
|
- When the author description exists, base ``short_description`` on it rather
|
||||||
|
than on the README, which on some sites is auto-generated boilerplate.
|
||||||
|
- Treat the **site-curated tags** as strong signals for ``tags``: they are
|
||||||
|
already a curated content vocabulary, so prefer them over invented words.
|
||||||
|
- Treat the **base model reported by the site** as a strong hint for
|
||||||
|
``base_model``, but still map it to the EXACT canonical name from the
|
||||||
|
available base-model list.
|
||||||
|
- Use the **example image URLs** when the README contains no usable image.
|
||||||
|
|
||||||
## User Priority Tags Reference
|
## User Priority Tags Reference
|
||||||
|
|
||||||
The user has configured the following list of **meaningful tag categories** for this model type (`{{model_type}}`):
|
The user has configured the following list of **meaningful tag categories** for this model type (`{{model_type}}`):
|
||||||
@@ -55,10 +83,11 @@ Extract the following information from the README content above:
|
|||||||
### base_model
|
### base_model
|
||||||
The base model this model was trained on. Use EXACTLY one of the names from the **Available Base Models** list above. Do not invent new names or use aliases.
|
The base model this model was trained on. Use EXACTLY one of the names from the **Available Base Models** list above. Do not invent new names or use aliases.
|
||||||
|
|
||||||
Check the YAML frontmatter for ``base_model:`` first. If the frontmatter has no ``base_model:``, look at the **model filename** (``{{model_basename}}``), YAML ``tags:``, README title and first paragraph for clues — the base model family is often embedded in the name
|
Check the **base model reported by the site** (above) and the YAML frontmatter ``base_model:`` first. If neither yields a match, look at the **model filename** (``{{model_basename}}``), YAML ``tags:``, README title and first paragraph for clues — the base model family is often embedded in the name
|
||||||
|
|
||||||
### trigger_words
|
### trigger_words
|
||||||
The trigger words or activation prompts needed to use this LoRA. Look for:
|
The trigger words or activation prompts needed to use this LoRA. Look for:
|
||||||
|
- The **trigger words recorded for this file** in the site-provided metadata (most authoritative)
|
||||||
- `instance_prompt:` in the YAML frontmatter
|
- `instance_prompt:` in the YAML frontmatter
|
||||||
- Phrases like "trigger word:", "trigger:", "use this prompt:", "activation prompt:"
|
- Phrases like "trigger word:", "trigger:", "use this prompt:", "activation prompt:"
|
||||||
- In collection repos: the trigger section **specific to this model file** (look near matching download links or anchor IDs)
|
- In collection repos: the trigger section **specific to this model file** (look near matching download links or anchor IDs)
|
||||||
@@ -66,12 +95,13 @@ The trigger words or activation prompts needed to use this LoRA. Look for:
|
|||||||
Return as an array of strings. If none found, return an empty array `[]`. **Never** return `["None"]` or any placeholder value — a truly empty list means no trigger words exist.
|
Return as an array of strings. If none found, return an empty array `[]`. **Never** return `["None"]` or any placeholder value — a truly empty list means no trigger words exist.
|
||||||
|
|
||||||
### short_description
|
### short_description
|
||||||
A concise 1-2 sentence summary of what this model does. Extract from the "Model description" section or the first paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`, not the repo as a whole. Return empty string if the README is too minimal.
|
A concise 1-2 sentence summary of what this model does. For collection repos, focus on the **specific model version** matching `{{model_basename}}`, not the repo as a whole. Prefer the **author description** from the site-provided metadata when it is present; otherwise extract from the "Model description" section or the first paragraph. Return empty string if the available content is too minimal.
|
||||||
|
|
||||||
### tags
|
### tags
|
||||||
3-8 relevant tags for categorizing this model. **Quality over quantity.**
|
3-8 relevant tags for categorizing this model. **Quality over quantity.**
|
||||||
|
|
||||||
Sources to consider:
|
Sources to consider:
|
||||||
|
- The **site-curated tags** from the site-provided metadata (these are already filtered content tags — prefer them)
|
||||||
- The YAML frontmatter `tags:` list (filter out technical ones — see below)
|
- The YAML frontmatter `tags:` list (filter out technical ones — see below)
|
||||||
- The subject, style, character, or concept the model represents
|
- The subject, style, character, or concept the model represents
|
||||||
- The model filename itself may give clues (e.g. "pokemon", "anime", "pixelart")
|
- The model filename itself may give clues (e.g. "pokemon", "anime", "pixelart")
|
||||||
@@ -82,7 +112,9 @@ Sources to consider:
|
|||||||
|
|
||||||
2. **Cross-reference against the priority_tags reference.** Only include a tag if it meaningfully describes what the model actually creates (subject, style, character type) and is semantically close to one of the priority_tags. If none of the README's tags match meaningful categories, prefer returning a smaller set or an empty array over including low-value tags.
|
2. **Cross-reference against the priority_tags reference.** Only include a tag if it meaningfully describes what the model actually creates (subject, style, character type) and is semantically close to one of the priority_tags. If none of the README's tags match meaningful categories, prefer returning a smaller set or an empty array over including low-value tags.
|
||||||
|
|
||||||
3. **All lowercase, no spaces, no hyphens** (use single words like `"photorealistic"`, `"anime"`, `"character"`).
|
3. **All lowercase, no spaces, no hyphens** (use single words like `"photorealistic"`, `"anime"`, `"character"`). This rule applies to Latin-script tags; when the model's own tags are in another script (e.g. Chinese), keep them verbatim instead of dropping or translating them.
|
||||||
|
|
||||||
|
4. **Never invent a tag** that neither the site-provided metadata, the YAML frontmatter, nor the README text supports.
|
||||||
|
|
||||||
Return empty array if no meaningful content tags remain after filtering.
|
Return empty array if no meaningful content tags remain after filtering.
|
||||||
|
|
||||||
@@ -95,13 +127,13 @@ The URL of the most suitable preview image from the README. Look for:
|
|||||||
- The YAML frontmatter `widget:` section (which often has `output.url` fields)
|
- The YAML frontmatter `widget:` section (which often has `output.url` fields)
|
||||||
- In collection repos: the sample images listed **under the section** for this specific model version
|
- In collection repos: the sample images listed **under the section** for this specific model version
|
||||||
- Generic `` in the body
|
- Generic `` in the body
|
||||||
Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL from the repository raw-file base URL (`{{asset_base_url}}`) plus the relative path. If no suitable image is found, return an empty string.
|
Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL from the repository raw-file base URL (`{{asset_base_url}}`) plus the relative path. If the README has no suitable image, fall back to the site-provided **example image URLs** for this file. If nothing is available, return an empty string.
|
||||||
|
|
||||||
### notes
|
### notes
|
||||||
A plain-text summary of the model card's key practical usage information. Combine trigger words, style modifiers, recommended parameters (steps, CFG, resolution, sampler), and any setup tips into a readable paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`. Return empty string if the README has no useful usage info.
|
A plain-text summary of the model card's key practical usage information. Combine trigger words, style modifiers, recommended parameters (steps, CFG, resolution, sampler), and any setup tips into a readable paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`. Include the **author description** from the site-provided metadata when it is present. Return empty string if there is no useful usage info.
|
||||||
|
|
||||||
### usage_tips
|
### usage_tips
|
||||||
A JSON string with structured usage recommendations. Extract from the README any explicit ranges or recommended values (e.g. "Set LoRA strength: **0.85 - 1.4**", "CLIP strength: 0.5"). Possible fields (include only those you can determine):
|
A JSON string with structured usage recommendations. Extract from the **author description** (site-provided metadata) and the README any explicit ranges or recommended values (e.g. "Set LoRA strength: **0.85 - 1.4**", "CLIP strength: 0.5", "权重0.5-1.2"). Possible fields (include only those you can determine):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
from .base import (
|
from .base import (
|
||||||
GROUP_PREFIXES,
|
GROUP_PREFIXES,
|
||||||
HTTP_TIMEOUT,
|
HTTP_TIMEOUT,
|
||||||
|
ModelCardContext,
|
||||||
ModelSource,
|
ModelSource,
|
||||||
ModelSourceError,
|
ModelSourceError,
|
||||||
SourceRef,
|
SourceRef,
|
||||||
@@ -45,6 +46,7 @@ __all__ = [
|
|||||||
"GROUP_PREFIXES",
|
"GROUP_PREFIXES",
|
||||||
"HTTP_TIMEOUT",
|
"HTTP_TIMEOUT",
|
||||||
"LEGACY_HF_URL_FIELD",
|
"LEGACY_HF_URL_FIELD",
|
||||||
|
"ModelCardContext",
|
||||||
"ModelSource",
|
"ModelSource",
|
||||||
"ModelSourceError",
|
"ModelSourceError",
|
||||||
"HuggingFaceSource",
|
"HuggingFaceSource",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ know about such a site is expressed by :class:`ModelSource`:
|
|||||||
* how to recognise one of its URLs (:meth:`ModelSource.parse`)
|
* how to recognise one of its URLs (:meth:`ModelSource.parse`)
|
||||||
* the canonical page URL for a source id (:meth:`ModelSource.canonical_url`)
|
* the canonical page URL for a source id (:meth:`ModelSource.canonical_url`)
|
||||||
* how to fetch the model card (:meth:`ModelSource.fetch_model_card`)
|
* how to fetch the model card (:meth:`ModelSource.fetch_model_card`)
|
||||||
|
* how to fetch the extras that live *outside* the README
|
||||||
|
(:meth:`ModelSource.fetch_model_card_context`)
|
||||||
* how to turn repository-relative asset paths into absolute URLs
|
* how to turn repository-relative asset paths into absolute URLs
|
||||||
(:meth:`ModelSource.asset_base_url`)
|
(:meth:`ModelSource.asset_base_url`)
|
||||||
* which capabilities the site actually supports
|
* which capabilities the site actually supports
|
||||||
@@ -22,7 +24,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Iterable, Optional
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -61,6 +63,56 @@ class SourceRef:
|
|||||||
"""Canonical URL of the model page."""
|
"""Canonical URL of the model page."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelCardContext:
|
||||||
|
"""Site-specific extras that accompany a model's README model card.
|
||||||
|
|
||||||
|
A model card is not always just ``README.md``. ModelScope, for example,
|
||||||
|
keeps the author's summary, the site-curated tags, and the per-file
|
||||||
|
example images in its model-detail API rather than in the repository.
|
||||||
|
Sources with no such extras return an empty context (the default), so
|
||||||
|
every field here must be treated as optional by callers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
description: str = ""
|
||||||
|
"""Author-written summary shown on the model page, outside the README."""
|
||||||
|
|
||||||
|
base_model: str = ""
|
||||||
|
"""Base model as reported by the site (possibly a site-local id)."""
|
||||||
|
|
||||||
|
base_model_aliases: list[str] = field(default_factory=list)
|
||||||
|
"""Other names the site uses for the same base model.
|
||||||
|
|
||||||
|
Sites often publish both a link-style id (``krea/Krea-2-Turbo``) and an
|
||||||
|
internal architecture enum (``KREA_2``). The enum usually normalises
|
||||||
|
cleanly onto this system's canonical vocabulary, so it is the better
|
||||||
|
resolution hint for :mod:`py.services.agent.base_model_resolver`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
official_tags: list[str] = field(default_factory=list)
|
||||||
|
"""Content tags curated by the site itself."""
|
||||||
|
|
||||||
|
example_images: list[str] = field(default_factory=list)
|
||||||
|
"""Absolute URLs of example images for the requested model file."""
|
||||||
|
|
||||||
|
trigger_words: list[str] = field(default_factory=list)
|
||||||
|
"""Trigger words the site records for the requested model file."""
|
||||||
|
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
"""Return ``True`` when the site contributed nothing extra."""
|
||||||
|
|
||||||
|
return not any(
|
||||||
|
(
|
||||||
|
self.description,
|
||||||
|
self.base_model,
|
||||||
|
self.base_model_aliases,
|
||||||
|
self.official_tags,
|
||||||
|
self.example_images,
|
||||||
|
self.trigger_words,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModelSourceError(Exception):
|
class ModelSourceError(Exception):
|
||||||
"""Raised when a model source cannot satisfy a request.
|
"""Raised when a model source cannot satisfy a request.
|
||||||
|
|
||||||
@@ -230,6 +282,22 @@ class ModelSource:
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
async def fetch_model_card_context(
|
||||||
|
self, source_id: str, filename: str = ""
|
||||||
|
) -> ModelCardContext:
|
||||||
|
"""Return the card extras the site keeps outside the README.
|
||||||
|
|
||||||
|
*filename* is the model file's basename (no directory) and selects
|
||||||
|
the right entry when a repository holds several models. Sites whose
|
||||||
|
model card is fully described by :meth:`fetch_model_card` need no
|
||||||
|
override and inherit this empty context.
|
||||||
|
|
||||||
|
Implementations must never raise: enrichment treats a missing
|
||||||
|
context as "the site had nothing extra to say".
|
||||||
|
"""
|
||||||
|
|
||||||
|
return ModelCardContext()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Download support
|
# Download support
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -301,6 +369,7 @@ def filter_weight_files(entries: Iterable[tuple[str, int]]) -> list[dict[str, An
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"GROUP_PREFIXES",
|
"GROUP_PREFIXES",
|
||||||
"HTTP_TIMEOUT",
|
"HTTP_TIMEOUT",
|
||||||
|
"ModelCardContext",
|
||||||
"ModelSource",
|
"ModelSource",
|
||||||
"ModelSourceError",
|
"ModelSourceError",
|
||||||
"SourceRef",
|
"SourceRef",
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
ModelScope exposes the same "model card as README.md" convention as
|
ModelScope exposes the same "model card as README.md" convention as
|
||||||
Hugging Face, including a YAML frontmatter block that often carries
|
Hugging Face, including a YAML frontmatter block that often carries
|
||||||
``base_model:`` and ``trigger_words:``. Three public endpoints are used,
|
``base_model:`` and ``trigger_words:``. Four public endpoints are used,
|
||||||
none of which requires an API key for public models:
|
none of which requires an API key for public models:
|
||||||
|
|
||||||
* ``/models/{owner}/{name}/resolve/{revision}/README.md`` — raw model card
|
* ``/models/{owner}/{name}/resolve/{revision}/README.md`` — raw model card
|
||||||
* ``/api/v1/models/{owner}/{name}/repo?Revision=..&FilePath=README.md`` —
|
* ``/api/v1/models/{owner}/{name}/repo?Revision=..&FilePath=README.md`` —
|
||||||
the same content through the API, used as a fallback when the resolve
|
the same content through the API, used as a fallback when the resolve
|
||||||
URL is unavailable.
|
URL is unavailable.
|
||||||
|
* ``/api/v1/models/{owner}/{name}`` — the model-detail payload behind the
|
||||||
|
model page. It carries the author's summary (``Description``), the
|
||||||
|
site-curated tags (``OfficialTags``), and, per published version, the
|
||||||
|
model filenames (``MuseInfo.versions[].stats.fileList``) together with
|
||||||
|
that file's example images (``coverImages``) and trigger words. See
|
||||||
|
:meth:`ModelScopeSource.fetch_model_card_context`.
|
||||||
* ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` — the file
|
* ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` — the file
|
||||||
listing backing the download picker. It reports real sizes for LFS
|
listing backing the download picker. It reports real sizes for LFS
|
||||||
files (not the pointer size), so no extra HEAD request is needed.
|
files (not the pointer size), so no extra HEAD request is needed.
|
||||||
@@ -22,10 +28,14 @@ valid; the CDN URL must never be cached.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
|
ModelCardContext,
|
||||||
ModelSource,
|
ModelSource,
|
||||||
ModelSourceError,
|
ModelSourceError,
|
||||||
fetch_json,
|
fetch_json,
|
||||||
@@ -95,6 +105,47 @@ class ModelScopeSource(ModelSource):
|
|||||||
return text
|
return text
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
async def fetch_model_card_context(
|
||||||
|
self, source_id: str, filename: str = ""
|
||||||
|
) -> ModelCardContext:
|
||||||
|
"""Read the model-detail API that backs the ModelScope model page.
|
||||||
|
|
||||||
|
ModelScope splits a model card in two: ``README.md`` holds the
|
||||||
|
long-form content, while the author's summary, the site-curated tags,
|
||||||
|
and the per-file example images live only here. AIGC repositories
|
||||||
|
frequently ship an auto-generated README ("the contributor provided
|
||||||
|
no further description") and put everything useful in ``Description``,
|
||||||
|
so enrichment that reads only the README comes back nearly empty.
|
||||||
|
|
||||||
|
Example images are matched to *filename* through each version's
|
||||||
|
``stats.fileList``, which means the images returned belong to the
|
||||||
|
exact ``.safetensors`` being enriched — essential for collection
|
||||||
|
repositories, where every checkpoint has its own sample image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status, payload = await fetch_json(
|
||||||
|
f"https://modelscope.cn/api/v1/models/{source_id}"
|
||||||
|
)
|
||||||
|
if status != 200 or not isinstance(payload, dict):
|
||||||
|
logger.debug("ModelScope detail API returned HTTP %s for %s", status, source_id)
|
||||||
|
return ModelCardContext()
|
||||||
|
data = payload.get("Data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return ModelCardContext()
|
||||||
|
|
||||||
|
context = ModelCardContext(
|
||||||
|
description=_clean_text(data.get("Description")),
|
||||||
|
base_model=_first_string(data.get("BaseModel")),
|
||||||
|
base_model_aliases=_base_model_aliases(data),
|
||||||
|
official_tags=_official_tags(data.get("OfficialTags")),
|
||||||
|
)
|
||||||
|
|
||||||
|
versions = _matching_versions(data.get("MuseInfo"), filename)
|
||||||
|
if versions:
|
||||||
|
context.example_images = _cover_image_urls(versions)
|
||||||
|
context.trigger_words = _version_trigger_words(versions)
|
||||||
|
return context
|
||||||
|
|
||||||
async def list_files(
|
async def list_files(
|
||||||
self, source_id: str, revision: str = ""
|
self, source_id: str, revision: str = ""
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
@@ -142,3 +193,213 @@ class ModelScopeSource(ModelSource):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = ["ModelScopeSource"]
|
__all__ = ["ModelScopeSource"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Model-detail API parsing helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Trigger-word values that mean "the author left this blank".
|
||||||
|
_EMPTY_TRIGGER_VALUES = frozenset({"none", "null", "n/a"})
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value: Any) -> str:
|
||||||
|
"""Return a stripped string for *value*, or ``""`` for anything else."""
|
||||||
|
|
||||||
|
return value.strip() if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _first_string(value: Any) -> str:
|
||||||
|
"""Return the first non-empty string in a list, or ``""``."""
|
||||||
|
|
||||||
|
if isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
text = _clean_text(item)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _base_model_aliases(data: dict[str, Any]) -> list[str]:
|
||||||
|
"""Return the site's own names for the base model.
|
||||||
|
|
||||||
|
ModelScope publishes a link-style id (``krea/Krea-2-Turbo``) plus its
|
||||||
|
internal architecture enums (``VisionFoundation: KREA_2``,
|
||||||
|
``SubVisionFoundation: KREA_2_TURBO``). The enums are the better
|
||||||
|
resolution hint because they normalise onto this system's canonical
|
||||||
|
vocabulary, so they come first; the owner prefix is also stripped from
|
||||||
|
the link-style ids.
|
||||||
|
"""
|
||||||
|
|
||||||
|
aliases: list[str] = []
|
||||||
|
for key in ("VisionFoundation", "SubVisionFoundation"):
|
||||||
|
value = _clean_text(data.get(key))
|
||||||
|
if value and value not in aliases:
|
||||||
|
aliases.append(value)
|
||||||
|
|
||||||
|
base_models = data.get("BaseModel")
|
||||||
|
if isinstance(base_models, list):
|
||||||
|
for item in base_models:
|
||||||
|
text = _clean_text(item)
|
||||||
|
leaf = text.rsplit("/", 1)[-1] if text else ""
|
||||||
|
if leaf and leaf not in aliases:
|
||||||
|
aliases.append(leaf)
|
||||||
|
return aliases
|
||||||
|
|
||||||
|
|
||||||
|
def _official_tags(value: Any) -> list[str]:
|
||||||
|
"""Extract the site-curated tag values from ``OfficialTags``.
|
||||||
|
|
||||||
|
ModelScope's entries are dicts carrying an English ``Tag`` plus a
|
||||||
|
``ChineseName``; the English value is the curated content vocabulary, so
|
||||||
|
that is the one surfaced here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tags: list[str] = []
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return tags
|
||||||
|
for entry in value:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
tag = _clean_text(entry.get("Tag"))
|
||||||
|
if tag and tag not in tags:
|
||||||
|
tags.append(tag)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def _version_files(version: dict[str, Any]) -> list[str]:
|
||||||
|
"""Return the model filenames covered by one ``MuseInfo.versions`` entry.
|
||||||
|
|
||||||
|
The listing normally sits in ``stats.fileList``; some payloads only
|
||||||
|
carry the same field as a JSON-encoded string under
|
||||||
|
``modelVersion.stats``, so both shapes are accepted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
stats = version.get("stats")
|
||||||
|
files = stats.get("fileList") if isinstance(stats, dict) else None
|
||||||
|
|
||||||
|
if not isinstance(files, list):
|
||||||
|
model_version = version.get("modelVersion")
|
||||||
|
raw = model_version.get("stats") if isinstance(model_version, dict) else None
|
||||||
|
if isinstance(raw, str) and raw.strip():
|
||||||
|
try:
|
||||||
|
decoded = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
decoded = None
|
||||||
|
if isinstance(decoded, dict):
|
||||||
|
files = decoded.get("fileList")
|
||||||
|
|
||||||
|
if not isinstance(files, list):
|
||||||
|
return []
|
||||||
|
return [item for item in files if isinstance(item, str) and item]
|
||||||
|
|
||||||
|
|
||||||
|
def _version_show_name(version: dict[str, Any]) -> str:
|
||||||
|
"""Return the human-facing version label (e.g. ``c1-st1000``)."""
|
||||||
|
|
||||||
|
model_version = version.get("modelVersion")
|
||||||
|
if not isinstance(model_version, dict):
|
||||||
|
return ""
|
||||||
|
return _clean_text(model_version.get("showName")).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_versions(muse_info: Any, filename: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return the ``versions`` entries that publish *filename*.
|
||||||
|
|
||||||
|
Matching is by exact basename first, then by the version's ``showName``
|
||||||
|
appearing in the file stem (which absorbs the naming drift ModelScope
|
||||||
|
sometimes applies to uploaded weights). All matches are returned so a
|
||||||
|
file re-published across several versions contributes all of its
|
||||||
|
example images. With no *filename* only an unambiguous single-version
|
||||||
|
repository is used, because a per-file image must never be attributed
|
||||||
|
to the wrong file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(muse_info, dict):
|
||||||
|
return []
|
||||||
|
versions = muse_info.get("versions")
|
||||||
|
if not isinstance(versions, list):
|
||||||
|
return []
|
||||||
|
entries = [entry for entry in versions if isinstance(entry, dict)]
|
||||||
|
if not entries:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
return entries if len(entries) == 1 else []
|
||||||
|
|
||||||
|
target = os.path.basename(filename).strip().lower()
|
||||||
|
if not target:
|
||||||
|
return []
|
||||||
|
stem = os.path.splitext(target)[0]
|
||||||
|
|
||||||
|
exact: list[dict[str, Any]] = []
|
||||||
|
fuzzy: list[dict[str, Any]] = []
|
||||||
|
for version in entries:
|
||||||
|
files = {os.path.basename(path).lower() for path in _version_files(version)}
|
||||||
|
if target in files:
|
||||||
|
exact.append(version)
|
||||||
|
continue
|
||||||
|
show_name = _version_show_name(version)
|
||||||
|
if show_name and show_name in stem:
|
||||||
|
fuzzy.append(version)
|
||||||
|
|
||||||
|
return exact or fuzzy
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_image_urls(versions: list[dict[str, Any]]) -> list[str]:
|
||||||
|
"""Collect the example-image URLs published by the given versions."""
|
||||||
|
|
||||||
|
urls: list[str] = []
|
||||||
|
for version in versions:
|
||||||
|
covers = version.get("coverImages")
|
||||||
|
if not isinstance(covers, list):
|
||||||
|
continue
|
||||||
|
for cover in covers:
|
||||||
|
if not isinstance(cover, dict):
|
||||||
|
continue
|
||||||
|
url = _clean_text(cover.get("url"))
|
||||||
|
if url and url not in urls:
|
||||||
|
urls.append(url)
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def _version_trigger_words(versions: list[dict[str, Any]]) -> list[str]:
|
||||||
|
"""Return the first non-empty trigger-word list across *versions*."""
|
||||||
|
|
||||||
|
for version in versions:
|
||||||
|
model_version = version.get("modelVersion")
|
||||||
|
raw = (
|
||||||
|
model_version.get("triggerWords")
|
||||||
|
if isinstance(model_version, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
words = _parse_trigger_words(raw)
|
||||||
|
if words:
|
||||||
|
return words
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_trigger_words(raw: Any) -> list[str]:
|
||||||
|
"""Decode ModelScope's JSON-encoded trigger-word string list."""
|
||||||
|
|
||||||
|
if isinstance(raw, list):
|
||||||
|
candidates = raw
|
||||||
|
elif isinstance(raw, str) and raw.strip():
|
||||||
|
try:
|
||||||
|
decoded = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return []
|
||||||
|
if not isinstance(decoded, list):
|
||||||
|
return []
|
||||||
|
candidates = decoded
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
words: list[str] = []
|
||||||
|
for item in candidates:
|
||||||
|
word = _clean_text(item)
|
||||||
|
if not word or word.lower() in _EMPTY_TRIGGER_VALUES:
|
||||||
|
continue
|
||||||
|
if word not in words:
|
||||||
|
words.append(word)
|
||||||
|
return words
|
||||||
|
|||||||
@@ -332,6 +332,190 @@ class TestAssetBaseUrl:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Model card context (site extras kept outside the README)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _modelscope_detail_payload() -> dict:
|
||||||
|
"""A trimmed-but-faithful ModelScope model-detail response.
|
||||||
|
|
||||||
|
Mirrors the shape of ``/api/v1/models/{id}`` for an AIGC LoRA repo whose
|
||||||
|
README is auto-generated boilerplate, so the author summary and the
|
||||||
|
per-file example images are only reachable through this API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Code": 200,
|
||||||
|
"Data": {
|
||||||
|
"Name": "Krea-2-LORA",
|
||||||
|
"ChineseName": "krea脸模",
|
||||||
|
"Description": "权重0.5-1.2。配合《风格滤镜》lora一起使用。",
|
||||||
|
"BaseModel": ["krea/Krea-2-Turbo"],
|
||||||
|
"License": "Apache License 2.0",
|
||||||
|
"OfficialTags": [
|
||||||
|
{"Tag": "photography", "ChineseName": "写实摄影"},
|
||||||
|
{"Tag": "woman", "ChineseName": "女生"},
|
||||||
|
{"Tag": "photography", "ChineseName": "重复项"},
|
||||||
|
],
|
||||||
|
"MuseInfo": {
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"stats": {"fileList": ["Krea-2-LORA_c1-st8000.safetensors"]},
|
||||||
|
"modelVersion": {"showName": "c1-st8000", "triggerWords": '[""]'},
|
||||||
|
"coverImages": [
|
||||||
|
{"url": "https://resources.modelscope.cn/cover-images/a.png"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stats": {"fileList": ["Krea-2-LORA_c1-st1000.safetensors"]},
|
||||||
|
"modelVersion": {
|
||||||
|
"showName": "c1-st1000",
|
||||||
|
"triggerWords": '["kreaface","kreamodel"]',
|
||||||
|
},
|
||||||
|
"coverImages": [
|
||||||
|
{"url": "https://resources.modelscope.cn/cover-images/b.png"},
|
||||||
|
{"url": "https://resources.modelscope.cn/cover-images/c.png"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchModelCardContext:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_reads_description_tags_and_base_model(self, monkeypatch):
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
assert url == "https://modelscope.cn/api/v1/models/u/r"
|
||||||
|
return 200, _modelscope_detail_payload()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
|
||||||
|
context = await ModelScopeSource().fetch_model_card_context("u/r")
|
||||||
|
|
||||||
|
assert context.description == "权重0.5-1.2。配合《风格滤镜》lora一起使用。"
|
||||||
|
assert context.base_model == "krea/Krea-2-Turbo"
|
||||||
|
# OfficialTag values only, de-duplicated, order preserved.
|
||||||
|
assert context.official_tags == ["photography", "woman"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_matches_example_images_by_filename(self, monkeypatch):
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 200, _modelscope_detail_payload()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
|
||||||
|
context = await ModelScopeSource().fetch_model_card_context(
|
||||||
|
"u/r", "Krea-2-LORA_c1-st1000.safetensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only the requested file's images, never a sibling checkpoint's.
|
||||||
|
assert context.example_images == [
|
||||||
|
"https://resources.modelscope.cn/cover-images/b.png",
|
||||||
|
"https://resources.modelscope.cn/cover-images/c.png",
|
||||||
|
]
|
||||||
|
assert context.trigger_words == ["kreaface", "kreamodel"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_never_borrows_images_for_an_unknown_file(
|
||||||
|
self, monkeypatch
|
||||||
|
):
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 200, _modelscope_detail_payload()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
|
||||||
|
context = await ModelScopeSource().fetch_model_card_context(
|
||||||
|
"u/r", "other.safetensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.example_images == []
|
||||||
|
assert context.trigger_words == []
|
||||||
|
# The repo-wide fields are still returned.
|
||||||
|
assert context.base_model == "krea/Krea-2-Turbo"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_single_version_repo_without_filename(self, monkeypatch):
|
||||||
|
payload = _modelscope_detail_payload()
|
||||||
|
versions = payload["Data"]["MuseInfo"]["versions"]
|
||||||
|
payload["Data"]["MuseInfo"]["versions"] = versions[:1]
|
||||||
|
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 200, payload
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
|
||||||
|
context = await ModelScopeSource().fetch_model_card_context("u/r")
|
||||||
|
|
||||||
|
assert context.example_images == [
|
||||||
|
"https://resources.modelscope.cn/cover-images/a.png"
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_tolerates_failures_and_odd_payloads(self, monkeypatch):
|
||||||
|
payloads = (None, {"Code": 500}, {"Data": "nope"}, {"Data": {}})
|
||||||
|
for payload in payloads:
|
||||||
|
|
||||||
|
async def fake_fetch_json(url, _payload=payload, **_kwargs):
|
||||||
|
return (0 if _payload is None else 200), _payload
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
context = await ModelScopeSource().fetch_model_card_context(
|
||||||
|
"u/r", "a.safetensors"
|
||||||
|
)
|
||||||
|
assert context.is_empty(), payload
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_reads_stats_from_json_encoded_fallback(self, monkeypatch):
|
||||||
|
payload = {
|
||||||
|
"Data": {
|
||||||
|
"MuseInfo": {
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"modelVersion": {
|
||||||
|
"showName": "v1",
|
||||||
|
"stats": '{"fileList": ["model.safetensors"]}',
|
||||||
|
"triggerWords": '["hi"]',
|
||||||
|
},
|
||||||
|
"coverImages": [{"url": "https://cdn.example/x.png"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 200, payload
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
|
||||||
|
context = await ModelScopeSource().fetch_model_card_context(
|
||||||
|
"u/r", "model.safetensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.example_images == ["https://cdn.example/x.png"]
|
||||||
|
assert context.trigger_words == ["hi"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_context_is_empty_for_other_sources(self):
|
||||||
|
assert (await HuggingFaceSource().fetch_model_card_context("u/r")).is_empty()
|
||||||
|
assert (await TensorArtSource().fetch_model_card_context("123")).is_empty()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Download support
|
# Download support
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ functions and verify the business logic (conditions, merges, dispatch).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py.services.agent.post_processor import PostProcessor
|
from py.services.agent.post_processor import PostProcessor
|
||||||
|
from py.services.model_sources import ModelCardContext
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -523,3 +525,481 @@ class TestMergeTags:
|
|||||||
result = PostProcessor._merge_tags(existing, new)
|
result = PostProcessor._merge_tags(existing, new)
|
||||||
# All tags are lowercased (matching TagUpdateService behaviour)
|
# All tags are lowercased (matching TagUpdateService behaviour)
|
||||||
assert result == ["anime", "flux", "lora"]
|
assert result == ["anime", "flux", "lora"]
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# enrich_hf_metadata — site-provided card extras (ModelCardContext)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteProvidedContext:
|
||||||
|
"""ModelScope keeps the author summary, the curated tags and the per-file
|
||||||
|
example images outside the README; these tests pin how they are applied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MODELSCOPE_METADATA = {
|
||||||
|
"from_civitai": False,
|
||||||
|
"source_platform": "modelscope",
|
||||||
|
"source_url": "https://modelscope.cn/models/user/repo",
|
||||||
|
}
|
||||||
|
|
||||||
|
LLM_OUTPUT = {
|
||||||
|
"base_model": "",
|
||||||
|
"trigger_words": [],
|
||||||
|
"short_description": "",
|
||||||
|
"tags": [],
|
||||||
|
"recommended_width": 0,
|
||||||
|
"recommended_height": 0,
|
||||||
|
"preview_url": "",
|
||||||
|
"confidence": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_example_images_become_gallery_and_preview(self, processor):
|
||||||
|
"""A boilerplate README still yields images and a downloaded preview."""
|
||||||
|
context = ModelCardContext(
|
||||||
|
example_images=[
|
||||||
|
"https://resources.modelscope.cn/cover-images/a.png",
|
||||||
|
"https://resources.modelscope.cn/cover-images/b.png",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
boilerplate = "### 当前模型的贡献者未提供更加详细的模型介绍。\n"
|
||||||
|
|
||||||
|
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"),
|
||||||
|
):
|
||||||
|
mock_dl.return_value = "/p.webp"
|
||||||
|
result = await processor.process(
|
||||||
|
skill_name="enrich_hf_metadata",
|
||||||
|
model_path="/p.safetensors",
|
||||||
|
llm_output=self.LLM_OUTPUT,
|
||||||
|
metadata=dict(self.MODELSCOPE_METADATA),
|
||||||
|
readme_content=boilerplate,
|
||||||
|
source_context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
applied = mock_apply.call_args[0][1]
|
||||||
|
images = applied["civitai"]["images"]
|
||||||
|
assert [img["url"] for img in images] == context.example_images
|
||||||
|
assert images[0]["type"] == "image"
|
||||||
|
# The first (per-file) site image is used as the preview.
|
||||||
|
mock_dl.assert_awaited_once_with(
|
||||||
|
"/p.safetensors", "https://resources.modelscope.cn/cover-images/a.png"
|
||||||
|
)
|
||||||
|
assert applied["preview_url"] == "/p.webp"
|
||||||
|
assert result["preview_downloaded"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_example_images_work_without_any_readme(self, processor):
|
||||||
|
"""The site images alone are enough — the README may be unreachable."""
|
||||||
|
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=dict(self.MODELSCOPE_METADATA),
|
||||||
|
readme_content="",
|
||||||
|
source_context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
images = mock_apply.call_args[0][1]["civitai"]["images"]
|
||||||
|
assert [img["url"] for img in images] == context.example_images
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_site_description_precedes_readme_in_model_description(self, processor):
|
||||||
|
context = ModelCardContext(description="权重0.5-1.2。配合滤镜lora一起使用。")
|
||||||
|
readme = "# 模型介绍\n\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=dict(self.MODELSCOPE_METADATA),
|
||||||
|
readme_content=readme,
|
||||||
|
source_context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
description = mock_apply.call_args[0][1]["modelDescription"]
|
||||||
|
assert description.startswith(f"<p>{context.description}</p>")
|
||||||
|
assert "<h1>模型介绍</h1>" in description
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_site_description_is_html_escaped(self, processor):
|
||||||
|
context = ModelCardContext(description="a < b & c")
|
||||||
|
|
||||||
|
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="",
|
||||||
|
source_context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mock_apply.call_args[0][1]["modelDescription"] == "<p>a < b & c</p>"
|
||||||
|
|
||||||
|
@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
|
||||||
|
|||||||
Reference in New Issue
Block a user