feat(recipes): explain empty LoRA lists with collapsible "Why no LoRAs?" panel

Record import provenance on every recipe: a new import_info block
(channel, machine-readable no-LoRA reason, diagnostic details) built at
import time across all channels (batch import, single URL, local file,
upload, widget save, re-imports) and persisted in the recipe JSON plus
the SQLite persistent cache (new import_info_json column with ALTER
TABLE migration).

The recipe modal renders the empty LoRA list with a collapsed details
panel showing the import method, the reason (CivitAI API returned no
LoRA resource data, API meta missing, no embedded metadata, ComfyUI
workflow metadata, video, unparsable format), and recorded diagnostics.
Legacy recipes without import_info fall back to heuristics labeled as
inferred. Genuine no-LoRA generations show no panel.

CivitAI images are always classified by API meta shape: the onsite
generator writes A1111-style EXIF without LoRA references, so parsed
EXIF cannot prove "no LoRAs used".

Adds recipes.resources.noLoras* i18n keys (all 10 locales) plus
frontend vitest and backend pytest coverage.
This commit is contained in:
Will Miao
2026-08-30 16:28:41 +08:00
parent 3fd29f6943
commit bccd494a56
23 changed files with 1342 additions and 10 deletions
+16
View File
@@ -20,6 +20,11 @@ from .recipes import (
RecipeDownloadError,
RecipeNotFoundError,
)
from .recipes.import_info import (
CHANNEL_BATCH_IMPORT_LOCAL,
CHANNEL_BATCH_IMPORT_URL,
build_import_info,
)
class ImportItemType(Enum):
@@ -624,6 +629,17 @@ class BatchImportService:
"loras": loras,
"gen_params": payload.get("gen_params", {}),
"source_path": item.source,
# Record why this import ended up with no LoRAs so the
# recipe modal can explain it (collapsed by default).
"import_info": build_import_info(
(
CHANNEL_BATCH_IMPORT_URL
if item.item_type == ImportItemType.URL
else CHANNEL_BATCH_IMPORT_LOCAL
),
payload.get("diagnostics"),
loras,
),
}
if payload.get("checkpoint"):
+24 -1
View File
@@ -59,6 +59,7 @@ class PersistentRecipeCache:
"gen_params_json",
"tags_json",
"has_workflow",
"import_info_json",
)
_instances: Dict[str, "PersistentRecipeCache"] = {}
_instance_lock = threading.Lock()
@@ -447,7 +448,8 @@ class PersistentRecipeCache:
checkpoint_json TEXT,
gen_params_json TEXT,
tags_json TEXT,
has_workflow INTEGER DEFAULT 0
has_workflow INTEGER DEFAULT 0,
import_info_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path);
@@ -473,6 +475,13 @@ class PersistentRecipeCache:
)
except Exception:
pass # column already exists
# Migration: add import_info_json column to existing databases
try:
conn.execute(
"ALTER TABLE recipes ADD COLUMN import_info_json TEXT"
)
except Exception:
pass # column already exists
conn.commit()
self._schema_initialized = True
except Exception as exc:
@@ -504,6 +513,9 @@ class PersistentRecipeCache:
tags = recipe.get("tags")
tags_json = json.dumps(tags) if tags else None
import_info = recipe.get("import_info")
import_info_json = json.dumps(import_info) if import_info else None
# Get file stats if json_path exists
file_mtime = 0.0
file_size = 0
@@ -536,6 +548,7 @@ class PersistentRecipeCache:
gen_params_json,
tags_json,
1 if recipe.get("has_workflow") else 0,
import_info_json,
)
def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]:
@@ -568,6 +581,13 @@ class PersistentRecipeCache:
except json.JSONDecodeError:
pass
import_info = None
if row["import_info_json"]:
try:
import_info = json.loads(row["import_info_json"])
except json.JSONDecodeError:
pass
recipe = {
"id": row["recipe_id"],
"file_path": row["file_path"] or "",
@@ -592,6 +612,9 @@ class PersistentRecipeCache:
if checkpoint:
recipe["checkpoint"] = checkpoint
if import_info:
recipe["import_info"] = import_info
return recipe
+3
View File
@@ -1,6 +1,7 @@
"""Recipe service layer implementations."""
from .analysis_service import RecipeAnalysisService
from .import_info import build_import_info, compute_no_loras_reason
from .persistence_service import RecipePersistenceService
from .sharing_service import RecipeSharingService
from .errors import (
@@ -15,6 +16,8 @@ __all__ = [
"RecipeAnalysisService",
"RecipePersistenceService",
"RecipeSharingService",
"build_import_info",
"compute_no_loras_reason",
"RecipeServiceError",
"RecipeValidationError",
"RecipeNotFoundError",
+53 -4
View File
@@ -72,15 +72,28 @@ class RecipeAnalysisService:
metadata = self._exif_utils.extract_image_metadata(temp_path)
if not metadata:
return AnalysisResult(
{"error": "No metadata found in this image", "loras": []}
{
"error": "No metadata found in this image",
"loras": [],
"diagnostics": {
"channel": "upload",
"exif_present": False,
},
}
)
return await self._parse_metadata(
result = await self._parse_metadata(
metadata,
recipe_scanner=recipe_scanner,
image_path=None,
include_image_base64=False,
)
result.payload["diagnostics"] = {
"channel": "upload",
"exif_present": True,
"exif_parser": result.payload.get("parser"),
}
return result
finally:
self._safe_cleanup(temp_path)
@@ -104,9 +117,13 @@ class RecipeAnalysisService:
image_info: Optional[dict[str, Any]] = None
is_video = False
extension = ".jpg" # Default
# Diagnostics collected during analysis; surfaced in the payload so
# callers can persist an import_info block explaining empty LoRA lists.
diagnostics: dict[str, Any] = {"channel": "url"}
try:
civitai_image_id = extract_civitai_image_id(url)
diagnostics["civitai_image"] = bool(civitai_image_id)
if civitai_image_id:
image_info = await civitai_client.get_image_info(
civitai_image_id, source_url=url
@@ -147,11 +164,23 @@ class RecipeAnalysisService:
):
metadata = metadata["meta"]
# Diagnostics: capture the API meta shape before injecting
# modelVersionIds / browsingLevel so the recipe modal can
# explain why an import ended up without LoRAs.
diagnostics["api_meta_present"] = isinstance(metadata, dict)
if isinstance(metadata, dict):
diagnostics["api_meta_keys"] = sorted(metadata.keys())
# Include modelVersionIds from root level if available.
# CivitAI API returns modelVersionIds at root level, not in meta.
# When meta is null (None), create a minimal dict so downstream
# parsers can still discover LoRAs and checkpoints.
model_version_ids = image_info.get("modelVersionIds")
diagnostics["api_model_version_ids"] = (
len(model_version_ids)
if isinstance(model_version_ids, list)
else 0
)
if model_version_ids:
if isinstance(metadata, dict):
metadata["modelVersionIds"] = model_version_ids
@@ -229,6 +258,8 @@ class RecipeAnalysisService:
finally:
self._safe_cleanup(orig_temp_path)
diagnostics["exif_present"] = bool(exif_metadata)
# Parse EXIF data (typically a string like parameters/prompt/workflow)
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
@@ -237,6 +268,7 @@ class RecipeAnalysisService:
if isinstance(exif_metadata, str):
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
if exif_parser:
diagnostics["exif_parser"] = exif_parser.__class__.__name__
exif_data = await exif_parser.parse_metadata(
exif_metadata, recipe_scanner=recipe_scanner,
)
@@ -324,6 +356,8 @@ class RecipeAnalysisService:
if isinstance(bl, int) and bl > 0:
result.payload["preview_nsfw_level"] = bl
diagnostics["is_video"] = is_video
result.payload["diagnostics"] = diagnostics
return result
finally:
if temp_path:
@@ -348,14 +382,25 @@ class RecipeAnalysisService:
self._exif_utils.extract_image_metadata, normalized_path
)
if not metadata:
return self._metadata_not_found_response(normalized_path)
result = self._metadata_not_found_response(normalized_path)
result.payload["diagnostics"] = {
"channel": "local",
"exif_present": False,
}
return result
return await self._parse_metadata(
result = await self._parse_metadata(
metadata,
recipe_scanner=recipe_scanner,
image_path=normalized_path,
include_image_base64=True,
)
result.payload["diagnostics"] = {
"channel": "local",
"exif_present": True,
"exif_parser": result.payload.get("parser"),
}
return result
async def analyze_widget_metadata(self, *, recipe_scanner) -> AnalysisResult:
"""Analyse the most recent generation metadata for widget saves."""
@@ -452,6 +497,10 @@ class RecipeAnalysisService:
metadata, recipe_scanner=recipe_scanner
)
# Record which parser handled the metadata so import diagnostics
# can distinguish e.g. ComfyUI workflow sources.
result["parser"] = parser.__class__.__name__
if include_image_base64 and image_path:
result["image_base64"] = self._encode_file(image_path)
+129
View File
@@ -0,0 +1,129 @@
"""Import provenance helpers for recipes.
Builds the ``import_info`` block persisted on a recipe: the import channel
(batch import / single URL / local file / upload / widget) and, when the
recipe ended up with no LoRAs, a machine-readable reason plus the diagnostic
details that led to it. The recipe modal renders this block in a collapsed
"Why no LoRAs?" panel; legacy recipes without ``import_info`` fall back to a
frontend heuristic.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
# Import channels (how the recipe entered the library).
CHANNEL_BATCH_IMPORT_URL = "batch_import_url"
CHANNEL_BATCH_IMPORT_LOCAL = "batch_import_local"
CHANNEL_URL = "url"
CHANNEL_LOCAL = "local"
CHANNEL_UPLOAD = "upload"
CHANNEL_WIDGET = "widget"
CHANNEL_REIMPORT_URL = "reimport_url"
CHANNEL_REIMPORT_LOCAL = "reimport_local"
_URL_CHANNELS = frozenset(
{CHANNEL_BATCH_IMPORT_URL, CHANNEL_URL, CHANNEL_REIMPORT_URL}
)
# No-LoRA reason codes (persisted, consumed by the recipe modal).
REASON_NO_LORAS_USED = "no_loras_used"
REASON_API_NO_LORA_RESOURCES = "api_meta_no_lora_resources"
REASON_API_META_MISSING = "api_meta_missing"
REASON_NO_EMBEDDED_METADATA = "no_embedded_metadata"
REASON_WORKFLOW_METADATA_LIMITED = "workflow_metadata_limited"
REASON_VIDEO_NO_METADATA = "video_no_metadata"
REASON_METADATA_UNSUPPORTED = "metadata_unsupported"
REASON_UNKNOWN = "unknown"
_COMFY_PARSER_NAME = "ComfyMetadataParser"
# Cap for api_meta_keys kept in details — enough for the UI bullet without
# bloating the recipe JSON.
_MAX_DETAIL_KEYS = 12
def compute_no_loras_reason(
channel: str, diagnostics: Optional[Dict[str, Any]]
) -> str:
"""Classify why an import produced no LoRA entries.
Args:
channel: One of the CHANNEL_* constants.
diagnostics: Signals collected during analysis (see
``RecipeAnalysisService``), or None for channels without analysis
(e.g. widget saves).
"""
diag = diagnostics or {}
if diag.get("is_video"):
return REASON_VIDEO_NO_METADATA
# Embedded metadata that is a ComfyUI workflow: LoRA extraction from
# workflows is limited, so report that specifically.
parser = diag.get("exif_parser") or diag.get("parser")
if parser == _COMFY_PARSER_NAME:
return REASON_WORKFLOW_METADATA_LIMITED
if channel in _URL_CHANNELS:
if not diag.get("civitai_image"):
# Generic (non-CivitAI) URL: only embedded metadata is available.
if not diag.get("exif_present"):
return REASON_NO_EMBEDDED_METADATA
return (
REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
)
# NOTE: no "parsed EXIF means no LoRAs were used" shortcut here.
# CivitAI's onsite generator writes A1111-style EXIF (prompt, seed,
# steps, ...) WITHOUT LoRA references — LoRA usage lives only in
# CivitAI-internal data — so cleanly parsed EXIF cannot prove the
# generation used no LoRAs. Report the API meta shape instead.
api_keys = diag.get("api_meta_keys") or []
api_mvids = diag.get("api_model_version_ids") or 0
if api_keys or api_mvids:
return REASON_API_NO_LORA_RESOURCES
return REASON_API_META_MISSING
if channel == CHANNEL_WIDGET:
return REASON_NO_LORAS_USED
# Local file / upload / local re-import: embedded metadata only.
if not diag.get("exif_present"):
return REASON_NO_EMBEDDED_METADATA
return REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
def build_import_info(
channel: str,
diagnostics: Optional[Dict[str, Any]],
loras: Optional[List[Dict[str, Any]]],
) -> Dict[str, Any]:
"""Build the ``import_info`` block persisted on a recipe.
Always records the import channel; adds ``reason`` and ``details`` only
when the recipe has no LoRAs.
"""
info: Dict[str, Any] = {"channel": channel}
if loras:
return info
info["reason"] = compute_no_loras_reason(channel, diagnostics)
diag = diagnostics or {}
details: Dict[str, Any] = {}
api_keys = diag.get("api_meta_keys")
if api_keys:
details["api_meta_keys"] = list(api_keys)[:_MAX_DETAIL_KEYS]
api_mvids = diag.get("api_model_version_ids")
if api_mvids is not None:
details["api_model_version_ids"] = api_mvids
if "exif_present" in diag:
details["exif_present"] = bool(diag.get("exif_present"))
if diag.get("exif_parser"):
details["exif_parser"] = diag["exif_parser"]
if diag.get("is_video"):
details["is_video"] = True
if details:
info["details"] = details
return info
@@ -21,6 +21,7 @@ from ...utils.base_model import (
from ...utils.utils import calculate_recipe_fingerprint
from ..pending_delete_service import get_pending_delete_service
from .errors import RecipeNotFoundError, RecipeValidationError
from .import_info import CHANNEL_UPLOAD, CHANNEL_WIDGET, build_import_info
@dataclass(frozen=True)
@@ -134,6 +135,22 @@ class RecipePersistenceService:
if metadata.get("source_path"):
recipe_data["source_path"] = metadata.get("source_path")
# Persist import provenance. Batch import / re-import paths pass a
# prebuilt import_info; frontend-driven saves (upload, single URL,
# local path) carry the analysis payload's diagnostics, from which
# import_info is derived here.
import_info = metadata.get("import_info")
if not isinstance(import_info, dict):
diagnostics = metadata.get("diagnostics")
if isinstance(diagnostics, dict):
import_info = build_import_info(
diagnostics.get("channel") or CHANNEL_UPLOAD,
diagnostics,
loras_data,
)
if isinstance(import_info, dict) and import_info:
recipe_data["import_info"] = import_info
nsfw_level = metadata.get("preview_nsfw_level")
if nsfw_level is not None and isinstance(nsfw_level, int):
recipe_data["preview_nsfw_level"] = nsfw_level
@@ -731,6 +748,9 @@ class RecipePersistenceService:
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
# embedded metadata chunks, so a workflow can never be present.
"has_workflow": False,
# Widget saves read LoRAs straight from the current workflow; an
# empty list means the workflow used no LoRAs.
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
}
if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry