* 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:
@@ -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
@@ -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
@@ -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
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user