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.
@@ -25,6 +25,34 @@ You are an expert assistant for AI image generation models. Your task is to extr
{{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
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
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
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
- 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)
@@ -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.
### 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
3-8 relevant tags for categorizing this model. **Quality over quantity.**
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 subject, style, character, or concept the model represents
- 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.
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.
@@ -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)
- In collection repos: the sample images listed **under the section** for this specific model version
- Generic `![alt](url)` 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
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
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
{