feat(agent): render HF README as HTML in modelDescription, move converter to skill-local module

- Add inline convert_readme_to_html() in new skill-local md_to_html.py
  (zero external deps, handles h1-h4/bold/italic/code/lists/tables/links/hr)
- Strip YAML frontmatter, <Gallery />, badge images, HTML comments pre-conversion
- Fix indented whitespace after lists being misidentified as code blocks
- Fix HTML double-escaping in _inline_md (each pattern escapes independently)
- LLM short_description → civitai.description ("About this version" sidebar)
- raw README HTML → modelDescription (description tab, always available offline)
- Pass full readme_content from agent_service to post_processor
- 51 tests for converter + 4 updated/added post-processor tests
This commit is contained in:
Will Miao
2026-07-02 23:34:52 +08:00
parent a8adcaf023
commit 88349bf944
6 changed files with 548 additions and 16 deletions

View File

@@ -284,6 +284,7 @@ class AgentService:
model_path=model_path,
llm_output=llm_response or {},
metadata=metadata,
readme_content=prompt_vars.get("readme_content_full", ""),
)
if model_result.get("success", True):
@@ -345,6 +346,7 @@ class AgentService:
"hf_url": "",
"repo": "",
"readme_content": "",
"readme_content_full": "",
"current_metadata": {},
"base_models": [],
"priority_tags": "",
@@ -367,6 +369,7 @@ class AgentService:
if repo:
readme = await self._fetch_readme(repo)
context["readme_content"] = readme[:8000] if readme else "(README not available)"
context["readme_content_full"] = readme or ""
try:
context["base_models"] = await list_base_models()

View File

@@ -39,15 +39,20 @@ class PostProcessor:
model_path: str,
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
readme_content: str = "",
) -> Dict[str, Any]:
"""Route *llm_output* to the correct skill post-processor.
*readme_content* is optional raw markdown content (e.g. HF README)
that is converted to HTML and stored as ``modelDescription`` for
the description tab.
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,
model_path, llm_output, metadata, readme_content,
)
return {
"success": False,
@@ -64,12 +69,14 @@ class PostProcessor:
model_path: str,
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
readme_content: str = "",
) -> Dict[str, Any]:
from ...agent_cli import (
apply_metadata_updates,
download_preview,
refresh_cache,
)
from .skills.enrich_hf_metadata.md_to_html import convert_readme_to_html
updated_fields: List[str] = []
preview_downloaded = False
@@ -97,12 +104,21 @@ class PostProcessor:
civitai_updates["trainedWords"] = cleaned
updates["civitai"] = civitai_updates
# modelDescription
new_desc = (llm_output.get("description") or "").strip()
if new_desc:
current_desc = metadata.get("modelDescription", "") or ""
if self._should_overwrite(current_desc, is_hf_model):
updates["modelDescription"] = new_desc
# modelDescription — from raw README content (converted to HTML)
if readme_content and is_hf_model:
converted = convert_readme_to_html(readme_content)
if converted:
updates["modelDescription"] = converted
# short_description → civitai.description (for "About this version")
short_desc = (llm_output.get("short_description") or "").strip()
if short_desc and is_hf_model:
current_civitai = metadata.get("civitai") or {}
civitai_updates = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
civitai_updates.update(updates["civitai"])
civitai_updates["description"] = short_desc
updates["civitai"] = civitai_updates
# tags
new_tags = llm_output.get("tags", [])

View File

@@ -58,7 +58,7 @@ The trigger words or activation prompts needed to use this LoRA. Look for:
- 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.
### description
### 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.
### tags
@@ -96,7 +96,7 @@ Return ONLY a JSON object with exactly these fields (no markdown fences, no extr
"model_path": "{{model_path}}",
"base_model": "<canonical name or empty string>",
"trigger_words": ["<word1>", "<word2>"],
"description": "<1-2 sentence summary>",
"short_description": "<1-2 sentence summary>",
"tags": ["<tag1>", "<tag2>"],
"preview_url": "<image URL or empty string>",
"confidence": "<high|medium|low>"

View File

@@ -0,0 +1,300 @@
"""Inline markdown-to-HTML converter for HF README content.
No external dependencies. Strips YAML frontmatter, ``<Gallery />`` sections,
badge images, and HTML comments before rendering. Only used by the
``enrich_hf_metadata`` skill.
"""
from __future__ import annotations
import html as html_module
import re
from typing import List, Tuple
def convert_readme_to_html(markdown_text: str | None) -> str:
"""Convert HF README markdown to sanitised HTML."""
if not markdown_text:
return ""
text = markdown_text
text = _strip_frontmatter(text)
text = _strip_gallery(text)
text = _strip_badge_images(text)
text = _strip_html_comments(text)
html = _md_to_html(text)
return html.strip()
# ---------------------------------------------------------------------------
# Pre-processing: strip unwanted sections
# ---------------------------------------------------------------------------
def _strip_frontmatter(text: str) -> str:
if text.startswith("---"):
idx = text.find("---", 3)
if idx != -1:
return text[idx + 3 :]
return text
def _strip_gallery(text: str) -> str:
text = re.sub(
r"<Gallery\b[^>]*/>|<Gallery\b[^>]*>.*?</Gallery>",
"",
text,
flags=re.IGNORECASE | re.DOTALL,
)
text = re.sub(
r'<div\s+[^>]*flex[^>]*>.*?(?:<Gallery\b|</?\w+\s+[^>]*gallery).*?</div>',
"",
text,
flags=re.IGNORECASE | re.DOTALL,
)
return text
def _strip_badge_images(text: str) -> str:
badge_keywords = (
"badge", "shield", "logo", "icon", "download", "license",
"python", "version", "status", "build", "test", "coverage",
"docker", "pypi", "npm", "github", "hugging face", "discord",
"twitter", "colab", "gradio", "space",
)
def _should_remove(m: re.Match) -> str:
alt = (m.group(1) or "").lower()
for kw in badge_keywords:
if kw in alt:
return ""
return m.group(0)
text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", _should_remove, text)
return text
def _strip_html_comments(text: str) -> str:
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
# ---------------------------------------------------------------------------
# Markdown → HTML rendering
# ---------------------------------------------------------------------------
def _md_to_html(text: str) -> str:
"""Convert a limited markdown subset to HTML.
Handles: h1-h4, paragraphs, bold, italic, inline code, fenced code
blocks, unordered/ordered lists, links, images (→ alt text), hr,
blockquotes, and tables.
"""
lines = text.split("\n")
out: list[str] = []
i = 0
n = len(lines)
while i < n:
line = lines[i]
# Fenced code block
if line.startswith("```"):
lang = line[3:].strip()
code_lines: list[str] = []
i += 1
while i < n and not lines[i].startswith("```"):
code_lines.append(lines[i])
i += 1
i += 1
code_escaped = html_module.escape("\n".join(code_lines))
lang_attr = f' class="language-{html_module.escape(lang)}"' if lang else ""
out.append(f"<pre><code{lang_attr}>{code_escaped}\n</code></pre>")
continue
# Horizontal rule
if re.match(r"^-{3,}\s*$", line) or re.match(r"^\*{3,}\s*$", line):
out.append("<hr>")
i += 1
continue
# ATX headers
h_match = re.match(r"^(#{1,4})\s+(.+?)\s*#*$", line)
if h_match:
level = len(h_match.group(1))
content = _inline_md(h_match.group(2))
out.append(f"<h{level}>{content}</h{level}>")
i += 1
continue
# Blockquote
if line.startswith("> "):
bq_lines: list[str] = []
while i < n and lines[i].startswith("> "):
bq_lines.append(lines[i][2:])
i += 1
bq_html = _md_to_html("\n".join(bq_lines))
out.append(f"<blockquote>{bq_html}</blockquote>")
continue
# Unordered list
if re.match(r"^[-*+]\s", line):
items, i = _parse_list(lines, i, r"^[-*+]\s")
list_html = "".join(f"<li>{_inline_md(item)}</li>" for item in items)
out.append(f"<ul>{list_html}</ul>")
continue
# Ordered list
if re.match(r"^\d+\.\s", line):
items, i = _parse_list(lines, i, r"^\d+\.\s")
list_html = "".join(f"<li>{_inline_md(item)}</li>" for item in items)
out.append(f"<ol>{list_html}</ol>")
continue
# Table
if "|" in line and i + 1 < n and re.match(r"^\|[\s:-]+\|", lines[i + 1]):
table_html, i = _parse_table(lines, i)
out.append(table_html)
continue
# Indented code block (4 spaces) — only when content is non-whitespace
if (line.startswith(" ") or line.startswith("\t")) and line.strip():
code_lines, i = _collect_indented_code(lines, i)
if code_lines:
code_escaped = html_module.escape("\n".join(code_lines))
out.append(f"<pre><code>{code_escaped}\n</code></pre>")
continue
# Whitespace-only indented line — treat as paragraph separator
if (line.startswith(" ") or line.startswith("\t")) and not line.strip():
i += 1
continue
# Empty line
if not line.strip():
i += 1
continue
# Paragraph (collect consecutive non-empty lines)
para_lines: list[str] = []
while i < n:
l = lines[i]
if not l.strip():
break
if re.match(r"^(#{1,4}\s|```|---|\*{3,}|\d+\.\s|[-*+]\s|>\s|\|)", l):
break
para_lines.append(l)
i += 1
para = " ".join(p.strip() for p in para_lines if p.strip())
if para:
out.append(f"<p>{_inline_md(para)}</p>")
continue
return "\n".join(out)
def _collect_indented_code(lines: list[str], start: int) -> Tuple[List[str], int]:
"""Collect lines of an indented code block starting at *start*.
Returns (code_lines, next_index). Whitespace-only lines within the
block are preserved; a block whose *only* content is whitespace
returns an empty list.
"""
code_lines: list[str] = []
# Include the first line
if lines[start].strip():
code_lines.append(lines[start])
i = start + 1
n = len(lines)
while i < n and (lines[i].startswith(" ") or lines[i].startswith("\t") or lines[i] == ""):
if lines[i].strip() or code_lines:
code_lines.append(lines[i])
i += 1
# If the block never had real content, discard it
if not any(ln.strip() for ln in code_lines):
return [], start + 1
return code_lines, i
def _inline_md(text: str) -> str:
"""Convert inline markdown within a paragraph/heading.
Each pattern independently HTML-escapes its captured content, so no
global pre-escape is needed. This avoids double-escaping issues
(e.g. `` `a < b` `` → ``<code>a &lt; b</code>``, not ``&amp;lt;``).
"""
# Image: ![alt](url) → alt text
text = re.sub(
r"!\[([^\]]*)\]\([^)]+\)",
lambda m: html_module.escape(m.group(1) or ""),
text,
)
# Link: [text](url) → <a href="url">text</a>
text = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
lambda m: f'<a href="{html_module.escape(m.group(2))}">{html_module.escape(m.group(1))}</a>',
text,
)
text = re.sub(
r"\*\*(.+?)\*\*",
lambda m: f"<strong>{html_module.escape(m.group(1))}</strong>",
text,
)
text = re.sub(
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)",
lambda m: f"<em>{html_module.escape(m.group(1))}</em>",
text,
)
text = re.sub(
r"`([^`]+)`",
lambda m: f"<code>{html_module.escape(m.group(1))}</code>",
text,
)
text = re.sub(
r"~~(.+?)~~",
lambda m: f"<s>{html_module.escape(m.group(1))}</s>",
text,
)
return text
def _parse_list(lines: list[str], start: int, pattern: str) -> Tuple[List[str], int]:
"""Collect list items starting at *start* matching *pattern*."""
items: list[str] = []
i = start
while i < len(lines):
m = re.match(pattern, lines[i])
if m:
item = lines[i][m.end():].strip()
i += 1
while i < len(lines) and lines[i].strip() and not re.match(
r"^(#{1,4}\s|```|\d+\.\s|[-*+]\s)", lines[i]
):
item += " " + lines[i].strip()
i += 1
items.append(item)
else:
break
return items, i
def _parse_table(lines: list[str], start: int) -> Tuple[str, int]:
"""Parse a markdown table starting at *start*."""
header_line = lines[start]
i = start + 2 # skip separator line
headers = [c.strip() for c in header_line.strip().strip("|").split("|")]
rows: list[str] = []
while i < len(lines) and "|" in lines[i]:
cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
cells_html = "".join(f"<td>{_inline_md(c)}</td>" for c in cells)
rows.append(f"<tr>{cells_html}</tr>")
i += 1
headers_html = "".join(f"<th>{_inline_md(h)}</th>" for h in headers)
table = f"<table><thead><tr>{headers_html}</tr></thead><tbody>"
table += "".join(rows)
table += "</tbody></table>"
return table, i