Pair category repair (#550) and saved instructions + dialog picker (#431) (#590)

* feat(maintenance): repair old Pair prompts' empty category and tags (#550)

Squashed from PR #579. Closes #550.

* feat(tagging): saved instructions with a default per engine (#431)

Squashed from PR #587.

* feat(tagging): pick saved instructions in the tagging dialogs (#431)

Squashed from PR #588. Closes #431.
This commit is contained in:
Vito
2026-11-01 03:01:07 +00:00
committed by GitHub
parent 9640ca86f8
commit 3295ff8b20
44 changed files with 3905 additions and 207 deletions
+23 -3
View File
@@ -78,6 +78,12 @@ MODEL_DOWNLOAD_BODY_MAX = 1024
_download_titles = {}
def _instruction_engine(model_type: str) -> str:
"""The language-model engine whose default instructions ``model_type``
reads: itself, or GGUF for a WD14 engine (which takes none)."""
return model_type if model_type in ("gguf", "hf") else "gguf"
def _holder_label(holder: Optional[str]) -> str:
if not holder:
return ANOTHER_OPERATION
@@ -294,7 +300,15 @@ class AutotagRoutesMixin:
"success": True,
"models": public_models(status, loaded_type),
# The saved tagging defaults (#328, System › Auto-tagging).
"default_prompt": AutotagConfig.TEMPLATE,
# The saved engine's default instructions (GGUF for a
# WD14 engine); kept one release for cached pages (#431).
"default_prompt": AutotagConfig.default_instructions_text(
_instruction_engine(AutotagConfig.MODEL_TYPE)
),
# #431: Built-in first, with every text, and each
# language-model engine's default.
"instructions": AutotagConfig.all_instructions(),
"default_instructions": dict(AutotagConfig.DEFAULT_INSTRUCTIONS),
"model_loaded": loaded,
"loaded_model_type": loaded_type,
"wd14_general_threshold": AutotagConfig.GENERAL_THRESHOLD,
@@ -549,7 +563,10 @@ class AutotagRoutesMixin:
else:
prompt = params.get("prompt")
template = prompt if isinstance(prompt, str) and prompt.strip() else None
options = {"instructions": template or AutotagConfig.TEMPLATE}
options = {
"instructions": template
or AutotagConfig.default_instructions_text(model_type)
}
try:
_model, options = validate_options(
model_type,
@@ -1092,7 +1109,10 @@ class AutotagRoutesMixin:
else:
prompt = data.get("prompt")
template = prompt if isinstance(prompt, str) and prompt.strip() else None
options = {"instructions": template or AutotagConfig.TEMPLATE}
options = {
"instructions": template
or AutotagConfig.default_instructions_text(model_type)
}
use_gpu = data.get("use_gpu", True)
options["use_gpu"] = use_gpu if isinstance(use_gpu, bool) else True
return validate_options(
+30 -17
View File
@@ -204,6 +204,8 @@ class Job:
self.total = 0
self.counters = dict.fromkeys(groups, 0)
self.outcomes = {group: [] for group in groups}
# Groups whose report lists every row, beyond MAX_OUTCOME_ROWS (#550).
self.complete = {}
self.log = []
self._cancel_requested = False
self.last_event = None
@@ -297,31 +299,36 @@ class Job:
)
self.log_line(log_line)
def record_counted(self, group, count, rows=()):
def record_counted(self, group, count, rows=(), *, complete=False):
"""Count ``count`` outcomes in ``group`` at once, listing ``rows``.
For outcomes that need no log line each (a thumbnail job's already
fresh previews): the counter is exact, ``rows`` (``record``'s row
shape) are listed up to MAX_OUTCOME_ROWS, and nothing is logged.
``complete`` also keeps every row for the report (#550: a record of
each change that must not be cut short; the caller bounds it).
"""
if group not in self.counters:
raise ValueError(f"Unknown outcome group {group!r} for {self.kind}")
self.counters[group] += max(0, int(count))
listed = self.outcomes[group]
kept = self.complete.setdefault(group, []) if complete else None
for row in rows:
if len(listed) >= MAX_OUTCOME_ROWS:
if kept is None and len(listed) >= MAX_OUTCOME_ROWS:
break
index = row.get("root_index")
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
index = None
listed.append(
{
"path": _relative_path(row.get("path")),
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
)
shaped = {
"path": _relative_path(row.get("path")),
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
if kept is not None:
kept.append(shaped)
if len(listed) < MAX_OUTCOME_ROWS:
listed.append(dict(shaped))
def finish(self, state="finished"):
"""End the job in ``state`` and release every subscriber.
@@ -406,8 +413,9 @@ class Job:
"""The downloadable report: the summary, every outcome group and the log.
Each group carries its exact count, its listed rows (at most
MAX_OUTCOME_ROWS) and whether the list is cut short. Rows and log lines
already hold root-relative paths only.
MAX_OUTCOME_ROWS, or every row of a ``complete`` group) and whether
the list is cut short. Rows and log lines already hold root-relative
paths only.
"""
summary = self.summary()
summary.pop("listed", None)
@@ -417,16 +425,21 @@ class Job:
"generated_at": self._clock(),
"job": summary,
"outcomes": {
group: {
"count": self.counters.get(group, len(rows)),
"rows": [dict(row) for row in rows],
"truncated": self.counters.get(group, len(rows)) > len(rows),
}
group: self._report_group(group, rows)
for group, rows in self.outcomes.items()
},
"log": list(self.log),
}
def _report_group(self, group, listed):
rows = self.complete.get(group, listed)
count = self.counters.get(group, len(rows))
return {
"count": count,
"rows": [dict(row) for row in rows],
"truncated": count > len(rows),
}
def report_filename(self):
"""The download name: prompt-manager-KIND-YYYYMMDD-HHMM.json (local time)."""
stamp = time.strftime("%Y%m%d-%H%M", time.localtime(self._clock()))
+60 -6
View File
@@ -1,8 +1,9 @@
"""Maintenance routes: reviewed, read-only plans (#249 System S6).
``POST /prompt_manager/maintenance/preview {operation, options:{}}`` reviews
one operation (``duplicate_prompts``, ``optimize``, ``unused_components`` or
``missing_links``; ``database/maintenance.py``; ``duplicate_files``,
one operation (``duplicate_prompts``, ``optimize``, ``unused_components``,
``missing_links`` or ``pair_metadata``, #550; ``database/maintenance.py``;
``duplicate_files``,
``py/api/duplicate_files.py``; ``rotated_logs``, the rotated log copies,
``py/api/log_cleanup.py``, which has its own apply job) off the event loop and
answers a ``Plan``: ``{success, token, operation, expires_at, scope:{count,
@@ -97,7 +98,11 @@ MAINTENANCE_TITLES = {
"unused_components": "Unused prompt components",
"missing_links": "Missing-file links",
"workflow_storage": "Store each workflow once",
# #550, beyond the concept: fills old Pair prompts' empty category and tags.
"pair_metadata": "Pair category and tags",
}
# Operations whose job keeps every changed row for its report (#550).
COMPLETE_RECORD = frozenset({"pair_metadata"})
_OPERATION_ERROR = "operation must be one of: " + ", ".join(PREVIEW_OPERATIONS)
MAINTENANCE_GROUPS = ("changed", "protected", "errors")
MAX_TOKEN_LENGTH = 128
@@ -281,9 +286,27 @@ def short_space_message(short):
)
def _changed_line(operation, count, marks=0):
def _pair_metadata_line(result):
count = result["changed"]
noun = (
"prompt from its saved pairs"
if count == 1
else "prompts from their saved pairs"
)
categories = result.get("categories", 0)
tag_sets = result.get("tag_sets", 0)
return (
f"Filled {count:,} {noun} "
f"({categories:,} {'category' if categories == 1 else 'categories'}, "
f"{tag_sets:,} {'tag set' if tag_sets == 1 else 'tag sets'})"
)
def _changed_line(operation, count, marks=0, result=None):
if operation == "optimize":
return "Optimized the database"
if operation == "pair_metadata":
return _pair_metadata_line(result or {"changed": count})
if operation == "duplicate_prompts":
noun = "duplicate prompt" if count == 1 else "duplicate prompts"
return f"Merged {count:,} {noun}"
@@ -668,9 +691,28 @@ class MaintenanceRoutesMixin:
rows = await self._run_in_executor(
self._maintenance_rows, plan.operation, result["rows"]
)
job.record_counted("changed", result["changed"], rows)
job.record_counted("protected", payload["protected"])
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
job.record_counted(
"changed",
result["changed"],
rows,
complete=plan.operation in COMPLETE_RECORD,
)
if "kept" in result:
# #550: each kept record with its reason, like the changed ones.
kept = [
{
"path": "",
"detail": f"Prompt {item['prompt_id']}: kept, "
+ "; ".join(item["reasons"]),
}
for item in result["kept"]
]
job.record_counted("protected", len(kept), kept, complete=True)
else:
job.record_counted("protected", payload["protected"])
line = _changed_line(
plan.operation, result["changed"], result.get("marks", 0), result
)
job.log_line(line)
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
return None
@@ -691,6 +733,8 @@ class MaintenanceRoutesMixin:
}
)
return rows
if operation == "pair_metadata":
return [{"path": "", "detail": _filled_detail(item)} for item in described]
if operation == "duplicate_prompts":
return [
{
@@ -708,6 +752,16 @@ class MaintenanceRoutesMixin:
]
def _filled_detail(item):
"""``Prompt 12: category “portrait”; tags a, b``: one filled prompt (#550)."""
parts = []
if "category" in item:
parts.append(f"category “{item['category']}”")
if item.get("tags"):
parts.append("tags " + ", ".join(item["tags"]))
return f"Prompt {item['prompt_id']}: " + "; ".join(parts)
def backup_note(backup):
"""The status line's backup part: the file name, or that there is none."""
return f"Safety backup {backup}" if backup else NO_BACKUP
+100 -14
View File
@@ -54,13 +54,24 @@ _SETTINGS_FILE_KEYS = {
"AUTOTAG_EXISTING": ("autotag", "defaults", "existing"),
"AUTOTAG_GENERAL_THRESHOLD": ("autotag", "defaults", "general_threshold"),
"AUTOTAG_CHARACTER_THRESHOLD": ("autotag", "defaults", "character_threshold"),
"AUTOTAG_TEMPLATE": ("autotag", "defaults", "template"),
"AUTOTAG_SUGGESTION_LIMIT": ("autotag", "defaults", "suggestion_limit"),
"AUTOTAG_SKIPPED_TAGS": ("autotag", "defaults", "skipped_tags"),
# #431: the saved instructions and each engine's default, always written
# together (server-side pairing).
"AUTOTAG_INSTRUCTIONS": ("autotag", "defaults", "instructions"),
"AUTOTAG_DEFAULT_INSTRUCTIONS": ("autotag", "defaults", "default_instructions"),
}
# The old single template: written only as the GGUF default's mirror for a
# downgrade (#431 B1.1), deleted when that default is Built-in.
_TEMPLATE_MIRROR_KEY = ("autotag", "defaults", "template")
TEMPLATE_REPLACED_ERROR = (
"LLM tag template was replaced by Saved instructions. Reload the page."
)
# Whole-list settings (#431): a save without ``expected_revision`` would merge
# a stale copy over a list changed elsewhere, so it is refused.
_REVISION_REQUIRED = frozenset({"autotag_skipped_tags"})
_REVISION_REQUIRED = frozenset(
{"autotag_skipped_tags", "autotag_instructions", "autotag_default_instructions"}
)
REVISION_REQUIRED_ERROR = "Reload the page and try again."
# The thumbnail settings a running Convert outdated job fixes (#317).
_THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE")
@@ -142,13 +153,20 @@ def _settings_mutation(updates, new_roots):
"""The config commit for a settings save: only the submitted keys change.
Every other section and key (integrations and the CivitAI key, database,
unknown sections) is kept as it is on disk.
unknown sections) is kept as it is on disk. A save of the instructions
also writes the old ``template`` key as the GGUF default's mirror.
"""
def mutate(config):
for name, value in updates.items():
*sections, key = _SETTINGS_FILE_KEYS[name]
_set_nested(config, sections, key, value)
_set_nested(config, sections, key, _jsonable(value))
if "AUTOTAG_INSTRUCTIONS" in updates:
*sections, key = _TEMPLATE_MIRROR_KEY
mirror = _mirror_text(
updates["AUTOTAG_INSTRUCTIONS"], updates["AUTOTAG_DEFAULT_INSTRUCTIONS"]
)
_set_nested(config, sections, key, _DELETE if mirror is None else mirror)
if new_roots is not None:
gallery = _section(config, "gallery")
monitoring = _section(gallery, "monitoring")
@@ -160,10 +178,37 @@ def _settings_mutation(updates, new_roots):
return mutate
# ``_set_nested``'s value that removes the key instead of setting it.
_DELETE = object()
def _jsonable(value):
"""A runtime value as config.json holds it (tuples become lists)."""
if isinstance(value, (list, tuple)):
return [_jsonable(item) for item in value]
if isinstance(value, dict):
return {key: _jsonable(item) for key, item in value.items()}
return value
def _mirror_text(entries, defaults):
"""The GGUF default's text, or None when it is Built-in (#431 B1.1)."""
from ..tagging_defaults import BUILTIN_INSTRUCTIONS_ID
for item in entries:
if item["id"] == defaults["gguf"] != BUILTIN_INSTRUCTIONS_ID:
return item["text"]
return None
def _set_nested(config, sections, key, value):
"""``config[s1][s2]...[key] = value``, each section a fresh dict."""
"""``config[s1][s2]...[key] = value``, each section a fresh dict;
``_DELETE`` removes the key."""
if not sections:
config[key] = value
if value is _DELETE:
config.pop(key, None)
else:
config[key] = value
return
merged = _section(config, sections[0])
_set_nested(merged, sections[1:], key, value)
@@ -182,7 +227,6 @@ def _validate_autotag_settings(data):
MODEL_TYPES,
validate_saved_threshold,
validate_skipped_tags,
validate_template,
)
updates = {}
@@ -211,12 +255,7 @@ def _validate_autotag_settings(data):
raise ValueError(f"{error}.") from None
updates[key.upper()] = value
if "autotag_template" in data:
try:
updates["AUTOTAG_TEMPLATE"] = validate_template(
data["autotag_template"], "LLM tag template"
)
except ValueError as error:
raise ValueError(f"{error}.") from None
raise ValueError(TEMPLATE_REPLACED_ERROR)
if "autotag_suggestion_limit" in data:
value = data["autotag_suggestion_limit"]
if type(value) is not int or not 1 <= value <= MAX_SUGGESTION_LIMIT:
@@ -229,9 +268,37 @@ def _validate_autotag_settings(data):
updates["AUTOTAG_SKIPPED_TAGS"] = tuple(
validate_skipped_tags(data["autotag_skipped_tags"])
)
updates.update(_validate_instruction_settings(data))
return updates
def _validate_instruction_settings(data):
"""Server-side pairing (#431): a save that carries either instruction key
gets the other from the running configuration (which holds the migrated
list), the defaults are checked against the merged list, and both are
written. Empty when the save carries neither."""
from ..config import AutotagConfig
from ..tagging_defaults import (
validate_default_instructions,
validate_instructions,
)
if not {"autotag_instructions", "autotag_default_instructions"} & set(data):
return {}
if "autotag_instructions" in data:
entries = validate_instructions(data["autotag_instructions"])
else:
entries = [dict(item) for item in AutotagConfig.INSTRUCTIONS]
defaults = data.get(
"autotag_default_instructions", dict(AutotagConfig.DEFAULT_INSTRUCTIONS)
)
defaults = validate_default_instructions(defaults, entries)
return {
"AUTOTAG_INSTRUCTIONS": tuple(entries),
"AUTOTAG_DEFAULT_INSTRUCTIONS": defaults,
}
async def _read_json(request):
"""Parse a JSON object body through the package's capped reader."""
from . import _read_json_body
@@ -259,6 +326,7 @@ class SettingsRoutesMixin:
"""Get current settings."""
try:
from ..config import AutotagConfig, PromptManagerConfig, GalleryConfig
from ..tagging_defaults import DEFAULT_TAG_TEMPLATE
root_paths = [
self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES
@@ -291,7 +359,19 @@ class SettingsRoutesMixin:
"autotag_character_threshold": (
AutotagConfig.CHARACTER_THRESHOLD
),
"autotag_template": AutotagConfig.TEMPLATE,
# Read-only for one release (#431): a page cached
# from before the upgrade still renders.
"autotag_template": AutotagConfig.default_instructions_text(
"gguf"
),
"autotag_instructions": [
dict(item) for item in AutotagConfig.INSTRUCTIONS
],
"autotag_default_instructions": dict(
AutotagConfig.DEFAULT_INSTRUCTIONS
),
# Built-in's text, which System shows read-only.
"autotag_builtin_instructions": DEFAULT_TAG_TEMPLATE,
"autotag_suggestion_limit": AutotagConfig.SUGGESTION_LIMIT,
"autotag_skipped_tags": list(AutotagConfig.SKIPPED_TAGS),
"cpu_count": PromptManagerConfig.max_worker_threads(),
@@ -526,6 +606,12 @@ class SettingsRoutesMixin:
if name in _AUTOTAG_SETTINGS:
setattr(AutotagConfig, name[len(_AUTOTAG_PREFIX) :], value)
continue
if "AUTOTAG_INSTRUCTIONS" in updates:
# What config.json now holds as the old key (the mirror).
AutotagConfig._LEGACY_TEMPLATE = AutotagConfig.mirror_template()
for name, value in updates.items():
if name in _AUTOTAG_SETTINGS:
continue
owner = GalleryConfig if name in _GALLERY_SETTINGS else PromptManagerConfig
setattr(owner, name, value)
restart_fields = []