mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -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
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user