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:
Will Miao
2026-09-14 20:38:56 +08:00
parent e711e643f1
commit 35b291ab19
7 changed files with 1269 additions and 47 deletions
+233 -39
View File
@@ -10,12 +10,16 @@ refresh cache). All actual I/O is delegated to :mod:`~py.metadata_ops`.
from __future__ import annotations
import html
import json
import logging
import os
import re
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__)
@@ -42,6 +46,8 @@ class PostProcessor:
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
readme_content: str = "",
source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "",
) -> Dict[str, Any]:
"""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
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),
``preview_downloaded`` (bool), and ``errors`` (list).
"""
if skill_name == "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 {
"success": False,
@@ -72,6 +87,8 @@ class PostProcessor:
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
readme_content: str = "",
source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "",
) -> Dict[str, Any]:
from ...metadata_ops import (
apply_metadata_updates,
@@ -109,8 +126,11 @@ class PostProcessor:
# -- Collect updates -----------------------------------------------
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()
if not new_base:
new_base = (resolved_base_model or "").strip()
current_base = metadata.get("base_model", "") or ""
if new_base and self._should_overwrite(current_base, is_source_model):
updates["base_model"] = new_base
@@ -131,14 +151,29 @@ class PostProcessor:
trig_civitai["trainedWords"] = cleaned
updates["civitai"] = trig_civitai
# modelDescription — from raw README content (converted to HTML)
if readme_content and is_source_model:
converted = convert_readme_to_html(readme_content)
if converted:
updates["modelDescription"] = converted
# modelDescription — the author's own summary (when the site keeps one
# 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)
if 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()
if not short_desc:
short_desc = site_description
if short_desc and is_source_model:
current_civitai = metadata.get("civitai") or {}
desc_civitai = dict(current_civitai)
@@ -147,19 +182,31 @@ class PostProcessor:
desc_civitai["description"] = short_desc
updates["civitai"] = desc_civitai
# gallery images → civitai.images (from YAML frontmatter widget entries
# and Sample Gallery markdown tables in the README body)
gallery_images: List[Dict[str, Any]] = []
if readme_content and is_source_model:
repo = source_id
if repo:
rec_w = llm_output.get("recommended_width") or 0
rec_h = llm_output.get("recommended_height") or 0
# gallery images → civitai.images (site example images, YAML frontmatter
# widget entries, and Sample Gallery markdown tables in the README body)
rec_width = llm_output.get("recommended_width") or 0
rec_height = 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)
gallery = extract_gallery_images(
readme_content, repo,
default_width=rec_w, default_height=rec_h,
default_width=rec_width, default_height=rec_height,
base_url=asset_base_url,
)
@@ -168,7 +215,7 @@ class PostProcessor:
table_images = extract_gallery_table_images(
readme_content, repo,
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,
)
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(
readme_content, repo,
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,
)
existing_urls.update(img["url"] for img in simple_images if img.get("url"))
@@ -186,25 +233,39 @@ class PostProcessor:
html_images = extract_html_img_tags(
readme_content, repo,
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,
)
all_images = gallery + table_images + simple_images + html_images
if all_images:
gallery_images = all_images
current_civitai = metadata.get("civitai") or {}
gallery_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
gallery_civitai.update(updates["civitai"])
gallery_civitai["images"] = all_images
updates["civitai"] = gallery_civitai
readme_images = gallery + table_images + simple_images + html_images
# tags
# 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:
gallery_images = all_images
current_civitai = metadata.get("civitai") or {}
gallery_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
gallery_civitai.update(updates["civitai"])
gallery_civitai["images"] = all_images
updates["civitai"] = gallery_civitai
# 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", [])
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 []
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:
updates["tags"] = merged
@@ -217,16 +278,22 @@ class PostProcessor:
if raw_confidence:
updates["_llm_confidence"] = raw_confidence
# Fallback: extract instance_prompt from YAML frontmatter when the LLM
# returned empty trigger words but the README has instance_prompt.
# Fallback: use the trigger words the site records for this exact file,
# then the README's YAML `instance_prompt`, when the LLM returned none.
if trigger_words_empty:
instance_prompt = _extract_yaml_instance_prompt(readme_content)
if instance_prompt:
site_triggers = (
list(source_context.trigger_words) if source_context else []
)
if not site_triggers:
instance_prompt = _extract_yaml_instance_prompt(readme_content)
if instance_prompt:
site_triggers = [instance_prompt]
if site_triggers:
current_civitai = metadata.get("civitai") or {}
trig_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = [instance_prompt]
trig_civitai["trainedWords"] = site_triggers
updates["civitai"] = trig_civitai
preview_remote_url = (llm_output.get("preview_url") or "").strip()
@@ -260,8 +327,12 @@ class PostProcessor:
if 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()
if not raw_tips or raw_tips == "{}":
raw_tips = _extract_usage_tips(site_description)
if raw_tips and raw_tips != "{}":
try:
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:
"""Extract ``instance_prompt`` from the YAML frontmatter of a HF README.