diff --git a/py/services/agent/agent_service.py b/py/services/agent/agent_service.py
index 3126849b..e834cb44 100644
--- a/py/services/agent/agent_service.py
+++ b/py/services/agent/agent_service.py
@@ -24,11 +24,16 @@ from typing import Any, Dict, List, Optional
import aiohttp
+import os
+
from ..llm_service import LLMService
from ..websocket_manager import ws_manager
from .post_processor import PostProcessor
from .skill_registry import SkillRegistry
-from .skills.enrich_hf_metadata.md_to_html import clean_readme_for_llm
+from .skills.enrich_hf_metadata.md_to_html import (
+ clean_readme_for_llm,
+ extract_relevant_section,
+)
logger = logging.getLogger(__name__)
@@ -344,6 +349,7 @@ class AgentService:
context: Dict[str, Any] = {
"model_path": model_path,
+ "model_basename": "",
"hf_url": "",
"repo": "",
"readme_content": "",
@@ -353,6 +359,11 @@ class AgentService:
"priority_tags": "",
}
+ # Extract model basename (filename without extension) for the LLM
+ # to use when locating the matching section in collection repos.
+ raw_basename = os.path.splitext(os.path.basename(model_path))[0]
+ context["model_basename"] = raw_basename or ""
+
context["current_metadata"] = {
"file_name": metadata.get("file_name", ""),
"base_model": metadata.get("base_model", ""),
@@ -369,7 +380,13 @@ class AgentService:
context["repo"] = repo or ""
if repo:
readme = await self._fetch_readme(repo)
- cleaned = clean_readme_for_llm(readme) if readme else ""
+ # Trim README to the section relevant to this model file
+ # (collection repos often have multiple models in one README).
+ if readme and raw_basename:
+ trimmed = extract_relevant_section(readme, raw_basename)
+ cleaned = clean_readme_for_llm(trimmed) if trimmed else ""
+ else:
+ cleaned = clean_readme_for_llm(readme) if readme else ""
context["readme_content"] = cleaned if cleaned else "(README not available)"
context["readme_content_full"] = readme or ""
diff --git a/py/services/agent/post_processor.py b/py/services/agent/post_processor.py
index 83d5e51f..c60f620a 100644
--- a/py/services/agent/post_processor.py
+++ b/py/services/agent/post_processor.py
@@ -13,6 +13,7 @@ from __future__ import annotations
import json
import logging
import os
+import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
@@ -81,6 +82,8 @@ class PostProcessor:
convert_readme_to_html,
extract_gallery_images,
extract_gallery_table_images,
+ extract_simple_markdown_images,
+ extract_html_img_tags,
extract_repo_from_hf_url,
)
@@ -101,9 +104,11 @@ class PostProcessor:
# trigger words → civitai.trainedWords
new_triggers = llm_output.get("trigger_words", [])
+ trigger_words_empty = True
if isinstance(new_triggers, list):
cleaned = [t.strip() for t in new_triggers if t.strip()]
cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")]
+ trigger_words_empty = not cleaned
current_civitai = metadata.get("civitai") or {}
current_triggers = current_civitai.get("trainedWords") or []
if self._should_overwrite_list(current_triggers, is_hf_model):
@@ -152,8 +157,24 @@ class PostProcessor:
existing_urls=existing_urls,
default_width=rec_w, default_height=rec_h,
)
+ existing_urls.update(img["url"] for img in table_images if img.get("url"))
- all_images = gallery + table_images
+ # 3. Simple markdown images `` in the body
+ simple_images = extract_simple_markdown_images(
+ readme_content, repo,
+ existing_urls=existing_urls,
+ default_width=rec_w, default_height=rec_h,
+ )
+ existing_urls.update(img["url"] for img in simple_images if img.get("url"))
+
+ # 4. HTML `
` tags (used by many collection repos)
+ html_images = extract_html_img_tags(
+ readme_content, repo,
+ existing_urls=existing_urls,
+ default_width=rec_w, default_height=rec_h,
+ )
+
+ all_images = gallery + table_images + simple_images + html_images
if all_images:
gallery_images = all_images
current_civitai = metadata.get("civitai") or {}
@@ -175,6 +196,23 @@ class PostProcessor:
updates["metadata_source"] = "agent:enrich_hf_metadata"
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
+ # Store LLM confidence in metadata so it's accessible for evaluation
+ raw_confidence = (llm_output.get("confidence") or "").strip()
+ 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.
+ if trigger_words_empty:
+ instance_prompt = _extract_yaml_instance_prompt(readme_content)
+ if instance_prompt:
+ 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]
+ updates["civitai"] = trig_civitai
+
preview_remote_url = (llm_output.get("preview_url") or "").strip()
# Fallback: if the LLM couldn't find a preview image in the cleaned
# README, use the first gallery image extracted from the YAML widget
@@ -249,3 +287,35 @@ class PostProcessor:
merged.append(t)
seen.add(t)
return merged
+
+
+# ------------------------------------------------------------------
+# Module-level helpers
+# ------------------------------------------------------------------
+
+
+def _extract_yaml_instance_prompt(readme_content: str) -> str:
+ """Extract ``instance_prompt`` from the YAML frontmatter of a HF README.
+
+ Returns the prompt text, or empty string if not found. Handles
+ ``null`` / ``~`` YAML null values by returning empty string.
+ """
+ if not readme_content or not readme_content.startswith("---"):
+ return ""
+
+ # Find end of frontmatter
+ end = readme_content.find("---", 3)
+ if end == -1:
+ return ""
+ frontmatter = readme_content[3:end]
+
+ for line in frontmatter.split("\n"):
+ line = line.strip()
+ m = re.match(r"^instance_prompt:\s*(.*)", line)
+ if m:
+ val = m.group(1).strip().strip('"').strip("'")
+ if val.lower() in ("null", "~", "none", ""):
+ return ""
+ return val
+
+ return ""
diff --git a/py/services/agent/skills/enrich_hf_metadata/SKILL.md b/py/services/agent/skills/enrich_hf_metadata/SKILL.md
index e1d3b8a0..79bfb8a8 100644
--- a/py/services/agent/skills/enrich_hf_metadata/SKILL.md
+++ b/py/services/agent/skills/enrich_hf_metadata/SKILL.md
@@ -13,6 +13,7 @@ You are an expert assistant for AI image generation models. Your task is to extr
- **Repository**: {{hf_url}}
- **Model file path**: {{model_path}}
+- **Model filename**: {{model_basename}}
- **Repository ID**: {{repo}}
## Current Metadata (may be incomplete)
@@ -36,6 +37,31 @@ These are the subjects, styles, and concepts the user considers useful for categ
The following base models are currently valid in this system:
{{base_models}}
+## HuggingFace → CivitAI Base Model Mapping
+
+HuggingFace repos often declare `base_model:` in YAML frontmatter using HuggingFace
+model names. Map them to CivitAI names using this reference:
+
+| HuggingFace repo / model name | CivitAI base model |
+|-------------------------------------------------------|--------------------|
+| `runwayml/stable-diffusion-v1-5` | SD 1.5 |
+| `CompVis/stable-diffusion-v1-4` | SD 1.5 |
+| `stabilityai/stable-diffusion-2-1` / `-2-1-base` | SD 2.1 |
+| `stabilityai/stable-diffusion-xl-base-1.0` | SDXL 1.0 |
+| `stabilityai/stable-diffusion-3-5-large` | SD 3.5 Large |
+| `black-forest-labs/FLUX.1-dev` | Flux.1 D |
+| `black-forest-labs/FLUX.1-schnell` | Flux.1 S |
+| `black-forest-labs/FLUX.1-krea` | Flux.1 Krea |
+| `krea/Krea-2-Raw` or `krea/Krea-2-Turbo` | Krea 2 |
+| `Comfy-Org/z_image_turbo` | ZImageTurbo |
+| `alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union-2.1` | ZImageTurbo |
+| `CogVideo` | CogVideoX |
+| `Wan-AI/Wan2.1-T2V-14B` | Wan Video 2.2 T2V-A14B |
+| `stabilityai/stable-video-diffusion-img2vid` | SVD |
+
+The model file name itself may also hint at the base model (e.g. "flux", "sdxl",
+"sd15", "krea2" in the filename).
+
## HuggingFace README Content
```
@@ -49,24 +75,26 @@ 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 (between --- markers) for `base_model:` first, then look at the description text and safetensors metadata. If you cannot determine it, return an empty string.
+Check the YAML frontmatter (between --- markers) for `base_model:` first, then look at the description text and safetensors metadata. If the YAML frontmatter uses a HuggingFace model name (e.g. `runwayml/stable-diffusion-v1-5`), use the mapping table above to find the correct CivitAI name. If you cannot determine it, return an empty string.
### trigger_words
The trigger words or activation prompts needed to use this LoRA. Look for:
- `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)
- Example prompts at the start (usually the first word or phrase before any description)
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. Return empty string if the README is too minimal.
+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.
### tags
3-8 relevant tags for categorizing this model. **Quality over quantity.**
Sources to consider:
-- The YAML frontmatter `tags:` list
+- 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")
**Critical filtering rules — apply them strictly:**
@@ -82,10 +110,15 @@ Return empty array if no meaningful content tags remain after filtering.
The recommended image generation resolution for this model, in pixels. Look for sections like "Best Dimensions", "Recommended size", "Suggested resolution", or similar phrasing in the README. Prefer the explicitly marked "Best" or default resolution. If the table/list has multiple entries (e.g. "768 x 1024 (Best)" and "1024 x 1024 (Default)"), use the one marked "Best". Return integers. If no resolution can be determined, return 0 for both.
### preview_url
-The URL of the most suitable preview image from the README. Look for image tags (e.g. ``) and the YAML frontmatter `widget:` section (which often has `output.url` fields). Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL as `https://huggingface.co/{{repo}}/resolve/main/{filename}`. If no suitable image is found, return an empty string.
+The URL of the most suitable preview image from the README. Look for:
+- Image tags near the section matching the model filename (`{{model_basename}}`)
+- 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 `` in the body
+Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL as `https://huggingface.co/{{repo}}/resolve/main/{filename}`. If no suitable image is found, 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. 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}}`. Return empty string if the README has 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):
@@ -109,6 +142,25 @@ Your confidence level in the extracted data:
- "medium" — some fields were inferred from context
- "low" — most fields are guesses based on limited information
+## Important: Handling Collection Repos (multiple model files)
+
+Many HuggingFace repos contain **multiple model files** in a single repository
+(e.g. a "LoRA collection" with different styles/characters in separate files).
+
+The model file currently being enriched is: **`{{model_basename}}`**
+
+To find the correct section in the README:
+
+1. **Search for download links** containing the filename — the surrounding paragraph is your section.
+2. **Search for anchor IDs** (``) or section headings whose text matches words from the filename.
+3. **Search for HTML headings** (`