diff --git a/TESTING.md b/TESTING.md index 3d80f2ae..ec95197c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1886,7 +1886,8 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68 engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400 MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start - from the server's `default_prompt`. + from the chosen engine's default saved instructions (#431, D204; before, the + server's `default_prompt`). - **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model change keeps it. @@ -2978,7 +2979,8 @@ change: ComfyUI needs a restart. A Pair save, by a run or the node's Save pair button, writes the category and tags to the positive prompt only when that save creates the prompt row (maintainer ruling). An existing prompt never changes on a run: empty, cleared or set by a person, including one first seen as another pair's negative. The negative stays empty, the saved pair keeps its own metadata, and `save_prompt_pair` writes -no prompt metadata unless called with `fill_new_positive`. No backfill; optional repair is #550. +no prompt metadata unless called with `fill_new_positive`. No backfill on a run; the opt-in repair for prompts older +Pair runs saved is System › Maintenance › "Pair category and tags" (#550, "Pair category and tags repair" below). Tests: `tests/test_prompt_pair_node.py`, `tests/test_api_pairs.py`, `tests/comfyui/pair-node.spec.mjs`. A Python change: ComfyUI needs a restart. ### Delete… on the archived shelf (#492) @@ -4307,6 +4309,12 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati primary stays the concept's plain button; the plan it opens has the primary. `duplicates dialog matches the concept` and `duplicates comparison detects mutations` (system-maintenance-concept.spec) pin the mapping. +- **D192** (Maintenance, #550, beyond the concept): Database adds "Pair category and + tags" after "Unused prompt components", a plain Review plan row (no danger mark: it + deletes nothing) with the line "Fill an empty category or tags on prompts older Pair + node runs saved, from their saved pairs. Only empty fields of never-edited prompts." + `system-maintenance-concept.spec.mjs` maps it (`mapPairRow`) as a copy of Optimize + database's row. - **D102** (Maintenance, S7, Q5, #222): Metadata cache clears only the browser entries Prompt Manager owns as a cache (`web/js/cache-ownership.js`). The inventory of every storage writer has none (see "Browser cache ownership" below), so the row's line adds @@ -4606,9 +4614,9 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati engine and policy (read-only) and opens "Review tag output images" in the concept's record-changing review (its `maintenancePlan` shell): `h3` "Tag output images", the `dl.meta-grid` Model, Existing tags, Images, Already auto-tagged, Images to tag - ("about N"), Time, Settings ("General · Character " or "Template of N - characters", then " · up to L tags per image" with the preview's saved - `suggestion_limit`) and Backup, the estimate sentence ("Nothing is written until you + ("about N"), Time, Settings ("General · Character ", then " · up to L tags + per image" with the preview's saved `suggestion_limit`; for a language model, since + D205, an "Instructions" row and "Up to L tags per image") and Backup, the estimate sentence ("Nothing is written until you start. The number to tag is an estimate: images changed since they were tagged are tagged again, and missing files are skipped. Activity reports what was tagged.") in the `p.small`, the D55 backup box and Cancel / "Start tagging", which starts the @@ -4650,6 +4658,51 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati concept row's), `skipped tags add, preview, remove, save and reload (D202)`, `skipped tags preview checks library tags only …` (workspace-system-tagging.spec) and `suggestions omit skipped tags and say how many were hidden` (workspace-tagging.spec). +- **D203** (Auto-tagging, #431, beyond the concept): "Saved instructions" replaces the + concept's "LLM tag template" field (the mock's state 2): a `.setting-block` + (`data-setting="tag-instructions"`, `data-key="autotag_instructions"`) with the label, + "Instructions for the language-model engines. Built-in can’t be changed; duplicate it + to make your own. N of 20 saved · Built‑in is not counted.", a `role="listbox"` list + (Built-in first with a "Read-only" chip, then the saved entries; "Default for GGUF" / + "Default for HuggingFace" chips), "+ New" ("New instructions", "(2)" if taken), + "Duplicate" (" copy"), "Delete" (`.danger`, disabled for Built-in), "At most 20 + saved instructions." at the cap, and the editor beside the list (under it below 700px): + "Name" ("N of 60") and "Text" ("N of 4,000 characters"), read-only for Built-in with + "Built-in instructions can’t be changed. Duplicate them to make your own.", else the + entry's problem ("The name “X” is already used.", "The text must not be empty."…). + Then two concept select rows, "Default for GGUF JoyCaption" and "Default for + HuggingFace JoyCaption" ("The Tag image dialog and batches start from this.", + `data-key="autotag_default_instructions"`), and the note a deleted default leaves + ("GGUF now uses Built-in."; nothing is written until Save preferences). Pinned by the + comparison's mapping (system-tagging-concept.spec: the block in the concept row's + tokens, the two rows on the concept's select row) and `saved instructions: new, edit, + per-engine default, delete, save and reload (D203)`, `a refused instructions save names + the entry and highlights the block (D203)` (workspace-system-tagging.spec). +- **D204** (Tag image and Auto-tag selected dialogs, #431, beyond the concept): for a + language-model engine a "Saved instructions" select (`#image-tag-instructions-pick`, + `#bulk-tag-instructions-pick`) sits above "Tagging instructions" (the mock's state 3), + on the concept's select field: Built-in, then the saved entries (`GET /autotag/models` + `instructions`, full text), the engine's default marked " · default". It starts + at that default; a pick replaces the text. The text stays editable for the run: while + it differs from the picked entry the select shows a disabled "Edited for this run" and + a muted line under the textarea reads "Edits apply to this run only. Manage saved + instructions in System › Auto-tagging." Choosing an entry again replaces the edit. + Switching between GGUF and HF starts from the new engine's default and drops an edit; + WD14 hides both. A preset (Preview tagging review, the bulk review) shows the entry + with its text, else "Edited for this run". When the read fails the select holds only + Built-in with the client's copy (`BUILTIN_INSTRUCTIONS`, pinned equal to + `DEFAULT_TAG_TEMPLATE` by workspace-image-tagging-state.test). Request bodies are + unchanged (`options.instructions` is the textarea's text); there is no "Save as new". + The workspace no longer reads `default_prompt`. Pinned by the tagging comparison's + mapping (tagging-canonical-comparison.mjs: the select before the instructions), + `the saved instructions picker fills, edits one-off and resets per engine (D204)` + (workspace-tagging.spec) and `Auto-tag selected picks saved instructions and sends the + text shown (D204)` (workspace-tagging-bulk.spec). +- **D205** (Auto-tagging, #431; amends D134): for a language-model engine the batch + review adds a read-only "Instructions" row with the drafted engine default's name + before "Settings", which reads "Up to L tags per image"; the batch and Preview tagging + review send that entry's text. WD14 is unchanged. Pinned by `the batch review names the + language model's default instructions (D205)` (workspace-system-tagging.spec). - **D158** (Sources, #247 D-Q4): the concept has no switch for prompts from workflows without a PromptManager node; the maintainer chose a visible switch over a config key. A `.setting-row` in the concept's toggle markup follows "Watch for new outputs": @@ -4881,6 +4934,47 @@ replaced the database since the review; the plan is dropped). The job: prompt ids only), logs the backup by its file name, and prunes safety backups to the newest 10. With no backup the log and the status line say "No safety backup". +#### Pair category and tags repair (#550) + +`pair_metadata` ("Pair category and tags", D192; `database/pair_metadata_repair.py`) fills +the empty category and the empty tag set of prompts that Pair node runs before #546 +saved, from their saved pairs. It is reviewed and applied like the operations above and +follows the safety backup policy like them (no forced backup). Rules +(`docs/egm/post-3.4.1/550-design.md`): + +- Considered: prompts the Pair node created as a positive: their earliest pair (lowest + id, either role, archived included) has them as the positive and its `created_at` is + within 2 s of theirs. A prompt first saved by the PromptManager node, or first as a + negative, is out of scope and not counted. +- Sources: the prompt's non-archived pairs where it is the positive; pairs with a blank + category or no tags are ignored for that field. Category values are trimmed and + case-sensitive, tag sets compared by tag id; a field fills when exactly one value is + left. A whitespace-only category counts as empty. +- Kept, per field, in this order: archived prompt, edited prompt (`updated_at` not within + 2 s of `created_at`; NULL or unreadable counts as edited), pairs that disagree, a tag + set over 20 tags. A prompt can fill one field and keep the other. +- The plan lists protected rows first, then fills, then a "Record" row, within the 50 + detail rows; at most 20,000 prompts per plan ("review again for the rest"). +- The fingerprint covers each considered prompt's computed result (values, tag ids and + names, source pair ids), never a pair's title, notes, rating, run count or use + stamps, so a Pair run between review and apply still applies. +- The apply's writes are guarded (`category` still blank, no `prompt_tags` row) and a + guard that hits nothing rolls the whole transaction back as `plan-changed`. It never + writes `prompt_pairs`, `prompt_pair_tags`, `updated_at`, a tag or Library state. +- The job lists 200 changed rows ("Prompt 12: category “night”; tags a, b"); its + Download report lists every one (`truncated: false`, `Job.record_counted(complete=True)`). + Protected lists one row per kept prompt in the review's wording and order ("Prompt 7: + kept, pairs disagree on category: Adult, asian"), also complete in the report. +- Activity shows a row with no path as a record: its detail as plain text, never an open + or locate action, and the filter reads the detail. A maintenance job of records (every + operation but Missing-file links) says "No records in this group.", "No listed record + matches this filter." and "Filter records…" (`groupCopy`); file jobs keep "files". + +Tests: `tests/test_maintenance_pair_metadata.py`, `tests/test_api_maintenance_pair_metadata.py`, +`tests/test_jobs.py`, `tests/js/workspace-system-maintenance.test.js`, +`tests/browser/workspace-system-maintenance.spec.mjs` (hook `/__test__/system/pair-metadata`). +A Python change: ComfyUI needs a restart. + The legacy restore (`POST /prompt_manager/restore`) takes a backup of any size the disks have room for, so a real library's safety backup restores: the upload, its staged copy and the swap (the upload twice in the temporary folder, once more plus the current @@ -5235,16 +5329,16 @@ server-side tagging configuration, `autotag.defaults` in config.json: `model_typ (`wd14-swinv2`, `wd14-vit`, `gguf`, `hf`), `existing` (`skip`, `merge`, `replace_after_review`), `general_threshold` and `character_threshold` (0–1 in steps of 0.05, #369), -`template` (non-blank, at most 4,000 characters and 16 KiB) and `suggestion_limit` -(1–100). They are host drafts (`autotag_model_type`, `autotag_existing`, -`autotag_general_threshold`, `autotag_character_threshold`, `autotag_template`, -`autotag_suggestion_limit`) saved by the one Save row through `POST +`suggestion_limit` (1–100), and since #431 `skipped_tags`, `instructions` and +`default_instructions` (below). They are host drafts (`autotag_model_type`, `autotag_existing`, +`autotag_general_threshold`, `autotag_character_threshold`, `autotag_suggestion_limit`, +`autotag_skipped_tags`, `autotag_instructions`, `autotag_default_instructions`) saved by the one Save row through `POST /prompt_manager/settings` with `expected_revision`; a bad value is refused with a 400 naming the setting by its row label ("General-tag confidence must be between 0 and 1."), nothing is written, the Save row shows that sentence (`invalid-setting`, set by `settingRefusal` in system.mjs only for a save that carried `autotag_*` keys; every other section's plain 400 keeps the generic status) and the control is highlighted. -The template's limit counts code points, as Python's `len` does. A hand-edited bad value +Instruction text limits count code points, as Python's `len` does. A hand-edited bad value falls back to its default on load, one key at a time. Every page shows the thresholds in steps of 0.05 (the System rows, the Tag image dialogs, the legacy sliders), so a saved threshold off that grid is refused ("General-tag confidence must be a multiple of @@ -5255,8 +5349,42 @@ stored exactly on the grid (#369). A per-request threshold keeps its own check: `validate_options` and starts the `autotag` job titled "Tag output images (legacy page)"; the legacy `/autotag/single` only requires a number. `GET /autotag/models` reports the saved values (`wd14_general_threshold`, `wd14_character_threshold`, `suggestion_limit`, -`default_prompt`, `model_type`, `existing`), so the Tag image and Auto-tag selected -dialogs start from them; a preset (the bulk review, Preview tagging review) wins. +`default_prompt`, `model_type`, `existing`, and since #431 `instructions` and +`default_instructions`), so the Tag image and Auto-tag selected dialogs start from them; +a preset (the bulk review, Preview tagging review) wins. + +**Saved instructions** (#431, D203, D205): `autotag.defaults.instructions` holds the +saved entries `[{id, name, text}]` in order (at most 20; `id` 12 lowercase hex made by +the client, unique, `builtin` reserved; `name` trimmed, 1–60 characters, no control +character, unique case-folded, not "Built-in"; `text` as `validate_template`), and +`autotag.defaults.default_instructions` is `{gguf, hf}`, each `builtin` or a saved id. +Built-in (`{id: "builtin", name: "Built-in", text: DEFAULT_TAG_TEMPLATE}`) is never +stored; readers put it first (`AutotagConfig.all_instructions`), so a later release's +built-in text reaches everyone who uses it. Refusals (400, the Save row shows them and +highlights the editor): "Saved instructions: at most 20 entries besides Built-in.", +"Saved instructions: entry N has an invalid id.", "… repeats the id of entry M.", "… +needs a name.", "… name must be at most 60 characters.", "… name must not contain a line +break.", "… name “X” is already used.", "Saved instructions: the name “Built-in” is +reserved.", "Saved instructions: entry N text must be …", "Default for GGUF JoyCaption +must be one of the saved instructions." (and HuggingFace). A save carrying either key +without `expected_revision` is refused ("Reload the page and try again."); a save +carrying one is paired with the other from the running configuration and both are +written, plus the old `template` key as the GGUF default's text (deleted when that +default is Built-in), so an older build reads that default as its one template. `POST` +with `autotag_template` is refused ("LLM tag template was replaced by Saved instructions. +Reload the page."); `GET` keeps returning it (the GGUF default's text, read-only) for one +release and adds `autotag_builtin_instructions` (Built-in's text, read-only). Load: each +key alone (a damaged list → Built-in only, a default naming nothing → Built-in, each with +one warning), then the in-memory migration of the old `template`: with no `instructions` +key, a template other than Built-in becomes "Custom template" (id = first 12 hex of its +SHA-256), the default of both engines unless `default_instructions` is stored; with the +key, a template matching no entry (edited on an older build) is appended as "Custom +template (n)" with one INFO line (a full list: one WARNING, nothing imported). Nothing is +written at load; the next save that carries either key writes the list. +`AutotagConfig.default_instructions_text(engine)` feeds the legacy batch and single +blank-prompt fallbacks (by the request's engine) and `default_prompt`. Tests: +`tests/test_saved_instructions.py`, `tests/test_saved_instructions_routes.py`, +`tests/js/workspace-system-instructions.test.js`. **Skipped tags** (#431, D202): `autotag.defaults.skipped_tags`, host draft `autotag_skipped_tags`, a list of at most 200 entries, each trimmed, 1–50 code points, diff --git a/database/maintenance.py b/database/maintenance.py index 27002761..38eadfbe 100644 --- a/database/maintenance.py +++ b/database/maintenance.py @@ -2,7 +2,8 @@ Four operations, each the native owner of a legacy ``/maintenance`` one and built on the same queries, so a preview counts what the legacy cleanup would -change: +change (``workflow_storage`` and #550's ``pair_metadata``, which fills old +Pair prompts' empty category and tags, live in their own modules): - ``duplicate_prompts``: prompts whose text is equal ignoring case and outer spaces (``DUPLICATE_PROMPTS_SQL``, legacy ``cleanup_duplicates``); each @@ -60,6 +61,9 @@ MAINTENANCE_OPERATIONS = ( "missing_links", # Applied as its own batched job (database/workflow_storage.py). "workflow_storage", + # #550: fill old Pair prompts' empty category and tags + # (database/pair_metadata_repair.py). + "pair_metadata", ) MAX_DETAILS = 50 # detail rows a plan lists MAX_RESULT_ROWS = 200 # changed records an apply describes (the job lists 200) @@ -533,6 +537,10 @@ class MaintenanceOperations: lambda conn: (self._link_rows(conn), _mark_rows(conn)), ) return self._plan_missing_links(rows, roots, _stale_marks(marks, roots)) + if operation == "pair_metadata": + from .pair_metadata_repair import plan_pair_metadata + + return _read_snapshot(self.model.db_path, plan_pair_metadata) reader = ( self._plan_duplicate_prompts if operation == "duplicate_prompts" @@ -800,7 +808,8 @@ class MaintenanceOperations: records changed and up to ``MAX_RESULT_ROWS`` descriptions of them (``{prompt_id[, kept_id]}`` or ``{image_path}``). ``marks`` are a missing-links plan's stale removal marks; that apply also returns - ``marks``, how many it forgot. + ``marks``, how many it forgot. ``pair_metadata`` returns every filled + prompt, uncapped (its job keeps the complete record, #550). """ if operation not in MAINTENANCE_OPERATIONS or operation == "workflow_storage": raise ValueError(f"Unknown maintenance operation {operation!r}") @@ -815,6 +824,10 @@ class MaintenanceOperations: "unused_components": self._apply_unused_components, } with self._pair_transaction() as conn: + if operation == "pair_metadata": + from .pair_metadata_repair import apply_pair_metadata + + return apply_pair_metadata(conn, fingerprint, targets) if operation == "missing_links": return self._apply_missing_links( conn, fingerprint, targets, roots, list(marks) diff --git a/database/pair_metadata_repair.py b/database/pair_metadata_repair.py new file mode 100644 index 00000000..0e430764 --- /dev/null +++ b/database/pair_metadata_repair.py @@ -0,0 +1,452 @@ +"""Pair category and tags repair (#550): the ``pair_metadata`` maintenance plan. + +Before #546 a Pair node run saved its category and tags on the saved pair +only; the positive prompt it created stayed Uncategorized and untagged. This +plan fills the **empty** category and the **empty** tag set of those prompts +from their saved pairs (``docs/egm/post-3.4.1/550-design.md``): + +- Considered: prompts the Pair node created as a positive (their earliest + pair, in either role, has them as the positive and its ``created_at`` is + within ``PAIR_CREATED_TOLERANCE`` of theirs) whose category is blank + and/or that have no tags. +- Sources: the prompt's non-archived pairs where it is the positive. Pairs + with a blank category or no tags are ignored for that field. A field fills + when exactly one value remains (category: trimmed, case-sensitive; tags: + the tag-id set). +- Kept (protected), in this order: archived prompt, edited prompt + (``updated_at`` not within the tolerance of ``created_at``), pairs that + disagree, a tag set over ``MAX_LIBRARY_TAGS``. + +The apply runs in the caller's ``BEGIN IMMEDIATE`` transaction, recomputes +the fingerprint, then writes guarded statements (the field must still be +empty) that never touch a pair, a negative or ``updated_at``. A guard that +hits nothing raises ``PlanChanged`` so the whole transaction rolls back. +""" + +import datetime + +from . import maintenance as _maintenance +from .library_commands import MAX_LIBRARY_TAGS +from .maintenance import ( + MAX_DETAILS, + MaintenanceDraft, + PlanChanged, + _excerpt, + _Fingerprint, + _marks, + _plural, +) + +OPERATION = "pair_metadata" +# Seconds: the Pair save stamps its new prompt and its pair with separate +# utc_now_iso() calls in one transaction (microseconds apart). +PAIR_CREATED_TOLERANCE = 2.0 +# Fill prompts one plan applies; the rest are applied by the next review. +# Bounds the complete record a job keeps in memory (about 100 bytes a row). +MAX_PLAN_FILLS = 20000 +DETAIL_VALUES = 3 +DETAIL_TAGS = 5 +MEDIA_LABEL = "No image file or saved pair is changed." +EMPTY_LABEL = "No Pair prompts are missing a category or tags" +RECORD_NOTE = ( + "The job's report lists every filled field. Download it to keep it: " + "Activity forgets jobs after a restart." +) +# Protected classes in precedence order: (key, detail, reason one, many). +EDITED_DETAIL = ( + "Kept: edited after it was saved; its empty category or tags may be intentional" +) +PROTECTED = ( + ( + "archived", + "Kept: archived in the Library", + "archived in the Library", + "archived in the Library", + ), + ( + "edited", + EDITED_DETAIL, + "edited after it was saved", + "edited after they were saved", + ), + ("conflict", None, "where pairs disagree", "where pairs disagree"), + ( + "too-many-tags", + f"Kept: its pair has more than {MAX_LIBRARY_TAGS} tags", + f"whose pair has more than {MAX_LIBRARY_TAGS} tags", + f"whose pair has more than {MAX_LIBRARY_TAGS} tags", + ), +) +_PROTECTED_ORDER = [key for key, *_rest in PROTECTED] +# Detail rows: what the user can act on (a conflict) first. +DETAIL_ORDER = ("conflict", "edited", "archived", "too-many-tags") + +_CANDIDATES_SQL = """ + SELECT p.id, p.hash, p.text, p.created_at, p.updated_at, + COALESCE(TRIM(p.category), '') = '' AS blank_category, + NOT EXISTS (SELECT 1 FROM prompt_tags pt WHERE pt.prompt_id = p.id) + AS untagged, + EXISTS (SELECT 1 FROM prompt_archive pa WHERE pa.prompt_id = p.id) + AS archived, + (SELECT CASE WHEN pp.positive_prompt_id = p.id + THEN pp.created_at END + FROM prompt_pairs pp + WHERE pp.positive_prompt_id = p.id OR pp.negative_prompt_id = p.id + ORDER BY pp.id LIMIT 1) AS first_pair_at + FROM prompts p + WHERE EXISTS (SELECT 1 FROM prompt_pairs pp WHERE pp.positive_prompt_id = p.id) + AND (COALESCE(TRIM(p.category), '') = '' + OR NOT EXISTS (SELECT 1 FROM prompt_tags pt WHERE pt.prompt_id = p.id)) + ORDER BY p.id +""" +_SOURCES_SQL = ( + "SELECT id, positive_prompt_id, TRIM(COALESCE(category, '')) FROM prompt_pairs " + "WHERE archived = 0 AND positive_prompt_id IN ({marks}) ORDER BY id" +) +_PAIR_TAGS_SQL = ( + "SELECT ppt.pair_id, t.id, t.name FROM prompt_pair_tags ppt " + "JOIN tags t ON t.id = ppt.tag_id WHERE ppt.pair_id IN ({marks}) " + "ORDER BY ppt.pair_id, t.id" +) + + +def parse_stamp(value): + """A stored timestamp as an aware UTC datetime, or None when unreadable.""" + if not isinstance(value, str) or not value.strip(): + return None + try: + stamp = datetime.datetime.fromisoformat(value.strip()) + except ValueError: + return None + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=datetime.timezone.utc) + return stamp + + +def _close(first, second): + """Whether two stored stamps parse and lie within the tolerance.""" + a, b = parse_stamp(first), parse_stamp(second) + if a is None or b is None: + return False + return abs((a - b).total_seconds()) <= PAIR_CREATED_TOLERANCE + + +def _batches(ids): + size = _maintenance.ID_BATCH + for start in range(0, len(ids), size): + yield ids[start : start + size] + + +def _sources(conn, ids): + """``{prompt_id: [(pair_id, category, ((tag_id, name), ...)), ...]}``.""" + pairs = {} + for batch in _batches(ids): + for pair_id, prompt_id, category in conn.execute( + _SOURCES_SQL.format(marks=_marks(batch)), batch + ): + pairs.setdefault(prompt_id, []).append([pair_id, category, []]) + by_pair = {pair[0]: pair for group in pairs.values() for pair in group} + pair_ids = sorted(by_pair) + for batch in _batches(pair_ids): + for pair_id, tag_id, name in conn.execute( + _PAIR_TAGS_SQL.format(marks=_marks(batch)), batch + ): + by_pair[pair_id][2].append((tag_id, name)) + return { + prompt_id: [(pid, category, tuple(tags)) for pid, category, tags in group] + for prompt_id, group in pairs.items() + } + + +def _field(values, guard): + """``(class, value)`` of one field from its distinct values, or None.""" + if not values: + return None + if guard: + return (guard, None) + if len(values) > 1: + return ("conflict", sorted(values)) + return ("fill", next(iter(values))) + + +def _classify(row, sources): + """The result of one considered prompt, or None when nothing applies.""" + guard = None + if row["archived"]: + guard = "archived" + elif not _close(row["updated_at"], row["created_at"]): + guard = "edited" + category = tags = None + tag_pair = None + if row["blank_category"]: + category = _field({c for _p, c, _t in sources if c}, guard) + if row["untagged"]: + sets = {} + for pair_id, _category, pair_tags in sources: + if pair_tags: + sets.setdefault(frozenset(pair_tags), pair_id) + tags = _field(set(sets), guard) + if tags and tags[0] == "fill": + tag_pair = sets[tags[1]] + if len(tags[1]) > MAX_LIBRARY_TAGS: + tags = ("too-many-tags", None) + else: + tags = ("fill", tuple(sorted(tags[1]))) + elif tags and tags[0] == "conflict": + tags = ("conflict", [sorted(s) for s in sorted(sets, key=sorted)]) + if category is None and tags is None: + return None + return { + "id": row["id"], + "hash": row["hash"], + "text": row["text"], + "category": category, + "tags": tags, + "tag_pair": tag_pair if tags and tags[0] == "fill" else None, + "pairs": [pair_id for pair_id, _c, _t in sources], + } + + +def _state(conn): + """``(results, fingerprint)``: every considered prompt's result, by id.""" + considered = [] + for row in conn.execute(_CANDIDATES_SQL): + row = dict( + zip( + ( + "id", + "hash", + "text", + "created_at", + "updated_at", + "blank_category", + "untagged", + "archived", + "first_pair_at", + ), + tuple(row), + ) + ) + if _close(row["created_at"], row["first_pair_at"]): + considered.append(row) + sources = _sources(conn, [row["id"] for row in considered]) + fingerprint = _Fingerprint(OPERATION) + results = [] + for row in considered: + result = _classify(row, sources.get(row["id"], [])) + if result is None: + continue + results.append(result) + fingerprint.add( + [ + result["id"], + result["hash"], + result["category"], + result["tags"], + result["tag_pair"], + result["pairs"], + ] + ) + return results, fingerprint.hexdigest() + + +def _fills(result): + return ( + bool(result["category"] and result["category"][0] == "fill"), + bool(result["tags"] and result["tags"][0] == "fill"), + ) + + +def _targets(results): + targets = [] + for result in results: + category, tags = _fills(result) + if category or tags: + targets.append([result["id"], category, tags]) + return targets[:MAX_PLAN_FILLS] + + +def _protected_classes(result): + """The protected classes of a result's fields, in precedence order.""" + found = { + field[0] + for field in (result["category"], result["tags"]) + if field and field[0] != "fill" + } + return [key for key in _PROTECTED_ORDER if key in found] + + +def _more(values, limit): + shown = ", ".join(values[:limit]) + return shown + (", …" if len(values) > limit else "") + + +def _fill_detail(result): + parts = [] + category, tags = _fills(result) + if category: + parts.append(f"category “{result['category'][1]}”") + if tags: + names = sorted(name for _id, name in result["tags"][1]) + noun = "tag" if len(names) == 1 else "tags" + parts.append(f"{len(names)} {noun}: {_more(names, DETAIL_TAGS)}") + pairs = [str(pair_id) for pair_id in result["pairs"]] + label = "pair" if len(pairs) == 1 else "pairs" + return f"Fills {' and '.join(parts)} ({label} {_more(pairs, DETAIL_VALUES)})" + + +def _protected_detail(result, key): + if key != "conflict": + return next(detail for k, detail, *_r in PROTECTED if k == key) + if result["category"] and result["category"][0] == "conflict": + values = result["category"][1] + return f"Kept: pairs disagree on category: {_more(values, DETAIL_VALUES)}" + return "Kept: pairs disagree on tags" + + +def _kept(results): + """One row per kept prompt, its review reasons without "Kept: " (#550).""" + rows = [] + for result in results: + classes = set(_protected_classes(result)) + if classes: + rows.append( + { + "prompt_id": result["id"], + "reasons": [ + _protected_detail(result, key).removeprefix("Kept: ") + for key in DETAIL_ORDER + if key in classes + ], + } + ) + return rows + + +def _details(results, targets): + rows = [] + for key in DETAIL_ORDER: + for result in results: + if key in _protected_classes(result): + rows.append( + { + "label": _excerpt(result["text"]), + "detail": _protected_detail(result, key), + } + ) + planned = {target[0] for target in targets} + for result in results: + if result["id"] in planned: + rows.append( + {"label": _excerpt(result["text"]), "detail": _fill_detail(result)} + ) + if not targets: + return rows[:MAX_DETAILS] + return rows[: MAX_DETAILS - 1] + [{"label": "Record", "detail": RECORD_NOTE}] + + +def _scope_label(targets, total): + if not targets: + return EMPTY_LABEL + categories = sum(1 for _id, category, _tags in targets if category) + tag_sets = sum(1 for _id, _category, tags in targets if tags) + label = ( + f"{_plural(len(targets), 'prompt')} to fill: " + f"{_plural(categories, 'category', 'categories')}, " + f"{_plural(tag_sets, 'tag set')}" + ) + if total > len(targets): + label += ( + f" (the first {len(targets):,} of {total:,}; review again for the rest)" + ) + return label + + +def plan_pair_metadata(conn): + """The reviewed ``pair_metadata`` plan on ``conn``'s snapshot.""" + results, fingerprint = _state(conn) + targets = _targets(results) + total = sum(1 for result in results if any(_fills(result))) + counts = {key: 0 for key in _PROTECTED_ORDER} + held = 0 + for result in results: + classes = _protected_classes(result) + held += bool(classes) + for key in classes: + counts[key] += 1 + reasons = [ + f"{counts[key]:,} {one if counts[key] == 1 else many}" + for key, _detail, one, many in PROTECTED + if counts[key] + ] + return MaintenanceDraft( + operation=OPERATION, + fingerprint=fingerprint, + targets=targets, + scope={"count": len(targets), "label": _scope_label(targets, total)}, + protected={"count": held, "reasons": reasons}, + media={"deletes_files": False, "label": MEDIA_LABEL}, + details=_details(results, targets), + ) + + +def _fill_categories(conn, fills): + """Set each blank category; raises PlanChanged when one is no longer blank.""" + cursor = conn.executemany( + "UPDATE prompts SET category = ? WHERE id = ? " + "AND COALESCE(TRIM(category), '') = ''", + fills, + ) + if cursor.rowcount != len(fills): + raise PlanChanged(OPERATION) + + +def _fill_tags(conn, fills, expected): + """Copy each source pair's tags onto its untagged prompt.""" + cursor = conn.executemany( + "INSERT INTO prompt_tags(prompt_id, tag_id) " + "SELECT ?, ppt.tag_id FROM prompt_pair_tags ppt " + "JOIN tags t ON t.id = ppt.tag_id WHERE ppt.pair_id = ? " + "AND NOT EXISTS (SELECT 1 FROM prompt_tags WHERE prompt_id = ?)", + fills, + ) + if cursor.rowcount != expected: + raise PlanChanged(OPERATION) + + +def apply_pair_metadata(conn, fingerprint, targets): + """Apply the reviewed fills on ``conn`` (inside BEGIN IMMEDIATE). + + Returns ``{changed, categories, tag_sets, rows, kept}``; ``rows`` lists + every filled prompt (``{prompt_id, category?, tags?}``) and ``kept`` every + prompt the plan keeps (``{prompt_id, reasons}``, in the review's wording + and order), both never capped. + """ + results, current = _state(conn) + if current != fingerprint or _targets(results) != targets: + raise PlanChanged(OPERATION) + by_id = {result["id"]: result for result in results} + categories, tags, rows = [], [], [] + tag_count = 0 + for prompt_id, fill_category, fill_tags in targets: + result = by_id[prompt_id] + row = {"prompt_id": prompt_id} + if fill_category: + row["category"] = result["category"][1] + categories.append((row["category"], prompt_id)) + if fill_tags: + row["tags"] = sorted(name for _id, name in result["tags"][1]) + tags.append((prompt_id, result["tag_pair"], prompt_id)) + tag_count += len(result["tags"][1]) + rows.append(row) + if categories: + _fill_categories(conn, categories) + if tags: + _fill_tags(conn, tags, tag_count) + return { + "changed": len(rows), + "categories": len(categories), + "tag_sets": len(tags), + "rows": rows, + "kept": _kept(results), + } diff --git a/py/api/autotag_routes.py b/py/api/autotag_routes.py index bcd46f1d..ee676e87 100644 --- a/py/api/autotag_routes.py +++ b/py/api/autotag_routes.py @@ -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( diff --git a/py/api/jobs.py b/py/api/jobs.py index 7196d43b..802f1ac4 100644 --- a/py/api/jobs.py +++ b/py/api/jobs.py @@ -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())) diff --git a/py/api/maintenance.py b/py/api/maintenance.py index d93a8973..0320e167 100644 --- a/py/api/maintenance.py +++ b/py/api/maintenance.py @@ -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 diff --git a/py/api/settings_routes.py b/py/api/settings_routes.py index d3d9fb93..4cccf0ae 100644 --- a/py/api/settings_routes.py +++ b/py/api/settings_routes.py @@ -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 = [] diff --git a/py/config.py b/py/config.py index ab7c1071..4af38a5a 100644 --- a/py/config.py +++ b/py/config.py @@ -30,6 +30,7 @@ routes = server_instance.routes # Extension info extension_uri = None # Will be set in __init__.py +import hashlib # noqa: E402 import os # noqa: E402 from typing import Any, Dict, List, Optional, Tuple # noqa: E402 @@ -590,6 +591,12 @@ try: validate_skipped_tags, validate_template, validate_threshold, + BUILTIN_INSTRUCTIONS_ID, + INSTRUCTION_ENGINES, + MAX_SAVED_INSTRUCTIONS, + builtin_instructions, + validate_default_instructions, + validate_instructions, ) except ImportError: from py.tagging_defaults import ( # noqa: F401 - re-exported @@ -604,6 +611,12 @@ except ImportError: validate_skipped_tags, validate_template, validate_threshold, + BUILTIN_INSTRUCTIONS_ID, + INSTRUCTION_ENGINES, + MAX_SAVED_INSTRUCTIONS, + builtin_instructions, + validate_default_instructions, + validate_instructions, ) @@ -623,13 +636,24 @@ def _valid_skipped_tags(value: Any) -> Tuple[str, ...]: return tuple(validate_skipped_tags(value)) +def _reason(error: ValueError) -> str: + """A settings sentence as a load warning's reason ("entry 2 is empty").""" + text = str(error).rstrip(".") + for prefix in ("Skipped tags", "Saved instructions"): + if text.startswith(prefix): + return text.removeprefix(prefix).lstrip(": ") + return text + + +def _text_id(text: str) -> str: + """A migrated entry's id: deterministic, so every load gives the same.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12] + + def _fallback_warning(key: str, error: ValueError, default: Any) -> str: """The one load warning of a saved tagging default that fails its check.""" - if key == "template": - return f"autotag.defaults.template {error}; using the built-in one" if key == "skipped_tags": - reason = str(error).removeprefix("Skipped tags").lstrip(": ").rstrip(".") - return f"autotag.defaults.skipped_tags {reason}; using no skipped tags" + return f"autotag.defaults.skipped_tags {_reason(error)}; using no skipped tags" return f"autotag.defaults.{key} {error}; using {default!r}" @@ -653,9 +677,18 @@ class AutotagConfig: EXISTING = "skip" GENERAL_THRESHOLD = 0.35 CHARACTER_THRESHOLD = 0.85 - TEMPLATE = DEFAULT_TAG_TEMPLATE SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT SKIPPED_TAGS: Tuple[str, ...] = () + # #431: the saved instructions (Built-in is never stored) and the default + # of each language-model engine, by id. + INSTRUCTIONS: Tuple[Dict[str, str], ...] = () + DEFAULT_INSTRUCTIONS: Dict[str, str] = { + "gguf": BUILTIN_INSTRUCTIONS_ID, + "hf": BUILTIN_INSTRUCTIONS_ID, + } + # The old single template as read at load (None when absent or invalid); + # only the migration reads it. Saves keep it as the GGUF default's mirror. + _LEGACY_TEMPLATE: Optional[str] = None # config.json key -> (attribute, validator, default) _KEYS = { @@ -671,7 +704,6 @@ class AutotagConfig: lambda v: validate_saved_threshold("value", v), 0.85, ), - "template": ("TEMPLATE", validate_template, DEFAULT_TAG_TEMPLATE), "suggestion_limit": ( "SUGGESTION_LIMIT", _valid_suggestion_limit, @@ -691,18 +723,142 @@ class AutotagConfig: type(value) is int and 1 <= value <= MAX_SUGGESTION_LIMIT ) # bool, float and str are rejected + @classmethod + def all_instructions(cls) -> List[Dict[str, str]]: + """Built-in first, then the saved entries (copies).""" + return [builtin_instructions()] + [dict(item) for item in cls.INSTRUCTIONS] + + @classmethod + def default_instructions_text(cls, engine: str) -> str: + """The text of ``engine``'s (``gguf`` or ``hf``) default instructions.""" + if engine not in INSTRUCTION_ENGINES: + raise ValueError(f"{engine} takes no instructions") + wanted = cls.DEFAULT_INSTRUCTIONS.get(engine, BUILTIN_INSTRUCTIONS_ID) + for item in cls.INSTRUCTIONS: + if item["id"] == wanted: + return item["text"] + return DEFAULT_TAG_TEMPLATE + + @classmethod + def mirror_template(cls) -> Optional[str]: + """What an older build reads as its one template (#431 B1.1): the + GGUF default's text, or None (no key) when that default is Built-in.""" + if cls.DEFAULT_INSTRUCTIONS.get("gguf") == BUILTIN_INSTRUCTIONS_ID: + return None + return cls.default_instructions_text("gguf") + @classmethod def get_config(cls) -> Dict[str, Any]: - """The saved defaults; the template only when it is not the built-in - one, so a later built-in template still reaches who never set one.""" + """The saved defaults; ``template`` only as the GGUF default's mirror.""" defaults = { key: getattr(cls, attr) for key, (attr, _v, _d) in cls._KEYS.items() } - if defaults["template"] == DEFAULT_TAG_TEMPLATE: - del defaults["template"] defaults["skipped_tags"] = list(defaults["skipped_tags"]) + defaults["instructions"] = [dict(item) for item in cls.INSTRUCTIONS] + defaults["default_instructions"] = dict(cls.DEFAULT_INSTRUCTIONS) + mirror = cls.mirror_template() + if mirror is not None: + defaults["template"] = mirror return {"defaults": defaults} + @classmethod + def _load_instructions(cls, defaults: Dict[str, Any]) -> None: + """Each instruction key alone, then the cross-key default check, then + the in-memory migration of the old template (#431). Nothing is + written here, so the revision never changes at load.""" + cls._LEGACY_TEMPLATE = None + if "template" in defaults: + try: + cls._LEGACY_TEMPLATE = validate_template(defaults["template"]) + except ValueError as error: + config_logger.warning( + f"autotag.defaults.template {error}; using the built-in one" + ) + entries: List[Dict[str, str]] = [] + damaged = False + if "instructions" in defaults: + try: + entries = validate_instructions(defaults["instructions"]) + except ValueError as error: + damaged = True + config_logger.warning( + f"autotag.defaults.instructions {_reason(error)}; " + "using Built-in only" + ) + chosen = {engine: BUILTIN_INSTRUCTIONS_ID for engine in INSTRUCTION_ENGINES} + stored = defaults.get("default_instructions") + if damaged: + # Both defaults fall back with the list (its one warning says so); + # the old template is migrated as on a first upgrade. + pass + elif "default_instructions" in defaults and ( + not isinstance(stored, dict) or set(stored) != set(INSTRUCTION_ENGINES) + ): + config_logger.warning( + "autotag.defaults.default_instructions must name gguf and hf; " + "using Built-in" + ) + elif isinstance(stored, dict): + ids = {item["id"] for item in entries} + for engine in INSTRUCTION_ENGINES: + value = stored[engine] + if isinstance(value, str) and ( + value == BUILTIN_INSTRUCTIONS_ID or value in ids + ): + chosen[engine] = value + else: + config_logger.warning( + f"autotag.defaults.default_instructions.{engine} names no " + "saved instructions; using Built-in" + ) + cls._migrate_template( + entries, + chosen, + first=damaged or "instructions" not in defaults, + keep_defaults=not damaged and "default_instructions" in defaults, + ) + cls.INSTRUCTIONS = tuple(entries) + cls.DEFAULT_INSTRUCTIONS = chosen + + @classmethod + def _migrate_template(cls, entries, chosen, *, first, keep_defaults) -> None: + """Bring a template saved by an older build in as a named entry.""" + legacy = cls._LEGACY_TEMPLATE + if legacy is None or legacy == DEFAULT_TAG_TEMPLATE: + return + if first: + # The first upgrade: it becomes "Custom template", the default for + # both engines unless defaults are stored already (20A). + entries.append( + {"id": _text_id(legacy), "name": "Custom template", "text": legacy} + ) + if not keep_defaults: + for engine in INSTRUCTION_ENGINES: + chosen[engine] = entries[-1]["id"] + return + if any(item["text"] == legacy for item in entries): + return # the mirror of a saved default + # Edited on an older build after an upgrade (B1.2): import it. + if len(entries) >= MAX_SAVED_INSTRUCTIONS: + config_logger.warning( + "The tagging template edited on an older version was not imported: " + f"the saved instructions are full ({MAX_SAVED_INSTRUCTIONS})" + ) + return + names = {item["name"].casefold() for item in entries} + number = 2 + while f"custom template ({number})" in names: + number += 1 + name = f"Custom template ({number})" + ids = {item["id"] for item in entries} + new_id, n = _text_id(legacy), 2 + while new_id in ids: + new_id, n = _text_id(f"{legacy}\n{n}"), n + 1 + entries.append({"id": new_id, "name": name, "text": legacy}) + config_logger.info( + "Imported the tagging template edited on an older version as " f"'{name}'" + ) + @classmethod def update_config(cls, new_config: Any) -> None: if not isinstance(new_config, dict): @@ -720,6 +876,7 @@ class AutotagConfig: except ValueError as error: config_logger.warning(_fallback_warning(key, error, default)) setattr(cls, attr, default) + cls._load_instructions(defaults) class PromptManagerConfig: diff --git a/py/tagging_defaults.py b/py/tagging_defaults.py index b1181372..9d00cd96 100644 --- a/py/tagging_defaults.py +++ b/py/tagging_defaults.py @@ -226,3 +226,97 @@ def wd14_label_names(path) -> Tuple[str, ...]: with _label_cache_lock: _label_cache[key] = (stamp, names) return names + + +# Saved instructions (#431): named language-model instructions with a default +# per engine. Built-in is never stored; readers put it first. +BUILTIN_INSTRUCTIONS_ID = "builtin" +BUILTIN_INSTRUCTIONS_NAME = "Built-in" +MAX_SAVED_INSTRUCTIONS = 20 +MAX_INSTRUCTION_NAME_CHARS = 60 +INSTRUCTION_ENGINES = ("gguf", "hf") +ENGINE_LABELS = {"gguf": "GGUF JoyCaption", "hf": "HuggingFace JoyCaption"} +_INSTRUCTION_ID = re.compile(r"[0-9a-f]{12}") +_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f

]") + + +def builtin_instructions() -> Dict[str, str]: + """The read-only Built-in entry, always the shipped template.""" + return { + "id": BUILTIN_INSTRUCTIONS_ID, + "name": BUILTIN_INSTRUCTIONS_NAME, + "text": DEFAULT_TAG_TEMPLATE, + } + + +def _instruction_entry(raw: Any, number: int) -> Dict[str, str]: + prefix = f"Saved instructions: entry {number}" + if ( + not isinstance(raw, dict) + or set(raw) != {"id", "name", "text"} + or not isinstance(raw["id"], str) + or _INSTRUCTION_ID.fullmatch(raw["id"]) is None + ): + raise ValueError(f"{prefix} has an invalid id.") + name = raw["name"].strip() if isinstance(raw["name"], str) else "" + if not name: + raise ValueError(f"{prefix} needs a name.") + if len(name) > MAX_INSTRUCTION_NAME_CHARS: + raise ValueError( + f"{prefix} name must be at most {MAX_INSTRUCTION_NAME_CHARS} characters." + ) + if _CONTROL.search(name): + raise ValueError(f"{prefix} name must not contain a line break.") + if name.casefold() == BUILTIN_INSTRUCTIONS_NAME.casefold(): + raise ValueError("Saved instructions: the name “Built-in” is reserved.") + try: + text = validate_template(raw["text"], f"{prefix} text") + except ValueError as error: + raise ValueError(f"{error}.") from None + return {"id": raw["id"], "name": name, "text": text} + + +def validate_instructions(value: Any) -> List[Dict[str, str]]: + """The saved entries ``[{id, name, text}]`` in order (names trimmed), or + ValueError with the sentence System shows (Built-in is not counted).""" + if not isinstance(value, list): + raise ValueError("Saved instructions must be a list.") + if len(value) > MAX_SAVED_INSTRUCTIONS: + raise ValueError( + f"Saved instructions: at most {MAX_SAVED_INSTRUCTIONS} entries besides " + "Built-in." + ) + entries: List[Dict[str, str]] = [] + ids: Dict[str, int] = {} + names: Dict[str, int] = {} + for number, raw in enumerate(value, 1): + item = _instruction_entry(raw, number) + if item["id"] in ids: + raise ValueError( + f"Saved instructions: entry {number} repeats the id of entry " + f"{ids[item['id']]}." + ) + key = item["name"].casefold() + if key in names: + raise ValueError( + f"Saved instructions: entry {number} name “{item['name']}” is " + "already used." + ) + ids[item["id"]] = number + names[key] = number + entries.append(item) + return entries + + +def validate_default_instructions(value: Any, entries) -> Dict[str, str]: + """``{gguf: id, hf: id}``, each Built-in or an id of ``entries``.""" + if not isinstance(value, dict) or set(value) != set(INSTRUCTION_ENGINES): + raise ValueError("Default instructions must name GGUF and HuggingFace.") + ids = {BUILTIN_INSTRUCTIONS_ID} | {item["id"] for item in entries} + for engine in INSTRUCTION_ENGINES: + if not isinstance(value[engine], str) or value[engine] not in ids: + raise ValueError( + f"Default for {ENGINE_LABELS[engine]} must be one of the saved " + "instructions." + ) + return {engine: value[engine] for engine in INSTRUCTION_ENGINES} diff --git a/tests/browser/system-maintenance-concept.spec.mjs b/tests/browser/system-maintenance-concept.spec.mjs index cf2007dd..2d3ee03f 100644 --- a/tests/browser/system-maintenance-concept.spec.mjs +++ b/tests/browser/system-maintenance-concept.spec.mjs @@ -64,11 +64,31 @@ function measureMaintenance(mainSelector) { }; } -/** D102: the Metadata cache row says why its review is unavailable and its button is aria-disabled (D93's dimmed style). */ -function mapMaintenanceConcept(reference) { +/** + * D192 (#550, beyond the concept): Database adds "Pair category and tags" after "Unused prompt components", shaped like + * the concept's plain Review plan row (Optimize database's). + */ +function mapPairRow(reference) { return { ...reference, - surfaces: reference.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => row.title !== 'Metadata cache' ? row : { + surfaces: reference.surfaces.map(surface => { + const plain = surface.rows.find(row => row.title === 'Optimize database'); + const at = surface.rows.findIndex(row => row.title === 'Unused prompt components'); + if (!plain || at < 0) return surface; + return { ...surface, tree: [...surface.tree, 'div.operation'], rows: [...surface.rows.slice(0, at + 1), + { ...plain, title: 'Pair category and tags', description: 'Fill an empty category or tags on prompts older ' + + 'Pair node runs saved, from their saved pairs. Only empty fields of never-edited prompts.' }, + ...surface.rows.slice(at + 1)] }; + }), + }; +} + +/** D102: the Metadata cache row says why its review is unavailable and its button is aria-disabled (D93's dimmed style). */ +function mapMaintenanceConcept(reference) { + const mapped = mapPairRow(reference); + return { + ...mapped, + surfaces: mapped.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => row.title !== 'Metadata cache' ? row : { ...row, description: `${row.description} Nothing is cached in this browser.`, actions: row.actions.map(action => ({ ...action, opacity: '0.55', cursor: 'not-allowed' })) }) })), }; @@ -209,8 +229,8 @@ test('maintenance comparison detects mutations', async ({ page, request }) => { const expected = mapMaintenanceConcept(concept); const production = await productionMaintenance(page, 'dark'); expect(diffInventory(expected, production)).toEqual([]); - // Unmapped, the section differs only on the D102 row. - expect(diffInventory(concept, production).map(item => item.path).sort()).toEqual([ + // Mapped only for the D192 row, the section differs only on the D102 row. + expect(diffInventory(mapPairRow(concept), production).map(item => item.path).sort()).toEqual([ 'surfaces.1.rows.3.actions.0.cursor', 'surfaces.1.rows.3.actions.0.opacity', 'surfaces.1.rows.3.description']); const measure = () => page.evaluate(measureMaintenance, '#workspace-main'); const paths = async () => diffInventory(expected, await measure()).map(item => item.path); diff --git a/tests/browser/system-tagging-concept.spec.mjs b/tests/browser/system-tagging-concept.spec.mjs index 865d8b14..9162e8bd 100644 --- a/tests/browser/system-tagging-concept.spec.mjs +++ b/tests/browser/system-tagging-concept.spec.mjs @@ -35,6 +35,8 @@ const MODEL_OPTIONS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF Jo const SUGGESTIONS = 'From 1 to 100: the most suggestions Generate shows for one image.'; const SKIPPED_DESCRIPTION = 'Suggested tags that contain one of these words are hidden. Words are separated by spaces, _ or -; ' + 'other characters such as ( ) belong to the word.'; +const DEFAULT_DESCRIPTION = 'The Tag image dialog and batches start from this.'; +const INSTRUCTIONS_DESCRIPTION = 'Instructions for the language-model engines. Built-in can’t be changed; duplicate it to make your own.'; const BATCH = 'Tag each output image from its own pixels with the model above. Prompts are not changed. Review the scope first.'; /** Runs in the page: Auto-tagging's surface under `mainSelector`: rows, field, actions, operation, geometry and tokens. */ @@ -64,7 +66,7 @@ function measureTagging(mainSelector) { text: [...row.firstElementChild.children].map(node => [name(node), text(node), node.localName === 'label' ? node.htmlFor : null]), control: control(row), flexWrap: getComputedStyle(row).flexWrap, controlBelow: row.lastElementChild.getBoundingClientRect().top >= row.firstElementChild.getBoundingClientRect().bottom - 0.5 })), - field: { tree: [...field.children].map(name), label: [text(field.querySelector('label')), field.querySelector('label').htmlFor], + field: field && { tree: [...field.children].map(name), label: [text(field.querySelector('label')), field.querySelector('label').htmlFor], textarea: { id: field.querySelector('textarea').id, value: field.querySelector('textarea').value, ...css(field.querySelector('textarea'), 'fontSize', 'minHeight', 'backgroundColor', 'color', 'borderTopColor') }, ...css(field, 'display', 'rowGap', 'fontSize', 'marginTop', 'marginBottom') }, @@ -81,7 +83,22 @@ function measureTagging(mainSelector) { label: css(block.querySelector('label'), 'fontSize', 'fontWeight', 'color'), description: css(block.querySelector('.setting-block-text p'), 'fontSize', 'color', 'marginTop'), input: css(block.querySelector('input'), 'fontSize', 'backgroundColor', 'color', 'borderTopColor') })( - surface.querySelector(':scope > .setting-block')), + surface.querySelector(':scope > .setting-block[data-setting="tag-skipped"]')), + // D203 (#431): Saved instructions (the concept has a template field instead). + instructions: (block => block && { tree: [...block.children].map(name), key: block.dataset.key, + text: [...block.firstElementChild.children].map(node => [node.localName, text(node)]), + list: [...block.querySelectorAll('[role="option"]')].map(option => [text(option.firstElementChild), + [...option.querySelectorAll('.badges > .chip')].map(chip => [text(chip), chip.className]), option.getAttribute('aria-selected')]), + listRole: block.querySelector('.instruction-list').getAttribute('role'), + actions: [...block.querySelectorAll('.instruction-actions > button')].map(item => [text(item), item.className, item.disabled]), + editor: [...block.querySelectorAll('.instruction-editor .field')].map(item => [text(item.querySelector('label > span')), + item.querySelector('input, textarea').value, item.querySelector('input, textarea').readOnly]), + note: text(block.querySelector('#instruction-note')), + layout: getComputedStyle(block.querySelector('.instructions-layout')).gridTemplateColumns.split(' ').length, + ...css(block, 'paddingTop', 'paddingBottom', 'borderTopWidth', 'borderTopColor'), + label: css(block.querySelector('label'), 'fontSize', 'fontWeight', 'color'), + textarea: css(block.querySelector('textarea'), 'fontSize', 'backgroundColor', 'borderTopColor') })( + surface.querySelector(':scope > .setting-block[data-setting="tag-instructions"]')), operations: [...surface.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')), description: text(row.querySelector('p')), actions: [...row.querySelectorAll('.row-actions > button')].map(item => [text(item), item.className]) })), @@ -115,14 +132,21 @@ function measureTagging(mainSelector) { * The concept's Auto-tagging with the recorded deviations applied, for the fixture (all engines on disk, none loaded). * `phone` is a viewport at the concept's 700px breakpoint or below. */ -function mapTaggingConcept(reference, { template, phone }) { +function mapTaggingConcept(reference, { builtin, saved, phone }) { const [model, existing, general, character] = reference.rows; const [preview] = reference.actions.buttons; + // D203: Built-in first, then the saved entries, in each default select. + const names = ['Built-in', ...saved.map(entry => entry.name)]; + const defaultRow = (engine, long) => ({ ...existing, + text: [['label', `Default for ${long}`, `instruction-default-${engine}`], ['p', DEFAULT_DESCRIPTION, null]], + control: { ...existing.control, id: `instruction-default-${engine}`, options: names.map(label => [label, false]), + selected: names[1] } }); return { ...reference, - // D133 adds a row after the thresholds, D202 the Skipped tags block after it; D134 adds the operation after the - // actions. - tree: [...reference.tree.slice(0, 6), 'div.setting-row', 'div.setting-block', ...reference.tree.slice(6), 'div.operation'], + // D133 adds a row after the thresholds, D202 the Skipped tags block after it; D203 replaces the template field + // with Saved instructions, the two default rows and their note; D134 adds the operation after the actions. + tree: [...reference.tree.slice(0, 6), 'div.setting-row', 'div.setting-block', 'div.setting-block', 'div.setting-row', + 'div.setting-row', 'p.instruction-reset.small', reference.tree[7], 'div.operation'], rows: [ // D132 (OQ5, OQ6): the four real engines with their download size, and the chosen one's live status. { ...model, text: [model.text[0], ['p', 'Downloaded. Not loaded.', null]], @@ -133,7 +157,26 @@ function mapTaggingConcept(reference, { template, phone }) { // D133: the persisted suggestion count, on the concept's number row. { ...general, text: [['label', 'Suggestions per image', 'tag-suggestions'], ['p', SUGGESTIONS, null]], control: { ...general.control, id: 'tag-suggestions', value: '10' } }, + // D203: the default of each language-model engine (the fixture's saved entry is both), on the concept's + // select row. + defaultRow('gguf', 'GGUF JoyCaption'), defaultRow('hf', 'HuggingFace JoyCaption'), ], + // D203: Saved instructions in the concept's setting-row tokens (as D202), Built-in selected and read-only. + instructions: { tree: ['div.setting-block-text', 'div.instructions-layout'], key: 'autotag_instructions', + text: [['label', 'Saved instructions'], ['p', `${INSTRUCTIONS_DESCRIPTION} ${saved.length} of 20 saved · Built\u2011in is not counted.`]], + list: [['Built-in', [['Read-only', 'chip']], 'true'], + ...saved.map((entry, index) => [entry.name, index === 0 + ? [['Default for GGUF', 'chip blue'], ['Default for HuggingFace', 'chip blue']] : [], 'false'])], + listRole: 'listbox', + actions: [['+ New', '', false], ['Duplicate', '', false], ['Delete', 'danger', true]], + editor: [['Name', 'Built-in', true], ['Text', builtin, true]], + note: 'Built-in instructions can’t be changed. Duplicate them to make your own.', + layout: phone ? 1 : 2, + paddingTop: reference.row.paddingTop, paddingBottom: reference.row.paddingBottom, + borderTopWidth: reference.row.borderTopWidth, borderTopColor: reference.row.borderTopColor, + label: reference.label, + textarea: { fontSize: reference.field.textarea.fontSize, backgroundColor: reference.field.textarea.backgroundColor, + borderTopColor: reference.field.textarea.borderTopColor } }, // D202 (#431): the Skipped tags block in the concept's setting-row tokens: its label and description as a row's, // the row's padding and rule above, the concept's field input; an empty list says so. skipped: { tree: ['div.setting-block-text', 'div.skip-add', 'p.skip-problem.small', 'div.skip-chips.tags', 'p.skip-preview.small'], @@ -148,15 +191,15 @@ function mapTaggingConcept(reference, { template, phone }) { marginTop: reference.description.marginTop }, input: { fontSize: '12px', backgroundColor: reference.field.textarea.backgroundColor, color: reference.field.textarea.color, borderTopColor: reference.field.textarea.borderTopColor } }, - // The saved template (the server's default until one is saved) replaces the concept's sample text. - field: { ...reference.field, textarea: { ...reference.field.textarea, value: template } }, + // D203: no template field. + field: null, // D132: Download (for an engine already on disk, "Download again") and Unload replace the two simulated jobs. actions: { ...reference.actions, buttons: [preview, ['Download again', ''], ['Unload', '']] }, // D134: the prompt-tag batch, reviewed first. operations: [{ tree: ['div', 'div.row-actions'], title: 'Tag output images', description: BATCH, actions: [['Review', '']] }], // D132 (#398): every select shows its whole chosen option at every width (at 390 the concept's 120px cap cuts // them; production lets each grow past it). - selectsFit: reference.selectsFit.map(() => true), + selectsFit: [...reference.selectsFit, true, true].map(() => true), }; } @@ -180,7 +223,9 @@ async function productionTagging(page, theme) { async function savedTemplate(request, page) { const response = await request.get('/prompt_manager/settings'); await expect(response).toBeOK(); - return { template: (await response.json()).settings.autotag_template, phone: page.viewportSize().width <= 700 }; + const { settings } = await response.json(); + return { builtin: settings.autotag_builtin_instructions, saved: settings.autotag_instructions, + phone: page.viewportSize().width <= 700 }; } for (const theme of THEMES) for (const [width, height] of SIZES) { @@ -204,14 +249,15 @@ for (const theme of THEMES) for (const width of [390, 320]) { await page.locator('#workspace-main #tag-existing').selectOption('replace_after_review'); await page.locator('#workspace-main section.surface').screenshot({ path: testInfo.outputPath(`production-tagging-selects-${theme}-${width}.png`) }); const selects = page.locator('#workspace-main section.surface > .setting-row > select'); - await expect(selects).toHaveCount(2); - for (let index = 0; index < 2; index += 1) { + // D203 adds the two per-engine default selects. + await expect(selects).toHaveCount(4); + for (let index = 0; index < 4; index += 1) { const select = selects.nth(index); const values = await select.evaluate(node => [...node.options].map(option => option.value)); for (const value of values) { await select.selectOption(value); const measured = await page.evaluate(measureTagging, '#workspace-main'); - expect(measured.selectsFit, `${await select.getAttribute('id')}=${value}`).toEqual([true, true]); + expect(measured.selectsFit, `${await select.getAttribute('id')}=${value}`).toEqual([true, true, true, true]); expect(measured.pageScroll, value).toBe(false); } } @@ -225,15 +271,16 @@ test('auto-tagging comparison detects mutations', async ({ page, request }) => { const expected = mapTaggingConcept(concept, saved); const production = await productionTagging(page, 'dark'); expect(diffInventory(expected, production)).toEqual([]); - // Unmapped, the section differs only where D132, D133, D134 and D202 are recorded (and the saved template's text). + // Unmapped, the section differs only where D132, D133, D134, D202 and D203 are recorded (and the saved template's text). // (The concept has no Skipped tags block, so every path under it is one D202 difference.) - expect(diffInventory(concept, production).map(item => (item.path.startsWith('skipped') ? 'skipped' + expect(diffInventory(concept, production).map(item => (/^(skipped|instructions|field)\b/.test(item.path) ? item.path.split('.')[0] : item.path.split('.').slice(0, 3).join('.'))) .filter((path, index, all) => all.indexOf(path) === index).sort()).toEqual([ - 'actions.buttons.1', 'actions.buttons.2', 'field.textarea.value', 'operations.0.actions', 'operations.0.description', - 'operations.0.title', 'operations.0.tree', 'operations.length', 'rows.0.control', 'rows.0.text', 'rows.4.control', 'rows.4.controlBelow', - 'rows.4.flexWrap', 'rows.4.text', 'rows.4.tree', 'rows.length', 'skipped', 'tree.6', 'tree.7', 'tree.8', 'tree.9', 'tree.10', - 'tree.length'].sort()); + 'actions.buttons.1', 'actions.buttons.2', 'field', 'instructions', 'operations.0.actions', 'operations.0.description', + 'operations.0.title', 'operations.0.tree', 'operations.length', 'rows.0.control', 'rows.0.text', + ...[4, 5, 6].flatMap(row => ['control', 'controlBelow', 'flexWrap', 'text', 'tree'].map(key => `rows.${row}.${key}`)), + 'rows.length', 'selectsFit.2', 'selectsFit.3', 'selectsFit.length', 'skipped', + ...Array.from({ length: 8 }, (_, index) => `tree.${index + 6}`), 'tree.length'].sort()); const measure = () => page.evaluate(measureTagging, '#workspace-main'); const paths = async () => diffInventory(expected, await measure()).map(item => item.path); // A removed row. @@ -284,8 +331,8 @@ test('auto-tagging at 390 wraps only the engine row', async ({ page, request }) const concept = await conceptTagging(page, 'dark'); const production = await productionTagging(page, 'dark'); expect(diffInventory(mapTaggingConcept(concept, await savedTemplate(request, page)), production)).toEqual([]); - expect(production.rows.map(row => row.flexWrap)).toEqual(['wrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap']); - expect(production.selectsFit).toEqual([true, true]); + expect(production.rows.map(row => row.flexWrap)).toEqual(['wrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap']); + expect(production.selectsFit).toEqual([true, true, true, true]); }); /** Runs in the page: the open review dialog: its title, body and the concept's tokens. */ diff --git a/tests/browser/tagging-canonical-comparison.mjs b/tests/browser/tagging-canonical-comparison.mjs index ef112625..ef297699 100644 --- a/tests/browser/tagging-canonical-comparison.mjs +++ b/tests/browser/tagging-canonical-comparison.mjs @@ -92,6 +92,8 @@ export function cardActionsInventory(card) { }; } +// #431: the fixture's saved "Describe the image" entry, the default of both engines (workspace_tagging_fixture.py). +export const FIXTURE_INSTRUCTIONS_ID='d35c71be0a1e'; // D84 (plan D61): the legacy engines' values, labels and sizes, GGUF JoyCaption in the concept's Ollama slot. const MODEL_LABELS=Object.freeze({'wd14-swinv2':['wd14-swinv2','WD14 SwinV2 · ~400 MB'],'wd14-vit':['wd14-vit','WD14 ViT · ~400 MB'], ollama:['gguf','GGUF JoyCaption (Recommended) · ~4.5 GB'],huggingface:['hf','HuggingFace JoyCaption · ~16 GB']}); @@ -106,17 +108,24 @@ const CONCEPT_LABELS=Object.freeze({'WD14 SwinV2 · fast classifier':'WD14 SwinV * (`chip`: a concept `.chip` inventory, the heading's size chip) around the row's own text (12px) in the checked * row's colours (accent border, `--soft-blue`); * - D84 (plan D61): the legacy engine labels and values, and the server's default instructions (`instructions`); + * - D204 (#431): a "Saved instructions" select (`picker`: its value and options) before the instructions, on the + * concept's select field tokens; * - D91 (plan D68): a Suggestions count field after the model's own fields, on the concept's number field tokens * (`numberField`: the concept's General confidence field) with the configured count (`limit`); * - D29: the prototype's "Sample suggestions from …" status reads "Suggestions from …". * The opened state is compared unmapped: both show the concept's on-screen "Generate suggestions with the selected * model before approving." (its renderTagModelOptions, a1062–1076, replaces the markup's sentence at once). */ -export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField}) { +export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField, + picker={value:FIXTURE_INSTRUCTIONS_ID,options:[['builtin','Built-in'],[FIXTURE_INSTRUCTIONS_ID,`${instructions} · default`]]}}) { const [select,...own]=reference.modelPanel.fields; + // D204 (#431): a "Saved instructions" select on the concept's select field, before the instructions. + const pickField={...select,label:'Saved instructions',control:{...select.control,id:'image-tag-instructions-pick', + value:picker.value,options:picker.options}}; const suggestionsField={...numberField,label:'Suggestions',control:{...numberField.control,id:'image-suggestion-limit', value:String(limit),min:'1',max:'100',step:'1'}}; - const mappedOwn=own.map(field=>field.control.id==='image-tag-instructions'?{...field,control:{...field.control,value:instructions}}:field); + const mappedOwn=own.flatMap(field=>field.control.id==='image-tag-instructions' + ?[pickField,{...field,control:{...field.control,value:instructions}}]:[field]); const rows=reference.suggestions.rows.map(row=>({...row, control:row.control?.kind==='checkbox'&&row.control.checked?{kind:'remove',label:`Remove ${row.name}`}:row.control, borderTopLeftRadius:chip.borderTopLeftRadius})); diff --git a/tests/browser/workspace-system-maintenance.spec.mjs b/tests/browser/workspace-system-maintenance.spec.mjs index 0b3a4a96..9b7c3f66 100644 --- a/tests/browser/workspace-system-maintenance.spec.mjs +++ b/tests/browser/workspace-system-maintenance.spec.mjs @@ -64,7 +64,9 @@ test('the section shows the concept rows; Metadata cache follows Q5 (D102)', asy item.querySelector('.row-actions > button').textContent, item.querySelector('.row-actions > button').className]), ]))).toEqual([ ['Database', [['Integrity & relationship checks', 'Check', ''], ['Duplicate prompt text', 'Review plan', ''], - ['Optimize database', 'Review plan', ''], ['Unused prompt components', 'Review plan', 'danger']]], + ['Optimize database', 'Review plan', ''], ['Unused prompt components', 'Review plan', 'danger'], + // D192 (#550): beyond the concept. + ['Pair category and tags', 'Review plan', '']]], ['Images & cache', [['Missing-file links', 'Review plan', 'danger'], ['Duplicate image files', 'Find duplicates', ''], ['Thumbnail cache', 'Review options', ''], ['Metadata cache', 'Review plan', '']]], ]); @@ -177,6 +179,70 @@ test("plan dialog shows the server's scope, protected, backup policy and media l expect(await serverJson(request, '/__test__/system/digest')).toEqual(before); }); +test('D192: Pair category and tags reviews old Pair prompts, keeps an edited one and fills the other (#550)', + async ({ page, request }) => { + await expect(await request.post('/__test__/system/pair-metadata')).toBeOK(); + const answers = []; + page.on('response', response => { if (response.url().endsWith(PREVIEW_URL)) answers.push(response.json()); }); + await openMaintenance(page); + const pairRow = row(page, 'Pair category and tags'); + await expect(pairRow.locator('p')).toHaveText('Fill an empty category or tags on prompts older Pair node runs saved, ' + + 'from their saved pairs. Only empty fields of never-edited prompts.'); + await pairRow.getByRole('button', { name: 'Review plan', exact: true }).click(); + const plan = dialog(page); + await expect(plan.locator('#modal-body > h3')).toHaveText('Pair category and tags'); + const answer = await answers[0]; + expect(answer.operation).toBe('pair_metadata'); + expect(await gridOf(page)).toEqual([ + ['Scope', '1 prompt to fill: 1 category, 1 tag set'], + ['Protected', '1 edited after it was saved'], + ['Backup', `Create and verify before changes (${answer.backup.destination_label})`], + ['Media on disk', 'No image file or saved pair is changed.'], + ]); + await expect(plan.locator('details > summary')).toHaveText('Listed records (3)'); + await plan.locator('details > summary').click(); + // Protected rows come first, the Record row last. + const lines = (await plan.locator('details > pre').textContent()).split('\n'); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe('S550 harbor · Kept: edited after it was saved; its empty category or tags may be intentional'); + expect(lines[1]).toMatch(/^S550 lantern · Fills category “night” and 2 tags: s550-glow, s550-warm \(pair \d+\)$/); + expect(lines[2]).toMatch(/^Record · The job's report lists every filled field\. Download it to keep it/); + await plan.getByRole('button', { name: 'Apply reviewed operation', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/); + const job = await ended(request, page.url().split('/').pop()); + expect([job.title, job.state, job.counters.changed, job.counters.protected]) + .toEqual(['Pair category and tags', 'finished', 1, 1]); + const main = page.locator('#workspace-main'); + await expect(main.locator('h1')).toHaveText('Pair category and tags'); + await expect(main.getByRole('button', { name: 'Download report', exact: true })).toBeVisible(); + // #550 live test: the Changed group lists the path-less record rows as plain text, never an open action. + const results = main.getByRole('region', { name: 'Results', exact: true }); + await results.locator('.outcome-list > button').filter({ hasText: 'Changed' }).click(); + const records = results.locator('.outcome-files > .file-row'); + await expect(records).toHaveCount(1); + await expect(records.first()).toHaveText(/^Prompt \d+: category “night”; tags s550-glow, s550-warm$/); + expect(await records.first().evaluate(node => node.localName)).toBe('div'); + await expect(results.locator('.outcome-files button')).toHaveCount(0); + await expect(results.getByRole('searchbox', { name: 'Filter results' })).toHaveAttribute('placeholder', 'Filter records…'); + // Protected lists each kept prompt with the review's reason (#550 live re-check). + await results.locator('.outcome-list > button').filter({ hasText: 'Protected' }).click(); + await expect(records).toHaveCount(1); + await expect(records.first()).toHaveText( + /^Prompt \d+: kept, edited after it was saved; its empty category or tags may be intentional$/); + // An empty group says records, not files. + await results.locator('.outcome-list > button').filter({ hasText: 'Errors' }).click(); + await expect(results.locator('.outcome-files > p.empty-state')).toHaveText('No records in this group.'); + const library = await serverJson(request, '/prompt_manager/library'); + const lantern = library.items.find(item => item.text === 'S550 lantern'); + const harbor = library.items.find(item => item.text === 'S550 harbor'); + const filled = (await serverJson(request, `/prompt_manager/library/${lantern.id}?archive=all`)).positive; + expect([filled.category, [...filled.tags].sort()]).toEqual(['night', ['s550-glow', 's550-warm']]); + expect((await serverJson(request, `/prompt_manager/library/${harbor.id}?archive=all`)).positive.category).toBeFalsy(); + await page.goto(`${workspace}#/library`); + await expect(page.locator(`article[data-prompt-id="${lantern.id}"] .card-title > .chip`)).toHaveText('night'); + }); + test('a plan with nothing to do shows only Close, never Apply', async ({ page, request }) => { const applies = []; page.on('request', item => { if (item.url().endsWith(APPLY_URL)) applies.push(item.url()); }); diff --git a/tests/browser/workspace-system-tagging.spec.mjs b/tests/browser/workspace-system-tagging.spec.mjs index eff83024..5205c222 100644 --- a/tests/browser/workspace-system-tagging.spec.mjs +++ b/tests/browser/workspace-system-tagging.spec.mjs @@ -112,13 +112,14 @@ test('model options are the four real engines with size and the description show await expect(status(page)).toHaveText('Unsaved changes.'); // The other rows keep the concept's copy and the saved values (D133 adds Suggestions per image). expect(await surface(page).locator(':scope > .setting-row label').allTextContents()).toEqual(['Tagging model', 'Existing tags', - 'General-tag confidence', 'Character-tag confidence', 'Suggestions per image']); + 'General-tag confidence', 'Character-tag confidence', 'Suggestions per image', 'Default for GGUF JoyCaption', + 'Default for HuggingFace JoyCaption']); await expect(page.locator('#tag-existing')).toHaveValue('skip'); await expect(page.locator('#threshold-general')).toHaveValue('0.35'); await expect(page.locator('#threshold-character')).toHaveValue('0.85'); await expect(page.locator('#tag-suggestions')).toHaveValue('10'); - const settings = await serverJson(request, SETTINGS_URL); - await expect(page.locator('#tag-template')).toHaveValue(settings.settings.autotag_template); + // D203 (#431): Saved instructions replace the template field. + await expect(page.locator('#tag-template')).toHaveCount(0); // Unload is offered only while a model is loaded (none is). await expect(actionOf(page, 'Unload')).toBeDisabled(); }); @@ -500,3 +501,74 @@ test('skipped tags preview checks library tags only without a label list, and a await expect(skippedBlock(page)).toHaveAttribute('aria-invalid', 'true'); await page.unrouteAll({ behavior: 'ignoreErrors' }); }); + +// D203 (#431): Saved instructions: Built-in read-only, New, edit, per-engine defaults, Delete resets a default, Save +// preferences and a reload; the server pairs the two keys. D205: the batch review names the engine's default. +const instructionOption = (page, name) => page.locator('#instruction-list [role="option"]').filter({ has: page.locator(`span:text-is("${name}")`) }); + +test('saved instructions: new, edit, per-engine default, delete, save and reload (D203)', async ({ page, request }) => { + await openTagging(page); + const settings = (await serverJson(request, SETTINGS_URL)).settings; + await expect(page.locator('#instructions-count')).toHaveText('1 of 20 saved · Built\u2011in is not counted.'); + await expect(page.locator('#instruction-list [role="option"] > span:first-child')).toHaveText(['Built-in', 'Describe the image']); + await expect(page.locator('#instruction-name')).toHaveValue('Built-in'); + await expect(page.locator('#instruction-text')).toHaveValue(settings.autotag_builtin_instructions); + await expect(page.locator('#instruction-text')).toHaveAttribute('readonly', ''); + await expect(page.locator('#instruction-delete')).toBeDisabled(); + await expect(page.locator('#instruction-default-gguf')).toHaveValue(settings.autotag_instructions[0].id); + // + New, then name and text it, and make it HuggingFace's default. + await page.locator('#instruction-new').click(); + await expect(page.locator('#instruction-name')).toHaveValue('New instructions'); + await expect(page.locator('#instruction-note')).toHaveText('The text must not be empty.'); + await page.locator('#instruction-name').fill('Photo realism'); + await page.locator('#instruction-text').fill('Tag camera angle and lighting.'); + await expect(page.locator('#instruction-text').locator('xpath=..').locator('label .muted')).toHaveText('30 of 4,000 characters'); + await page.locator('#instruction-default-hf').selectOption({ label: 'Photo realism' }); + await expect(instructionOption(page, 'Photo realism').locator('.chip')).toHaveText(['Default for HuggingFace']); + await expect(status(page)).toHaveText('Unsaved changes.'); + await save(page).click(); + await expect(status(page)).toHaveText('Saved to the server configuration.'); + let saved = (await serverJson(request, SETTINGS_URL)).settings; + expect(saved.autotag_instructions.map(entry => [entry.name, entry.text])).toEqual([['Describe the image', 'Describe the image'], + ['Photo realism', 'Tag camera angle and lighting.']]); + expect(saved.autotag_default_instructions.hf).toBe(saved.autotag_instructions[1].id); + // A reload shows the saved list; deleting GGUF's default resets it to Built-in and says so. + await openTagging(page); + await instructionOption(page, 'Describe the image').click(); + await page.locator('#instruction-delete').click(); + await expect(page.locator('#instruction-reset')).toHaveText('GGUF now uses Built-in.'); + await expect(page.locator('#instruction-default-gguf')).toHaveValue('builtin'); + await save(page).click(); + await expect(status(page)).toHaveText('Saved to the server configuration.'); + saved = (await serverJson(request, SETTINGS_URL)).settings; + expect(saved.autotag_instructions.map(entry => entry.name)).toEqual(['Photo realism']); + expect(saved.autotag_default_instructions.gguf).toBe('builtin'); + expect(saved.autotag_template).toBe(saved.autotag_builtin_instructions); +}); + +test('a refused instructions save names the entry and highlights the block (D203)', async ({ page }) => { + await openTagging(page); + await page.locator('#instruction-duplicate').click(); + await page.locator('#instruction-name').fill('describe the IMAGE'); + await expect(page.locator('#instruction-note')).toHaveText('The name “describe the IMAGE” is already used.'); + await save(page).click(); + await expect(status(page)).toHaveText('Saved instructions: entry 2 name “describe the IMAGE” is already used.'); + await expect(surface(page).locator('.setting-block[data-setting="tag-instructions"]')).toHaveAttribute('aria-invalid', 'true'); +}); + +test('the batch review names the language model\'s default instructions (D205)', async ({ page }) => { + await openTagging(page); + await page.locator('#tag-model').selectOption('gguf'); + await page.locator('#tag-existing').selectOption('merge'); + const answer = page.waitForResponse(item => item.url().endsWith(PREVIEW_URL)); + await reviewButton(page).click(); + await answer; + await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images'); + const grid = await gridOf(page); + expect(grid.slice(-3)).toEqual([['Instructions', 'Describe the image'], ['Settings', 'Up to 10 tags per image'], + grid.at(-1)]); + // The batch sends the default's text. + const start = page.waitForRequest(item => item.url().endsWith('/prompt_manager/jobs/autotag')); + await dialog(page).getByRole('button', { name: 'Start tagging', exact: true }).click(); + expect((await start).postDataJSON().options).toEqual({ instructions: 'Describe the image' }); +}); diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs index 14b8c9a5..4760e019 100644 --- a/tests/browser/workspace-tagging-bulk.spec.mjs +++ b/tests/browser/workspace-tagging-bulk.spec.mjs @@ -341,3 +341,21 @@ test('a running scan makes the start report busy', async ({ page, request }) => await request.post(`/__test__/jobs/${scan.job.id}/finish`, { data: { state: 'finished' } }); } }); + +// D204 (#431): Auto-tag selected has the same picker; the job sends the text the dialog shows. +test('Auto-tag selected picks saved instructions and sends the text shown (D204)', async ({ page }) => { + await select(page, FIRST); + await barButton(page, 'Auto-tag selected').click(); + await expect(title(page)).toHaveText('Auto-tag selected images'); + const body = modal(page).locator('#modal-body'); + await body.getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('gguf'); + const pick = body.getByRole('combobox', { name: 'Saved instructions', exact: true }); + await expect(pick).toHaveAttribute('id', 'bulk-tag-instructions-pick'); + await expect(pick.locator('option')).toHaveText(['Built-in', 'Describe the image · default']); + const text = body.getByRole('textbox', { name: 'Tagging instructions', exact: true }); + await text.fill('Bulk one-off.'); + await expect(pick.locator('option:checked')).toHaveText('Edited for this run'); + const started = jobStart(page); + await actionButtons(page).filter({ hasText: 'Start auto-tagging' }).click(); + expect((await started).postDataJSON().options.instructions).toBe('Bulk one-off.'); +}); diff --git a/tests/browser/workspace-tagging.spec.mjs b/tests/browser/workspace-tagging.spec.mjs index b3b2cef6..e9ad4a8b 100644 --- a/tests/browser/workspace-tagging.spec.mjs +++ b/tests/browser/workspace-tagging.spec.mjs @@ -4,7 +4,7 @@ import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; import { test, expect } from './fixtures.mjs'; import { saveImageTags } from './concept-inventory.mjs'; -import { taggingInventory } from './tagging-canonical-comparison.mjs'; +import { FIXTURE_INSTRUCTIONS_ID, taggingInventory } from './tagging-canonical-comparison.mjs'; const images = '/prompt_manager/workspace#/images'; const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs'; @@ -861,3 +861,49 @@ test('suggestions omit skipped tags and say how many were hidden', async ({ page await expect(chips(page).locator(':scope > span:first-child')).toHaveText(['sandstone', 'arch', 'sky', 'dune']); await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(`${CHIP_HINT} 2 skipped tags were not suggested.`); }); + +// D204 (#431): the Saved instructions select above Tagging instructions: the engine default, a pick, a one-off edit +// ("Edited for this run" and the muted note), an engine switch resets, WD14 hides both; Generate sends the text shown. +test('the saved instructions picker fills, edits one-off and resets per engine (D204)', async ({ page, request }) => { + const settings = await (await request.get('/prompt_manager/settings')).json(); + const builtin = settings.settings.autotag_builtin_instructions; + await openTag(page); + const options = dialog(page).locator('#image-tag-model-options'); + const pick = options.getByRole('combobox', { name: 'Saved instructions', exact: true }); + const text = options.getByRole('textbox', { name: 'Tagging instructions', exact: true }); + const note = options.locator('.instructions-edited'); + await expect(pick).toHaveCount(0); + await model(page).selectOption('gguf'); + await expect(pick.locator('option')).toHaveText(['Built-in', 'Describe the image · default']); + await expect(pick).toHaveAttribute('id', 'image-tag-instructions-pick'); + await expect(text).toHaveValue('Describe the image'); + await expect(note).toBeHidden(); + // A keyboard pick (the select focused) keeps focus on the select the fields are re-rendered with. + await pick.focus(); + await pick.selectOption('builtin'); + await expect(text).toHaveValue(builtin); + await expect(pick).toBeFocused(); + await text.fill('Only the sky.'); + await expect(text).toHaveAttribute('aria-describedby', 'image-tag-instructions-pick-note'); + await expect(pick.locator('option:checked')).toHaveText('Edited for this run'); + await expect(pick.locator('option[value="edited"]')).toBeDisabled(); + await expect(note).toHaveText('Edits apply to this run only. Manage saved instructions in System › Auto-tagging.'); + await expect(text).toBeFocused(); + // Generate sends the edited text; the saved entries are unchanged. + const sent = page.waitForRequest(item => item.url().endsWith('/image-annotations/suggest')); + await generate(page).click(); + expect((await sent).postDataJSON().options.instructions).toBe('Only the sky.'); + // Choosing an entry replaces the edit; switching engines starts from the new engine's default. + await pick.selectOption('builtin'); + await expect(text).toHaveValue(builtin); + await expect(note).toBeHidden(); + await text.fill('Edited again.'); + await model(page).selectOption('hf'); + await expect(text).toHaveValue('Describe the image'); + await expect(pick).toHaveValue(FIXTURE_INSTRUCTIONS_ID); + await model(page).selectOption('wd14-vit'); + await expect(pick).toHaveCount(0); + await expect(text).toHaveCount(0); + expect((await (await request.get('/prompt_manager/settings')).json()).settings.autotag_instructions) + .toEqual(settings.settings.autotag_instructions); +}); diff --git a/tests/browser/workspace_system_fixture.py b/tests/browser/workspace_system_fixture.py index c7d87af3..819d484a 100644 --- a/tests/browser/workspace_system_fixture.py +++ b/tests/browser/workspace_system_fixture.py @@ -41,6 +41,11 @@ read. These per-server hooks only change what a System test needs on top: - ``POST /__test__/system/duplicate-prompts`` (S7) adds two prompts whose text differs only in case and spacing (a duplicate prompt text group); ``{"again": true}`` adds a third, so a reviewed plan no longer matches; +- ``POST /__test__/system/pair-metadata`` (#550) saves two saved pairs the + way the Pair node did before #546 (the pair holds the category and tags, + its new positive prompt none): ``S550 lantern`` (category ``night``, tags + ``s550-glow``, ``s550-warm``) fills; ``S550 harbor`` was edited after it was + saved, so the plan keeps it; - ``POST /__test__/system/duplicate-files`` (S7) writes one PNG's bytes three times: ``output/s7-dupes/same.png``, ``input/s7-dupes/same.png`` (the same relative path in the other source) and ``output/s7-dupes/copy.png``; @@ -344,6 +349,25 @@ def register_system_commands(app, routes, api, root: Path): conn.commit() return web.json_response({"success": True}) + @routes.post("/__test__/system/pair-metadata") + async def pair_metadata(request): + def seed(): + api.db.save_prompt_pair( + "S550 lantern", + "S550 blurry", + category="night", + tags=["s550-glow", "s550-warm"], + ) + api.db.save_prompt_pair("S550 harbor", category="night") + with api.db.model.get_connection() as conn: + conn.execute( + "UPDATE prompts SET updated_at='2099-01-01T00:00:00+00:00' " + "WHERE text='S550 harbor'" + ) + + await api._run_in_executor(seed) + return web.json_response({"success": True}) + @routes.post("/__test__/system/duplicate-files") async def duplicate_files(request): body = await request.json() if request.can_read_body else {} diff --git a/tests/browser/workspace_tagging_fixture.py b/tests/browser/workspace_tagging_fixture.py index 26afa4eb..21acd432 100644 --- a/tests/browser/workspace_tagging_fixture.py +++ b/tests/browser/workspace_tagging_fixture.py @@ -391,10 +391,15 @@ DOWNLOAD_HOLD_LIMIT_S = 60 DEFAULTS = (0.35, 0.85, 10) +# The saved "Describe the image" entry's id (#431). +FIXTURE_INSTRUCTIONS_ID = "d35c71be0a1e" + + def _set_defaults(model, values) -> None: """Set the saved tagging defaults (``AutotagConfig``, #328); a reset also - puts back the saved model, batch policy and template (the fake model's - short instructions, which the concept comparisons show).""" + puts back the saved model, batch policy, skipped tags and instructions + (the fake model's short instructions, which the concept comparisons + show).""" from py.config import AutotagConfig general, character, limit = values @@ -404,7 +409,21 @@ def _set_defaults(model, values) -> None: if values == DEFAULTS: AutotagConfig.MODEL_TYPE = "wd14-swinv2" AutotagConfig.EXISTING = "skip" - AutotagConfig.TEMPLATE = model.default_prompt + # #431: Built-in is always the shipped text, so the fake model's short + # instructions are a saved entry, the default of both engines (the + # concept comparisons show it). + AutotagConfig.INSTRUCTIONS = ( + { + "id": FIXTURE_INSTRUCTIONS_ID, + "name": model.default_prompt, + "text": model.default_prompt, + }, + ) + AutotagConfig.DEFAULT_INSTRUCTIONS = { + "gguf": FIXTURE_INSTRUCTIONS_ID, + "hf": FIXTURE_INSTRUCTIONS_ID, + } + AutotagConfig._LEGACY_TEMPLATE = None AutotagConfig.SKIPPED_TAGS = () diff --git a/tests/fixtures/legacy_contract_snapshot.json b/tests/fixtures/legacy_contract_snapshot.json index 4fa2a827..93218b1d 100644 --- a/tests/fixtures/legacy_contract_snapshot.json +++ b/tests/fixtures/legacy_contract_snapshot.json @@ -185,9 +185,12 @@ "monitor.scheduled", "revision", "settings", + "settings.autotag_builtin_instructions", "settings.autotag_character_threshold", + "settings.autotag_default_instructions", "settings.autotag_existing", "settings.autotag_general_threshold", + "settings.autotag_instructions", "settings.autotag_model_type", "settings.autotag_skipped_tags", "settings.autotag_suggestion_limit", diff --git a/tests/js/workspace-image-tagging-state.test.js b/tests/js/workspace-image-tagging-state.test.js index 97a4f183..5060165f 100644 --- a/tests/js/workspace-image-tagging-state.test.js +++ b/tests/js/workspace-image-tagging-state.test.js @@ -53,8 +53,12 @@ function observed(overrides = {}, capture = {}) { ...overrides, }; } +// #431: the saved instructions; the fixture's entry is the default of both engines. +const SAVED_ENTRY = { id: '0123456789ab', name: 'Saved', text: DEFAULT_PROMPT }; const models = (overrides = {}) => ({ - success: true, models: { 'wd14-swinv2': { downloaded: true } }, default_prompt: DEFAULT_PROMPT, + success: true, models: { 'wd14-swinv2': { downloaded: true } }, + instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY], + default_instructions: { gguf: SAVED_ENTRY.id, hf: SAVED_ENTRY.id }, wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10, ...overrides, }); const SANDSTONE = [ @@ -244,10 +248,13 @@ test('dialog starts on the saved model unless a preset or the user chose one', a }); test('an unreadable or malformed models read falls back to the documented defaults', async () => { - for (const route of [() => fail(500, null), () => response(200, models({ default_prompt: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) { + for (const route of [() => fail(500, null), () => response(200, models({ instructions: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) { const { c } = await opened({ [`GET ${MODELS}`]: route }); assert.equal(c.state.phase, 'ready'); - assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10 }); + // #431: the client's Built-in copy (D204's read failure). + const { BUILTIN_INSTRUCTIONS } = await load(); + assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: BUILTIN_INSTRUCTIONS, + suggestion_limit: 10 }); } }); @@ -950,3 +957,85 @@ test('the chip hint says how many skipped tags were not suggested (#431)', async assert.equal(c.state.chipHint, hint, String(skipped)); } }); + +// D204 (#431): the Saved instructions picker of both dialogs. +const PHOTO_ENTRY = { id: '0123456789ac', name: 'Photo', text: 'Photo tags.' }; +const picker = (overrides = {}) => ({ [`GET ${MODELS}`]: () => response(200, models({ model_type: 'gguf', + instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY, PHOTO_ENTRY], + default_instructions: { gguf: SAVED_ENTRY.id, hf: PHOTO_ENTRY.id }, ...overrides })) }); + +test('the picker starts at the engine default; a pick replaces the text and an edit is one-off (D204)', async () => { + const { c } = await opened(picker()); + assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [SAVED_ENTRY.id, DEFAULT_PROMPT, false]); + const revision = c.state.optionsRevision; + c.pickInstructions(PHOTO_ENTRY.id); + assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]); + assert.equal(c.state.optionsRevision, revision + 1, 'a pick re-renders the textarea'); + c.setOption('instructions', 'Photo tags, please.'); + assert.equal(c.state.instructionsEdited, true); + assert.equal(c.state.optionsRevision, revision + 1, 'typing never re-renders'); + // Choosing an entry again replaces the edit without asking; an unknown id changes nothing. + c.pickInstructions(PHOTO_ENTRY.id); + assert.deepEqual([c.state.options.instructions, c.state.instructionsEdited], ['Photo tags.', false]); + c.pickInstructions('nope'); + assert.equal(c.state.instructionId, PHOTO_ENTRY.id); + // Switching engines resets to the new engine's default and drops a one-off edit; WD14 is never "edited". + c.setOption('instructions', 'edited'); + c.setModel('hf'); + assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]); + c.setModel('wd14-vit'); + assert.equal(c.state.instructionsEdited, false); + c.setModel('gguf'); + assert.deepEqual([c.state.instructionId, c.state.options.instructions], [SAVED_ENTRY.id, DEFAULT_PROMPT]); + c.dispose(); +}); + +test('a preset shows the entry with its text, else "Edited for this run" (D204)', async () => { + const { c } = await opened(picker(), { preset: { model: 'hf', options: { instructions: DEFAULT_PROMPT } } }); + assert.deepEqual([c.state.instructionId, c.state.instructionsEdited], [SAVED_ENTRY.id, false]); + const { c: other } = await opened(picker(), { preset: { model: 'hf', options: { instructions: 'Something else' } } }); + assert.deepEqual([other.state.instructionId, other.state.options.instructions, other.state.instructionsEdited], + [PHOTO_ENTRY.id, 'Something else', true]); + for (const controller of [c, other]) controller.dispose(); +}); + +test('a models read without instructions offers Built-in only with the client copy (D204)', async () => { + const { BUILTIN_INSTRUCTIONS, readChoices } = await load(); + assert.equal(BUILTIN_INSTRUCTIONS.startsWith('Generate only comma-separated Danbooru tags'), true); + for (const value of [null, {}, { instructions: [] }, { instructions: [{ id: 'x', name: 'X', text: 'y' }] }]) { + assert.deepEqual(readChoices(value).entries.map(entry => entry.id), ['builtin'], JSON.stringify(value)); + } + // A default naming no entry is Built-in. + assert.deepEqual({ ...readChoices({ instructions: [{ id: 'builtin', name: 'Built-in', text: 't' }], default_instructions: { gguf: 'gone' } }).defaults }, + { gguf: 'builtin', hf: 'builtin' }); + const { c } = await opened(picker({ instructions: 'broken' })); + assert.deepEqual([c.state.instructionId, c.state.options.instructions], ['builtin', BUILTIN_INSTRUCTIONS]); + c.dispose(); +}); + +test('the bulk options pick and reset like the Tag image dialog (D204)', async () => { + const { createTaggingOptionsController } = await load(); + const options = createTaggingOptionsController({ api: harness(defaultRoutes(picker())).api }); + await options.open(); + assert.deepEqual([options.state.model, options.state.instructionId, options.state.options.instructions], ['gguf', SAVED_ENTRY.id, DEFAULT_PROMPT]); + options.pickInstructions(PHOTO_ENTRY.id); + assert.equal(options.request().options.instructions, 'Photo tags.'); + options.setOption('instructions', 'One-off.'); + assert.equal(options.state.instructionsEdited, true); + assert.equal(options.request().options.instructions, 'One-off.'); + options.setModel('hf'); + assert.deepEqual([options.state.instructionId, options.state.instructionsEdited], [PHOTO_ENTRY.id, false]); + options.pickInstructions('nope'); + options.dispose(); + options.pickInstructions(SAVED_ENTRY.id); + assert.equal(options.state.instructionId, PHOTO_ENTRY.id); +}); + +test('the client Built-in copy equals the server DEFAULT_TAG_TEMPLATE (#431)', async () => { + const { BUILTIN_INSTRUCTIONS } = await load(); + const source = require('node:fs').readFileSync(require('node:path').join(__dirname, '../../py/tagging_defaults.py'), 'utf8'); + const block = source.slice(source.indexOf('DEFAULT_TAG_TEMPLATE = ('), source.indexOf('\n)\n', source.indexOf('DEFAULT_TAG_TEMPLATE = ('))); + const text = [...block.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(([, part]) => JSON.parse(`"${part}"`)).join(''); + assert.ok(text.length > 100); + assert.equal(BUILTIN_INSTRUCTIONS, text); +}); diff --git a/tests/js/workspace-images-tagging-bulk.test.js b/tests/js/workspace-images-tagging-bulk.test.js index 9ffec9a1..4b8a5182 100644 --- a/tests/js/workspace-images-tagging-bulk.test.js +++ b/tests/js/workspace-images-tagging-bulk.test.js @@ -180,7 +180,9 @@ test('the bulk options start from the server defaults and wait for them before S assert.equal(options.state.canStart, false); assert.equal(options.state.canGenerate, false); const opening = options.open(); - answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, default_prompt: 'Say it', suggestion_limit: 7, + answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, suggestion_limit: 7, + instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, { id: '0123456789ab', name: 'Say', text: 'Say it' }], + default_instructions: { gguf: '0123456789ab', hf: 'builtin' }, models: { 'wd14-swinv2': { downloaded: true }, 'wd14-vit': { downloaded: false }, gguf: { downloaded: true } } }); await opening; assert.equal(options.state.canStart, true); diff --git a/tests/js/workspace-system-instructions.test.js b/tests/js/workspace-system-instructions.test.js new file mode 100644 index 00000000..0a1a7814 --- /dev/null +++ b/tests/js/workspace-system-instructions.test.js @@ -0,0 +1,257 @@ +// System › Auto-tagging "Saved instructions" (#431, D203): Built-in read-only, New, Duplicate, Delete (a deleted default +// resets to Built-in), the caps, the editor drafts and the per-engine default selects. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const instructionsModule = import('../../web/js/workspace/system-instructions.mjs'); +const stateModule = import('../../web/js/workspace/system-state.mjs'); +const coreModule = import('../../web/js/shared/toolkit/core.js'); + +/** A minimal DOM: elements with children, attributes, listeners (aborted by their signal) and focus. */ +function fakeDOM() { + const previous = global.document; + class Element { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.attributes = {}; + this.listeners = []; + this.dataset = {}; + this.className = ''; + this.id = ''; + this.value = ''; + this.title = ''; + this.disabled = false; + this._text = ''; + } + get localName() { return this.tagName.toLowerCase(); } + get textContent() { return this._text + this.children.map(child => (typeof child === 'string' ? child : child.textContent)).join(''); } + set textContent(text) { this.children = []; this._text = String(text); } + append(...nodes) { for (const node of nodes) { if (typeof node !== 'string') node.parentNode = this; this.children.push(node); } } + replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); } + querySelectorAll(selector) { + assert.equal(selector, 'button'); + const found = []; + const walk = node => { for (const child of node.children) if (typeof child !== 'string') { if (child.tagName === 'BUTTON') found.push(child); walk(child); } }; + walk(this); + return found; + } + focus() { global.document.activeElement = this; } + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; } + removeAttribute(name) { delete this.attributes[name]; } + addEventListener(type, callback, options = {}) { + if (options.signal?.aborted) return; + const entry = { type, callback }; + this.listeners.push(entry); + options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); }); + } + dispatch(type, event = {}) { + return Promise.all(this.listeners.filter(item => item.type === type).map(({ callback }) => callback({ type, target: this, ...event }))); + } + } + global.document = { createElement: tag => new Element(tag), activeElement: null }; + const walk = (node, found = []) => { found.push(node); node.children.forEach(child => typeof child !== 'string' && walk(child, found)); return found; }; + return { find: (root, predicate) => walk(root).find(predicate), restore() { global.document = previous; } }; +} + + +const PHOTO = { id: '0123456789ab', name: 'Custom template', text: 'Photo tags.' }; +const ANIME = { id: '0123456789ac', name: 'Anime', text: 'Anime tags.' }; +const HOST = { autotag_builtin_instructions: 'Built-in text.', autotag_instructions: [PHOTO, ANIME], + autotag_default_instructions: { gguf: PHOTO.id, hf: PHOTO.id } }; + +async function mounted({ host = HOST } = {}) { + const { savedInstructionsBlock } = await instructionsModule; + const { createSystemDrafts } = await stateModule; + const { createScope } = await coreModule; + const dom = fakeDOM(); + const drafts = createSystemDrafts({ + readBrowser: () => ({}), readRuntime: () => ({}), readHost: async () => ({ values: host, revision: 'sha256:one' }), + writeBrowser: async () => ({}), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}), + }); + const scope = createScope(); + let counter = 0; + const random = bytes => bytes.map(() => (counter += 1)); + const block = savedInstructionsBlock({ scope, drafts, random }); + const root = global.document.createElement('div'); + root.append(...block.nodes); + scope.cleanup(drafts.subscribe(block.repaint)); + block.repaint(); + await drafts.reloadHost(); + const byId = id => dom.find(root, node => node.id === id); + const options = () => byId('instruction-list').children.map(option => [option.children[0].textContent, + option.children[1]?.children.map(badge => badge.textContent) ?? [], option.getAttribute('aria-selected')]); + const click = id => byId(id).dispatch('click'); + // A click on an option reaches the list's one listener (as it bubbles in a browser). + const pick = name => byId('instruction-list').dispatch('click', { target: byId('instruction-list').children + .find(option => option.children[0].textContent === name).children[0] }); + return { dom, drafts, scope, block, byId, options, click, pick, root }; +} + +test('helpers: Built-in first, defaults, free names, ids and the server sentences', async () => { + const { allEntries, defaultEntry, freeName, newId, listProblem, entryNote } = await instructionsModule; + assert.deepEqual(allEntries(HOST).map(entry => entry.name), ['Built-in', 'Custom template', 'Anime']); + assert.equal(allEntries(HOST)[0].text, 'Built-in text.'); + assert.equal(defaultEntry(HOST, 'hf').text, 'Photo tags.'); + assert.equal(defaultEntry({ ...HOST, autotag_default_instructions: { gguf: 'gone', hf: 'builtin' } }, 'gguf').name, 'Built-in'); + assert.equal(freeName('New instructions', allEntries(HOST)), 'New instructions'); + assert.equal(freeName('anime', allEntries(HOST)), 'anime (2)'); + assert.equal(freeName('Anime', [...allEntries(HOST), { name: 'Anime (2)' }]), 'Anime (3)'); + let calls = 0; + const id = newId([{ id: '000000000000' }], bytes => { calls += 1; return bytes.fill(calls === 1 ? 0 : 171); }); + assert.equal(id, 'abababababab'); + assert.match(newId([]), /^[0-9a-f]{12}$/); + const list = entries => listProblem({ autotag_instructions: entries }); + assert.equal(list([PHOTO]), null); + assert.equal(list([{ ...PHOTO, name: ' ' }]), 'Saved instructions: entry 1 needs a name.'); + assert.equal(list([PHOTO, { ...ANIME, name: 'x'.repeat(61) }]), 'Saved instructions: entry 2 name must be at most 60 characters.'); + assert.equal(list([{ ...PHOTO, name: 'a\nb' }]), 'Saved instructions: entry 1 name must not contain a line break.'); + assert.equal(list([{ ...PHOTO, name: 'built-in' }]), 'Saved instructions: the name “Built-in” is reserved.'); + assert.equal(list([PHOTO, { ...ANIME, name: ' custom TEMPLATE ' }]), 'Saved instructions: entry 2 name “custom TEMPLATE” is already used.'); + assert.equal(list([{ ...PHOTO, text: ' ' }]), 'Saved instructions: entry 1 text must be non-empty text.'); + assert.equal(list([{ ...PHOTO, text: 'x'.repeat(4001) }]), 'Saved instructions: entry 1 text must be at most 4000 characters.'); + const long = { ...PHOTO, text: 'x'.repeat(4001) }; + assert.equal(entryNote(long, [long]), 'The text must be at most 4,000 characters.'); +}); + +test('the list shows Built-in read-only first, the saved entries and their default chips', async () => { + const view = await mounted(); + try { + assert.deepEqual(view.options(), [['Built-in', ['Read-only'], 'true'], + ['Custom template', ['Default for GGUF', 'Default for HuggingFace'], 'false'], ['Anime', [], 'false']]); + assert.equal(view.byId('instructions-count').textContent, '2 of 20 saved · Built‑in is not counted.'); + assert.equal(view.byId('instruction-name').value, 'Built-in'); + assert.equal(view.byId('instruction-name').readOnly, true); + assert.equal(view.byId('instruction-text').value, 'Built-in text.'); + assert.equal(view.byId('instruction-text').readOnly, true); + assert.equal(view.byId('instruction-note').textContent, 'Built-in instructions can’t be changed. Duplicate them to make your own.'); + assert.equal(view.byId('instruction-delete').disabled, true); + assert.equal(view.byId('instruction-list').getAttribute('role'), 'listbox'); + assert.equal(view.block.nodes[0].dataset.key, 'autotag_instructions'); + assert.deepEqual(view.byId('instruction-default-gguf').children.map(option => option.textContent), ['Built-in', 'Custom template', 'Anime']); + assert.equal(view.byId('instruction-default-gguf').value, PHOTO.id); + assert.equal(view.byId('instruction-default-hf').dataset.key, 'autotag_default_instructions'); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('editing a saved entry drafts the list; its counts and note follow', async () => { + const view = await mounted(); + try { + await view.pick('Anime'); + assert.equal(view.byId('instruction-name').readOnly, false); + assert.equal(view.byId('instruction-delete').disabled, false); + view.byId('instruction-name').value = 'Anime tags'; + await view.byId('instruction-name').dispatch('input'); + view.byId('instruction-text').value = 'Tags, anime.'; + await view.byId('instruction-text').dispatch('input'); + assert.deepEqual(view.drafts.get('host', 'autotag_instructions'), [PHOTO, { ...ANIME, name: 'Anime tags', text: 'Tags, anime.' }]); + assert.equal(view.byId('instruction-name').parentNode.children[0].children[1].textContent, '10 of 60'); + assert.equal(view.byId('instruction-text').parentNode.children[0].children[1].textContent, '12 of 4,000 characters'); + view.byId('instruction-name').value = 'custom template'; + await view.byId('instruction-name').dispatch('input'); + assert.equal(view.byId('instruction-note').textContent, 'The name “custom template” is already used.'); + // Built-in is never edited. + await view.pick('Built-in'); + view.byId('instruction-text').value = 'changed'; + await view.byId('instruction-text').dispatch('input'); + assert.equal(view.drafts.get('host', 'autotag_instructions')[0], PHOTO); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('New and Duplicate append an entry with a fresh id and a free name; the caps disable them', async () => { + const view = await mounted(); + try { + await view.click('instruction-new'); + const added = view.drafts.get('host', 'autotag_instructions').at(-1); + assert.match(added.id, /^[0-9a-f]{12}$/); + assert.deepEqual([added.name, added.text], ['New instructions', '']); + assert.equal(view.options().at(-1)[2], 'true'); + assert.equal(view.byId('instruction-note').textContent, 'The text must not be empty.'); + await view.pick('Built-in'); + await view.click('instruction-duplicate'); + assert.deepEqual(view.drafts.get('host', 'autotag_instructions').at(-1).name, 'Built-in copy'); + assert.equal(view.drafts.get('host', 'autotag_instructions').at(-1).text, 'Built-in text.'); + const full = await mounted({ host: { ...HOST, autotag_instructions: Array.from({ length: 20 }, + (_, i) => ({ id: i.toString(16).padStart(12, '0'), name: `n${i}`, text: 't' })) } }); + try { + assert.equal(full.byId('instruction-new').disabled, true); + assert.equal(full.byId('instruction-duplicate').disabled, true); + assert.equal(full.byId('instruction-cap').hidden, false); + await full.click('instruction-new'); + assert.equal(full.drafts.get('host', 'autotag_instructions').length, 20); + } finally { full.scope.dispose(); } + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('deleting a default resets that engine to Built-in and says so; a select change clears the note', async () => { + const view = await mounted(); + try { + await view.pick('Custom template'); + await view.click('instruction-delete'); + assert.deepEqual(view.drafts.get('host', 'autotag_instructions'), [ANIME]); + assert.deepEqual(view.drafts.get('host', 'autotag_default_instructions'), { gguf: 'builtin', hf: 'builtin' }); + assert.equal(view.byId('instruction-reset').textContent, 'GGUF and HuggingFace now use Built-in.'); + assert.equal(view.options()[1][2], 'true', 'the next entry is selected'); + view.byId('instruction-default-hf').value = ANIME.id; + await view.byId('instruction-default-hf').dispatch('change'); + assert.deepEqual(view.drafts.get('host', 'autotag_default_instructions'), { gguf: 'builtin', hf: ANIME.id }); + assert.equal(view.byId('instruction-reset').textContent, ''); + await view.click('instruction-delete'); + assert.equal(view.byId('instruction-reset').textContent, 'HuggingFace now uses Built-in.'); + assert.deepEqual(view.options().map(([name]) => name), ['Built-in']); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('arrow keys move the selection; before the read everything is disabled', async () => { + const view = await mounted(); + try { + const list = view.byId('instruction-list'); + await list.dispatch('keydown', { key: 'ArrowDown' }); + assert.equal(view.byId('instruction-name').value, 'Custom template'); + await list.dispatch('keydown', { key: 'End' }); + assert.equal(view.byId('instruction-name').value, 'Anime'); + await list.dispatch('keydown', { key: 'ArrowDown' }); + assert.equal(view.byId('instruction-name').value, 'Anime'); + await list.dispatch('keydown', { key: 'Home' }); + assert.equal(view.byId('instruction-name').value, 'Built-in'); + await list.dispatch('keydown', { key: 'a' }); + assert.equal(list.getAttribute('aria-activedescendant'), 'instruction-option-builtin'); + } finally { view.scope.dispose(); view.dom.restore(); } + const unread = await mounted({ host: {} }); + try { + assert.equal(unread.byId('instruction-new').disabled, true); + assert.equal(unread.byId('instruction-default-gguf').disabled, true); + } finally { unread.scope.dispose(); unread.dom.restore(); } +}); + +test('the reset note goes once the defaults are saved; a long name is cut to fit its copy suffix', async () => { + const view = await mounted(); + try { + await view.pick('Custom template'); + await view.click('instruction-delete'); + assert.equal(view.byId('instruction-reset').textContent, 'GGUF and HuggingFace now use Built-in.'); + await view.drafts.save(); + assert.equal(view.byId('instruction-reset').textContent, ''); + await view.pick('Anime'); + view.byId('instruction-name').value = 'x'.repeat(60); + await view.byId('instruction-name').dispatch('input'); + await view.click('instruction-duplicate'); + const copy = view.drafts.get('host', 'autotag_instructions').at(-1).name; + assert.equal(copy, `${'x'.repeat(55)} copy`); + await view.click('instruction-duplicate'); + assert.equal([...view.drafts.get('host', 'autotag_instructions').at(-1).name].length <= 60, true); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('option clicks are one listener on the list, not one per repaint', async () => { + const view = await mounted(); + try { + const list = view.byId('instruction-list'); + const before = list.listeners.length; + for (let i = 0; i < 5; i += 1) await view.pick('Anime'); + assert.equal(list.listeners.length, before); + assert.equal(view.byId('instruction-list').children.every(option => option.listeners.length === 0), true); + assert.equal(view.byId('instruction-name').value, 'Anime'); + } finally { view.scope.dispose(); view.dom.restore(); } +}); diff --git a/tests/js/workspace-system-maintenance.test.js b/tests/js/workspace-system-maintenance.test.js index c085e22b..78545ea1 100644 --- a/tests/js/workspace-system-maintenance.test.js +++ b/tests/js/workspace-system-maintenance.test.js @@ -21,8 +21,23 @@ test('the section lists the concept rows in its order, labels and danger marks', assert.deepEqual(MAINTENANCE_SURFACES.map(([heading]) => heading), surfaces.map(text => text.replace('&', '&'))); const production = MAINTENANCE_SURFACES.flatMap(([, rows]) => rows.map(([title, description, label, , danger = false]) => [title, description, label, danger])); - assert.deepEqual(production, concept.map(([title, ...rest]) => [title.replace('&', '&'), ...rest])); - assert.equal(production.length, 8); + // D192 (#550, beyond the concept): "Pair category and tags" follows "Unused prompt components" in Database. + const expected = concept.map(([title, ...rest]) => [title.replace('&', '&'), ...rest]); + expected.splice(expected.findIndex(([title]) => title === 'Unused prompt components') + 1, 0, PAIR_ROW); + assert.deepEqual(production, expected); + assert.equal(production.length, 9); +}); + +const PAIR_ROW = Object.freeze(['Pair category and tags', 'Fill an empty category or tags on prompts older Pair node runs ' + + 'saved, from their saved pairs. Only empty fields of never-edited prompts.', 'Review plan', false]); + +test('D192: the Pair category and tags row reviews the pair_metadata plan under its title (#550)', async () => { + const { MAINTENANCE_SURFACES } = await maintenance; + const { OPERATION_TITLES } = await review; + const [, database] = MAINTENANCE_SURFACES[0]; + const row = database.find(([title]) => title === PAIR_ROW[0]); + assert.deepEqual(row, [PAIR_ROW[0], PAIR_ROW[1], PAIR_ROW[2], 'pair_metadata']); + assert.equal(OPERATION_TITLES.pair_metadata, 'Pair category and tags'); }); const PLAN = Object.freeze({ @@ -210,3 +225,40 @@ test('a rotated-log cleanup the log overtook offers Review again (S12)', async ( assert.deepEqual(reviewAgainOf({ kind: 'maintenance', state: 'failed', scope: { operation: 'rotated_logs', failure: 'plan-changed' } }), { operation: 'rotated_logs' }); }); + +// #550 live test: a records operation's rows have no path, only a detail ("Prompt 12: category …"). +const RECORD_JOB = Object.freeze({ + id: 'a'.repeat(32), kind: 'maintenance', title: 'Pair category and tags', state: 'finished', + admission: 'records', cancellable: false, progress: 100, processed: 0, total: 0, status_line: 'Filled 2 prompts', + scope: { operation: 'pair_metadata' }, counters: { changed: 2, protected: 0, errors: 0 }, + started_at: 1, finished_at: 2, +}); + +test('#550: path-less record rows are kept with their detail; file rows are unchanged', async () => { + const { parseDetail, filterRows } = await jobView; + const parsed = parseDetail({ ...RECORD_JOB, outcomes: { changed: [ + { path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian' }, + { path: '', root: '', root_index: null, detail: '' }, + { path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed' }, + ] }, log: [] }); + assert.deepEqual(parsed.outcomes.changed, [ + { path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian', record: true }, + { path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed', record: false }, + ]); + // The filter reads a record's detail and a file's path. + assert.deepEqual(filterRows(parsed.outcomes.changed, 'asian').map(row => row.detail), + ['Prompt 2325: category “asian”; tags asian']); + assert.deepEqual(filterRows(parsed.outcomes.changed, 'a/b').map(row => row.path), ['a/b.png']); +}); + +test('#550: a records operation says records, not files, for an empty group and a filter', async () => { + const { groupCopy, EMPTY_GROUP, NO_MATCH } = await jobView; + assert.deepEqual(groupCopy(RECORD_JOB), { empty: 'No records in this group.', noMatch: 'No listed record matches this filter.', + placeholder: 'Filter records…' }); + for (const operation of ['duplicate_prompts', 'unused_components']) { + assert.equal(groupCopy({ ...RECORD_JOB, scope: { operation } }).empty, 'No records in this group.'); + } + const files = { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: 'Filter by file path…' }; + assert.deepEqual(groupCopy({ ...RECORD_JOB, scope: { operation: 'missing_links' } }), files); + assert.deepEqual(groupCopy({ ...RECORD_JOB, kind: 'scan', scope: {} }), files); +}); diff --git a/tests/js/workspace-system-tagging.test.js b/tests/js/workspace-system-tagging.test.js index 7697edd7..7396fe6d 100644 --- a/tests/js/workspace-system-tagging.test.js +++ b/tests/js/workspace-system-tagging.test.js @@ -42,6 +42,8 @@ function fakeDOM() { this.children.push(node); } } + replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); } + focus() { global.document.activeElement = this; } remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; } setAttribute(name, value) { this.attributes[name] = String(value); } getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; } @@ -65,7 +67,9 @@ function fakeDOM() { } const HOST = { autotag_model_type: 'wd14-swinv2', autotag_existing: 'skip', autotag_general_threshold: 0.35, - autotag_character_threshold: 0.85, autotag_suggestion_limit: 10, autotag_template: 'Describe the image as tags.' }; + autotag_character_threshold: 0.85, autotag_suggestion_limit: 10, autotag_builtin_instructions: 'Built-in tags.', + autotag_instructions: [{ id: '0123456789ab', name: 'Describe', text: 'Describe the image as tags.' }], + autotag_default_instructions: { gguf: '0123456789ab', hf: 'builtin' } }; const entry = (label, size, extra = {}) => ({ name: label, label, size_label: size, downloaded: true, loaded: false, ...extra }); const MODELS = { success: true, @@ -124,7 +128,8 @@ test('the section keeps the concept surface, rows, labels and actions in order', for (const option of ['Skip already tagged', 'Merge with existing', 'Replace after review']) assert.ok(body.includes(option) && SOURCE.includes(`'${option}'`)); assert.ok(body.includes('

Auto-tagging

') && SOURCE.includes("'h2', 'Auto-tagging'")); assert.ok(body.includes('One tagging configuration for images and prompt-library actions. Every result can be reviewed before applying.')); - assert.ok(body.includes('LLM tag template') && SOURCE.includes("'LLM tag template'")); + // D203 (#431): Saved instructions replace the concept's template field. + assert.ok(body.includes('LLM tag template') && !SOURCE.includes("'LLM tag template'")); assert.ok(body.includes('"Preview tagging review", "tag-review", "", "ca-primary"')); // Prototype copy is never shipped. for (const sample of ['Simulate download', 'Simulate unload', 'Ollama']) assert.ok(!SOURCE.includes(sample), sample); @@ -167,27 +172,25 @@ test('controls draft their host keys; numbers as numbers, other text as typed', const { SETTING_KEYS, coerceNumber, draftValue } = await tagging; assert.deepEqual(SETTING_KEYS, { 'tag-model': 'autotag_model_type', 'tag-existing': 'autotag_existing', 'threshold-general': 'autotag_general_threshold', 'threshold-character': 'autotag_character_threshold', - 'tag-suggestions': 'autotag_suggestion_limit', 'tag-template': 'autotag_template' }); + 'tag-suggestions': 'autotag_suggestion_limit' }); assert.deepEqual(['0.4', ' 1.5 ', '15', '', 'abc', '1e-1'].map(coerceNumber), [0.4, 1.5, 15, '', 'abc', 0.1]); assert.equal(draftValue('autotag_suggestion_limit', '15'), 15); - assert.equal(draftValue('autotag_template', ' 12 '), ' 12 '); + assert.equal(draftValue('autotag_existing', ' 12 '), ' 12 '); assert.equal(draftValue('autotag_existing', undefined), ''); const { dom, drafts, scope, byId } = await mounted(); try { assert.equal(byId('tag-model').value, 'wd14-swinv2'); assert.equal(byId('threshold-general').value, '0.35'); - assert.equal(byId('tag-template').value, HOST.autotag_template); assert.deepEqual([byId('threshold-general').min, byId('threshold-general').max, byId('threshold-general').step], ['0', '1', '0.05']); assert.deepEqual([byId('tag-suggestions').min, byId('tag-suggestions').max], ['1', '100']); byId('tag-existing').value = 'merge'; await byId('tag-existing').dispatch('change'); byId('threshold-general').value = '0.4'; await byId('threshold-general').dispatch('change'); byId('tag-suggestions').value = '15'; await byId('tag-suggestions').dispatch('change'); - byId('tag-template').value = 'Tags only.'; await byId('tag-template').dispatch('input'); assert.deepEqual(drafts.changes('host'), { autotag_existing: 'merge', autotag_general_threshold: 0.4, - autotag_suggestion_limit: 15, autotag_template: 'Tags only.' }); + autotag_suggestion_limit: 15 }); // Every control names its stored key, so a refused save highlights it (D92). for (const id of Object.keys(SETTING_KEYS)) assert.equal(byId(id).dataset.key, SETTING_KEYS[id]); - assert.equal(byId('tag-template').dataset.setting, 'tag-template'); + assert.equal(byId('tag-template'), undefined); } finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); } }); @@ -201,7 +204,8 @@ test('opening the section reads the model status once; the select waits for it', assert.deepEqual(byId('tag-model').options.map(option => option.textContent), ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB']); assert.deepEqual(surface.children.map(node => node.className), - ['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-block', 'field', 'row-actions', 'operation']); + ['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-block', 'setting-block', 'setting-row', + 'setting-row', 'small instruction-reset', 'row-actions', 'operation']); } finally { scope.dispose(); dom.restore(); } const failed = await mounted({ modelsFail: true }); try { @@ -302,7 +306,9 @@ test('Preview tagging review opens the Tag image dialog with the section’s val assert.deepEqual(taggingPreset(HOST), { model: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85, suggestion_limit: 10 } }); assert.deepEqual(taggingPreset({ ...HOST, autotag_model_type: 'gguf' }), { model: 'gguf', - options: { instructions: HOST.autotag_template, suggestion_limit: 10 } }); + options: { instructions: 'Describe the image as tags.', suggestion_limit: 10 } }); + // D205 (#431): each language-model engine reads its own drafted default; Built-in's text comes from the server. + assert.deepEqual(taggingPreset({ ...HOST, autotag_model_type: 'hf' }).options.instructions, 'Built-in tags.'); assert.equal(draftProblem(HOST), null); assert.deepEqual(draftProblem({ ...HOST, autotag_general_threshold: 1.5 }), { key: 'autotag_general_threshold', message: 'General-tag confidence must be between 0 and 1.' }); @@ -319,15 +325,17 @@ test('Preview tagging review opens the Tag image dialog with the section’s val assert.equal(taggingPreset({ ...HOST, autotag_general_threshold: 0.1 + 0.2 }).options.general_threshold, 0.3); assert.equal(draftProblem({ ...HOST, autotag_suggestion_limit: 2.5 }).key, 'autotag_suggestion_limit'); assert.equal(draftProblem({ ...HOST, autotag_suggestion_limit: 0 }, { limit: false }), null); - assert.deepEqual(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: ' ' }), - { key: 'autotag_template', message: 'LLM tag template must be non-empty text.' }); - assert.deepEqual(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: 'x'.repeat(4001) }), - { key: 'autotag_template', message: 'LLM tag template must be at most 4000 characters.' }); + // #431: a saved entry the server would refuse holds back a language-model run, with the server's sentence. + const entries = list => ({ ...HOST, autotag_model_type: 'hf', autotag_instructions: list }); + assert.deepEqual(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: ' ' }])), + { key: 'autotag_instructions', message: 'Saved instructions: entry 1 text must be non-empty text.' }); + assert.deepEqual(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: 'x'.repeat(4001) }])), + { key: 'autotag_instructions', message: 'Saved instructions: entry 1 text must be at most 4000 characters.' }); // Characters are code points, as Python's len counts them: 4000 emoji (8000 UTF-16 units) are allowed. - assert.equal(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: '🏜'.repeat(4000) }), null); - assert.equal(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: '🏜'.repeat(4001) }).key, 'autotag_template'); - // A WD14 run never reads the template, nor a language model the thresholds. - assert.equal(draftProblem({ ...HOST, autotag_template: '' }), null); + assert.equal(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: '🏜'.repeat(4000) }])), null); + assert.equal(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: '🏜'.repeat(4001) }])).key, 'autotag_instructions'); + // A WD14 run never reads the instructions, nor a language model the thresholds. + assert.equal(draftProblem({ ...HOST, autotag_instructions: [{ id: '0123456789ab', name: '', text: '' }] }), null); assert.equal(draftProblem({ ...HOST, autotag_model_type: 'gguf', autotag_general_threshold: 7 }), null); const none = await mounted({ image: null }); @@ -372,9 +380,12 @@ test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5 ['Settings', 'General 0.35 · Character 0.85 · up to 10 tags per image'], ['Backup', 'Create and verify before changes']]); const llm = { ...HOST, autotag_model_type: 'gguf' }; assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' }, estimate_seconds: null, - estimate_device: null, suggestion_limit: 4 }, llm).slice(-3), + estimate_device: null, suggestion_limit: 4 }, llm).slice(-4), [['Time', 'Language-model engines take longer per image. Activity shows the time left once tagging starts.'], - ['Settings', 'Template of 27 characters · up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]); + ['Instructions', 'Describe'], ['Settings', 'Up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]); + // D205 (#431): HF shows its own default by name. + assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'hf' } }, { ...llm, autotag_model_type: 'hf' }).find(([term]) => term === 'Instructions'), + ['Instructions', 'Built-in']); assert.equal(batchGrid({}, HOST)[2][1], '0'); assert.equal(batchGrid({}, HOST)[6][1], 'General 0.35 · Character 0.85'); assert.equal(existingLabel('odd'), 'odd'); @@ -382,7 +393,7 @@ test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5 options: { general_threshold: 0.35, character_threshold: 0.85 } }); // #369: a threshold a hair off the grid is sent snapped to it. assert.equal(batchBody({ ...HOST, autotag_character_threshold: 0.1 + 0.2 }).options.character_threshold, 0.3); - assert.deepEqual(batchBody(llm), { model_type: 'gguf', existing: 'skip', options: { instructions: HOST.autotag_template } }); + assert.deepEqual(batchBody(llm), { model_type: 'gguf', existing: 'skip', options: { instructions: 'Describe the image as tags.' } }); assert.equal(batchCloseNote(PLAN), null); assert.equal(batchCloseNote({ ...PLAN, review_required: true }), REPLACE_NOTE); assert.equal(REPLACE_NOTE, 'Replacing tags needs a review of each image. Use Preview tagging review, or choose Skip or Merge for a batch run.'); diff --git a/tests/test_api_annotations.py b/tests/test_api_annotations.py index 769afa89..22b5ef2e 100644 --- a/tests/test_api_annotations.py +++ b/tests/test_api_annotations.py @@ -1144,8 +1144,10 @@ class TestEnvelopes(AnnotationAPITestCase): self.assertEqual( sorted(await self.json_of(resp)), [ + "default_instructions", # #431 adds the saved instructions "default_prompt", "existing", # #328 adds the saved batch policy and model + "instructions", "loaded_model_type", "model_loaded", "model_type", diff --git a/tests/test_api_autotag.py b/tests/test_api_autotag.py index d8cb6978..ae643f9f 100644 --- a/tests/test_api_autotag.py +++ b/tests/test_api_autotag.py @@ -1536,7 +1536,8 @@ class TestSavedTaggingDefaults(AutotagAPITestCase): "EXISTING", "GENERAL_THRESHOLD", "CHARACTER_THRESHOLD", - "TEMPLATE", + "INSTRUCTIONS", + "DEFAULT_INSTRUCTIONS", "SUGGESTION_LIMIT", ): self.addCleanup(setattr, AutotagConfig, attr, getattr(AutotagConfig, attr)) @@ -1557,7 +1558,11 @@ class TestSavedTaggingDefaults(AutotagAPITestCase): self.config.EXISTING = "replace_after_review" self.config.GENERAL_THRESHOLD = 0.4 self.config.CHARACTER_THRESHOLD = 0.7 - self.config.TEMPLATE = "Saved template" + # #431: the saved engine's default instructions. + self.config.INSTRUCTIONS = ( + {"id": "0123456789ab", "name": "Saved", "text": "Saved template"}, + ) + self.config.DEFAULT_INSTRUCTIONS = {"gguf": "0123456789ab", "hf": "builtin"} self.config.SUGGESTION_LIMIT = 15 data = await (await self.client.get("/prompt_manager/autotag/models")).json() self.assertEqual( @@ -1581,6 +1586,27 @@ class TestSavedTaggingDefaults(AutotagAPITestCase): "suggestion_limit": 15, }, ) + # Built-in first with every text, and each engine's default. + self.assertEqual( + data["instructions"], + [ + {"id": "builtin", "name": "Built-in", "text": DEFAULT_TAG_TEMPLATE}, + {"id": "0123456789ab", "name": "Saved", "text": "Saved template"}, + ], + ) + self.assertEqual( + data["default_instructions"], {"gguf": "0123456789ab", "hf": "builtin"} + ) + # HF reads its own default; a WD14 engine reads GGUF's. + for model_type, prompt in ( + ("hf", DEFAULT_TAG_TEMPLATE), + ("wd14-vit", "Saved template"), + ): + self.config.MODEL_TYPE = model_type + data = await ( + await self.client.get("/prompt_manager/autotag/models") + ).json() + self.assertEqual(data["default_prompt"], prompt, model_type) async def test_single_without_thresholds_uses_saved_ones(self): image = self._make_image(self.output_dir) diff --git a/tests/test_api_maintenance_pair_metadata.py b/tests/test_api_maintenance_pair_metadata.py new file mode 100644 index 00000000..08fc3365 --- /dev/null +++ b/tests/test_api_maintenance_pair_metadata.py @@ -0,0 +1,181 @@ +"""Pair category and tags repair through the maintenance routes (#550). + +``pair_metadata`` is reviewed and applied like the other maintenance plans: +``options`` must be ``{}``, the job is titled "Pair category and tags", the +safety backup follows the ``maintenance.safety_backup`` policy, and the job's +downloadable report lists every filled prompt, never cut at 200 rows. +""" + +import asyncio +import os +import sys +from unittest.mock import MagicMock + +_mock_server = MagicMock() +_mock_server.PromptServer.instance.routes = MagicMock() +sys.modules.setdefault("server", _mock_server) + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from py.api.jobs import MAX_OUTCOME_ROWS # noqa: E402 +from py.api.maintenance import MAINTENANCE_TITLES # noqa: E402 +from py.config import PromptManagerConfig # noqa: E402 + +try: + from tests.test_api_maintenance import WAIT, ApplyTestCase # noqa: E402 +except ImportError: # discovered with tests/ as the top-level directory + from test_api_maintenance import WAIT, ApplyTestCase # noqa: E402 + +OPERATION = "pair_metadata" +TITLE = "Pair category and tags" + + +class PairMetadataAPITestCase(ApplyTestCase): + def pair(self, positive, negative="", **metadata): + pair_id = self.api.db.save_prompt_pair(positive, negative, **metadata) + with self.api.db.model.get_connection() as conn: + prompt = conn.execute( + "SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair_id,) + ).fetchone()[0] + return pair_id, prompt + + def category(self, prompt_id): + with self.api.db.model.get_connection() as conn: + return conn.execute( + "SELECT category FROM prompts WHERE id=?", (prompt_id,) + ).fetchone()[0] + + +class TestPairMetadataRoutes(PairMetadataAPITestCase): + async def test_preview_accepts_the_operation_and_no_options(self): + self.assertEqual(MAINTENANCE_TITLES[OPERATION], TITLE) + self.pair("a cat", category="portrait", tags=["a"]) + plan = await self.preview(OPERATION) + self.assertEqual(plan["operation"], OPERATION) + self.assertEqual(plan["scope"]["count"], 1) + self.assertEqual(plan["backup"]["policy"], "on") + refused = await self.preview(OPERATION, 400, options={"all": True}) + self.assertEqual(refused["code"], "invalid-request") + + async def test_apply_fills_with_a_backup_and_names_it(self): + _pair, prompt = self.pair("a cat", "neg", category="portrait", tags=["a", "b"]) + plan, summary, job = await self.run_apply(OPERATION) + self.assertEqual(summary["title"], TITLE) + self.assertEqual(summary["admission"], "records") + self.assertEqual(job.state, "finished", job.log) + self.assertEqual(self.category(prompt), "portrait") + [backup] = self.backups() + self.assertEqual( + job.status_line, + "Filled 1 prompt from its saved pairs (1 category, 1 tag set) · " + f"Safety backup {backup}", + ) + self.assertEqual( + job.outcomes["changed"][0]["detail"], + f"Prompt {prompt}: category “portrait”; tags a, b", + ) + self.assertEqual(job.counters["changed"], 1) + + async def test_protected_prompts_are_counted(self): + _a, prompt = self.pair("conflict", "n1", category="x") + self.pair("conflict", "n2", category="y") + self.pair("fill me", category="portrait") + plan, _summary, job = await self.run_apply(OPERATION) + self.assertEqual(plan["protected"]["reasons"], ["1 where pairs disagree"]) + self.assertEqual(job.counters["protected"], 1) + self.assertIsNone(self.category(prompt)) + # Live test (#550): the count comes with its rows, in the review's wording. + self.assertEqual( + job.outcomes["protected"], + [ + { + "path": "", + "root": "", + "root_index": None, + "detail": ( + f"Prompt {prompt}: kept, pairs disagree on category: x, y" + ), + } + ], + ) + report = job.report()["outcomes"]["protected"] + self.assertEqual((report["count"], report["truncated"]), (1, False)) + + async def test_report_lists_every_kept_prompt(self): + PromptManagerConfig.SAFETY_BACKUP = False + count = MAX_OUTCOME_ROWS + 5 + for n in range(count): + _pair, prompt = self.pair(f"kept {n:03d}", category="portrait") + with self.api.db.model.get_connection() as conn: + conn.execute( + "UPDATE prompts SET updated_at='2099-01-01T00:00:00+00:00' " + "WHERE id=?", + (prompt,), + ) + _plan, _summary, job = await self.run_apply(OPERATION) + self.assertEqual(job.counters["protected"], count) + self.assertEqual(len(job.outcomes["protected"]), MAX_OUTCOME_ROWS) + protected = job.report()["outcomes"]["protected"] + self.assertEqual( + (len(protected["rows"]), protected["truncated"]), (count, False) + ) + self.assertTrue( + protected["rows"][0]["detail"].endswith( + ": kept, edited after it was saved; its empty category or tags " + "may be intentional" + ) + ) + + async def test_policy_off_writes_without_a_backup(self): + PromptManagerConfig.SAFETY_BACKUP = False + _pair, prompt = self.pair("no backup", category="portrait") + plan, _summary, job = await self.run_apply(OPERATION) + self.assertEqual(plan["backup"]["policy"], "off") + self.assertEqual(job.state, "finished", job.log) + self.assertIn("No safety backup (the policy is off)", "\n".join(job.log)) + self.assertTrue(job.status_line.endswith("· No safety backup")) + self.assertEqual(self.backups(), []) + self.assertEqual(self.category(prompt), "portrait") + + async def test_reviewed_with_the_policy_on_keeps_the_backup(self): + self.pair("promise", category="portrait") + plan = await self.preview(OPERATION) + PromptManagerConfig.SAFETY_BACKUP = False + body = await self.apply(plan["token"]) + job = self.api._jobs.get(body["job"]["id"]) + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + self.assertEqual(len(self.backups()), 1) + + async def test_plan_changed_writes_nothing(self): + pair_id, prompt = self.pair("drift", category="portrait") + plan = await self.preview(OPERATION) + self.api.db.update_prompt_pair_metadata(pair_id, category="landscape") + body = await self.apply(plan["token"]) + job = self.api._jobs.get(body["job"]["id"]) + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + self.assertEqual(job.state, "failed") + self.assertEqual(job.last_event["code"], "plan-changed") + self.assertIsNone(self.category(prompt)) + + async def test_report_lists_every_filled_prompt(self): + PromptManagerConfig.SAFETY_BACKUP = False + count = MAX_OUTCOME_ROWS + 5 + for n in range(count): + self.pair(f"bulk {n:03d}", category="portrait") + _plan, _summary, job = await self.run_apply(OPERATION) + self.assertEqual(job.state, "finished", job.log) + self.assertEqual(len(job.outcomes["changed"]), MAX_OUTCOME_ROWS) + response = await self.client.request( + "GET", f"/prompt_manager/jobs/{job.id}/report" + ) + self.assertEqual(response.status, 200) + report = await response.json() + changed = report["outcomes"]["changed"] + self.assertEqual(changed["count"], count) + self.assertEqual(len(changed["rows"]), count) + self.assertFalse(changed["truncated"]) + self.assertTrue( + all( + row["detail"].endswith("category “portrait”") for row in changed["rows"] + ) + ) diff --git a/tests/test_autotag_legacy.py b/tests/test_autotag_legacy.py index f8a379a7..8f5d8d3f 100644 --- a/tests/test_autotag_legacy.py +++ b/tests/test_autotag_legacy.py @@ -158,7 +158,30 @@ class TestLegacyBatch(LegacyTestCase): self.image("b.png", (2, 2, 2)) await self.relist() await self.legacy("model_type=gguf&prompt=%20") - self.assertEqual(self.tagger.calls[-1]["prompt"], AutotagConfig.TEMPLATE) + self.assertEqual( + self.tagger.calls[-1]["prompt"], + AutotagConfig.default_instructions_text("gguf"), + ) + # #431: a blank prompt falls back to the request engine's own default. + saved = (AutotagConfig.INSTRUCTIONS, AutotagConfig.DEFAULT_INSTRUCTIONS) + self.addCleanup( + lambda: ( + setattr(AutotagConfig, "INSTRUCTIONS", saved[0]), + setattr(AutotagConfig, "DEFAULT_INSTRUCTIONS", saved[1]), + ) + ) + AutotagConfig.INSTRUCTIONS = ( + {"id": "0123456789ab", "name": "Photo", "text": "Photo tags."}, + ) + AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"} + for model_type, expected in ( + ("hf", "Photo tags."), + ("gguf", AutotagConfig.default_instructions_text("gguf")), + ): + self.image(f"{model_type}.png", (3, 3, len(model_type))) + await self.relist() + await self.legacy(f"model_type={model_type}&prompt=%20&skip_tagged=false") + self.assertEqual(self.tagger.calls[-1]["prompt"], expected, model_type) self.assertEqual(self.tagger.custom_prompt, "legacy prompt") async def test_a_missing_backend_package_is_one_error_frame(self): diff --git a/tests/test_config.py b/tests/test_config.py index 145b339e..945aa5ab 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -442,9 +442,11 @@ class TestAutotagConfig(unittest.TestCase): "EXISTING", "GENERAL_THRESHOLD", "CHARACTER_THRESHOLD", - "TEMPLATE", "SUGGESTION_LIMIT", "SKIPPED_TAGS", + "INSTRUCTIONS", + "DEFAULT_INSTRUCTIONS", + "_LEGACY_TEMPLATE", ) } @@ -489,16 +491,13 @@ class TestAutotagConfig(unittest.TestCase): "character_threshold": 0.85, "suggestion_limit": 10, "skipped_tags": [], + "instructions": [], + "default_instructions": {"gguf": "builtin", "hf": "builtin"}, } }, ) - # The built-in template is not written; a saved one is. - self.config_class.TEMPLATE = "Saved." - self.assertEqual( - PromptManagerConfig.get_config()["autotag"]["defaults"]["template"], - "Saved.", - ) - self.config_class.TEMPLATE = config_module.DEFAULT_TAG_TEMPLATE + # #431: the old template key is written only as the GGUF default's + # mirror (test_saved_instructions covers it). with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "config.json") PromptManagerConfig.update_config( @@ -526,7 +525,6 @@ class TestAutotagConfig(unittest.TestCase): "existing": "merge", "general_threshold": 0.4, "character_threshold": 0.7, - "template": "Tags only.", "suggestion_limit": 15, "skipped_tags": ("blurry", "closed eyes"), } @@ -535,7 +533,6 @@ class TestAutotagConfig(unittest.TestCase): "existing": "skip", "general_threshold": 0.35, "character_threshold": 0.85, - "template": config_module.DEFAULT_TAG_TEMPLATE, "suggestion_limit": 10, "skipped_tags": (), } @@ -569,7 +566,12 @@ class TestAutotagConfig(unittest.TestCase): k: getattr(self.config_class, attr) for k, (attr, _v, _d) in self.config_class._KEYS.items() } - self.assertEqual(loaded, dict(good, **{key: fallback[key]})) + # The old template is no attribute any more (#431): a bad + # one is only warned about and imports nothing. + expected = ( + dict(good, **{key: fallback[key]}) if key in fallback else good + ) + self.assertEqual(loaded, expected) def test_skipped_tags_load_trimmed_and_warn_once_when_damaged(self): """#431: the list is kept as typed (trimmed); a damaged list is diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 9176ba6a..1eb6c503 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -490,6 +490,22 @@ class TestJobOutcomesAndLog(unittest.TestCase): with self.assertRaises(ValueError): job.record_counted("bogus", 1) + def test_record_counted_complete_keeps_every_row_for_the_report(self): + # #550: a repair's record of each change is never cut at the cap. + job = Job("maintenance", "Pair category and tags", groups=("changed", "errors")) + rows = [ + {"path": "", "detail": f"Prompt {i}"} for i in range(MAX_OUTCOME_ROWS + 5) + ] + job.record_counted("changed", len(rows), rows, complete=True) + self.assertEqual(len(job.outcomes["changed"]), MAX_OUTCOME_ROWS) + report = job.report()["outcomes"] + self.assertEqual(len(report["changed"]["rows"]), MAX_OUTCOME_ROWS + 5) + self.assertEqual( + report["changed"]["rows"][-1]["detail"], f"Prompt {MAX_OUTCOME_ROWS + 4}" + ) + self.assertFalse(report["changed"]["truncated"]) + self.assertEqual(report["errors"], {"count": 0, "rows": [], "truncated": False}) + def test_thumbnail_job_groups(self): self.assertEqual( jobs_module.THUMBNAIL_GROUP_NAMES, ("generated", "skipped", "errors") diff --git a/tests/test_maintenance_pair_metadata.py b/tests/test_maintenance_pair_metadata.py new file mode 100644 index 00000000..df401098 --- /dev/null +++ b/tests/test_maintenance_pair_metadata.py @@ -0,0 +1,539 @@ +"""Pair category and tags repair (#550): the ``pair_metadata`` maintenance plan. + +Before #546 a Pair node run saved its category and tags on the saved pair +only, so the positive prompt it created stayed Uncategorized and untagged. +``pair_metadata`` reviews those prompts and fills only their empty fields +from the prompt's non-archived pairs when every pair with a value agrees. +It never touches a negative, a saved pair or ``updated_at``, and a prompt +the Pair node did not create, that was edited, archived, or whose pairs +disagree, is kept (``550-design.md``). +""" + +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from database import maintenance # noqa: E402 +from database import pair_metadata_repair as repair # noqa: E402 + +try: + from tests.test_maintenance_plans import MaintenanceDatabaseCase # noqa: E402 +except ImportError: # discovered with tests/ as the top-level directory + from test_maintenance_plans import MaintenanceDatabaseCase # noqa: E402 + +OPERATION = "pair_metadata" + + +class PairMetadataCase(MaintenanceDatabaseCase): + def pair(self, positive, negative="", **metadata): + """A pre-#546 Pair save: the pair gets the metadata, the prompt none.""" + pair_id = self.db.save_prompt_pair(positive, negative, **metadata) + return pair_id, self.positive_of(pair_id) + + def positive_of(self, pair_id): + return self.sql( + "SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", pair_id + )[0][0] + + def sql(self, statement, *args): + with self.db.model.get_connection() as conn: + return [tuple(row) for row in conn.execute(statement, args)] + + def run_sql(self, statement, *args): + with self.db.model.get_connection() as conn: + conn.execute(statement, args) + + def category(self, prompt_id): + return self.sql("SELECT category FROM prompts WHERE id=?", prompt_id)[0][0] + + def tags(self, prompt_id): + return sorted( + row[0] + for row in self.sql( + "SELECT t.name FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id " + "WHERE pt.prompt_id=?", + prompt_id, + ) + ) + + def preview(self, operation=OPERATION, roots=None): + return self.db.preview_maintenance(operation, []) + + def apply(self, draft): + return self.db.apply_maintenance( + draft.operation, draft.fingerprint, draft.targets, [] + ) + + def edit_stamp(self, prompt_id, updated_at="2099-01-01T00:00:00+00:00"): + self.run_sql( + "UPDATE prompts SET updated_at=? WHERE id=?", updated_at, prompt_id + ) + + def details(self, draft): + return [(row["label"], row["detail"]) for row in draft.details] + + +class TestClassification(PairMetadataCase): + def test_fills_empty_category_and_tags_from_the_pair(self): + pair_id, prompt = self.pair( + "a cat on a sofa", "blurry", category="portrait", tags=["b", "a"] + ) + draft = self.preview() + self.assertEqual(draft.operation, OPERATION) + self.assertEqual(draft.targets, [[prompt, True, True]]) + self.assertEqual(draft.scope["count"], 1) + self.assertEqual( + draft.scope["label"], "1 prompt to fill: 1 category, 1 tag set" + ) + self.assertEqual(draft.protected, {"count": 0, "reasons": []}) + self.assertEqual( + draft.media, + { + "deletes_files": False, + "label": "No image file or saved pair is changed.", + }, + ) + self.assertIn( + ( + "a cat on a sofa", + f"Fills category “portrait” and 2 tags: a, b (pair {pair_id})", + ), + self.details(draft), + ) + self.assertEqual(draft.details[-1]["label"], "Record") + self.assertEqual(draft.details[-1]["detail"], repair.RECORD_NOTE) + + def test_empty_plan(self): + self.save("plain prompt") + draft = self.preview() + self.assertEqual(draft.targets, []) + self.assertEqual(draft.scope["count"], 0) + self.assertEqual( + draft.scope["label"], "No Pair prompts are missing a category or tags" + ) + self.assertEqual(draft.details, []) + + def test_prompt_already_complete_is_not_counted(self): + _pair, prompt = self.pair("done", category="portrait", tags=["a"]) + self.run_sql("UPDATE prompts SET category='portrait' WHERE id=?", prompt) + self.db.set_prompt_tags(prompt, ["a"]) + self.edit_stamp( + prompt, self.sql("SELECT created_at FROM prompts WHERE id=?", prompt)[0][0] + ) + self.assertEqual(self.preview().scope["count"], 0) + + def test_whitespace_category_is_blank_on_prompt_and_pair(self): + _p1, blank_prompt = self.pair("ws prompt", category=" portrait ") + self.run_sql("UPDATE prompts SET category=' ' WHERE id=?", blank_prompt) + _p2, blank_pair = self.pair("ws pair", category=" ") + draft = self.preview() + self.assertEqual(draft.targets, [[blank_prompt, True, False]]) + self.assertIn("“portrait”", self.details(draft)[0][1]) + self.assertNotIn(blank_pair, [t[0] for t in draft.targets]) + + def test_mixed_fields_category_fills_while_tags_conflict(self): + _a, prompt = self.pair("mixed", "n1", category="portrait", tags=["a"]) + self.pair("mixed", "n2", category="portrait", tags=["b"]) + draft = self.preview() + self.assertEqual(draft.targets, [[prompt, True, False]]) + self.assertEqual(draft.scope["count"], 1) + self.assertEqual(draft.protected["count"], 1) + self.assertEqual(draft.protected["reasons"], ["1 where pairs disagree"]) + self.assertIn(("mixed", "Kept: pairs disagree on tags"), self.details(draft)) + + def test_mixed_fields_tags_fill_while_category_conflicts(self): + _a, prompt = self.pair("mixed2", "n1", category="portrait", tags=["a"]) + self.pair("mixed2", "n2", category="landscape", tags=["a"]) + draft = self.preview() + self.assertEqual(draft.targets, [[prompt, False, True]]) + self.assertIn( + ("mixed2", "Kept: pairs disagree on category: landscape, portrait"), + self.details(draft), + ) + + def test_category_set_and_tags_empty_fills_tags_only(self): + _a, prompt = self.pair("has cat", category="portrait", tags=["a"]) + self.run_sql("UPDATE prompts SET category='mine' WHERE id=?", prompt) + draft = self.preview() + self.assertEqual(draft.targets, [[prompt, False, True]]) + + def test_pairs_without_a_value_are_ignored(self): + _a, prompt = self.pair("partial", "n1", category="portrait", tags=["a"]) + self.pair("partial", "n2") # no category, no tags + self.assertEqual(self.preview().targets, [[prompt, True, True]]) + + def test_equal_values_across_pairs_fill(self): + _a, prompt = self.pair("same", "n1", category="portrait", tags=["a", "b"]) + self.pair("same", "n2", category="portrait", tags=["b", "a"]) + self.assertEqual(self.preview().targets, [[prompt, True, True]]) + + def test_category_is_case_sensitive(self): + _a, prompt = self.pair("case", "n1", category="Portrait") + self.pair("case", "n2", category="portrait") + draft = self.preview() + self.assertEqual(draft.targets, []) + self.assertEqual(draft.protected["count"], 1) + + def test_edited_prompt_is_protected(self): + cases = { + "later": "2099-01-01T00:00:00+00:00", + "null": None, + "legacy": "2020-01-01 10:00:00", + "garbage": "yesterday", + } + for name, stamp in cases.items(): + with self.subTest(name=name): + _a, prompt = self.pair(f"edited {name}", category="portrait") + self.edit_stamp(prompt, stamp) + draft = self.preview() + self.assertNotIn(prompt, [t[0] for t in draft.targets]) + self.assertIn( + ( + f"edited {name}", + "Kept: edited after it was saved; its empty category or " + "tags may be intentional", + ), + self.details(draft), + ) + self.run_sql( + "DELETE FROM prompt_pairs WHERE positive_prompt_id=?", prompt + ) + + def test_first_release_stamps_microseconds_apart_are_never_edited(self): + _a, prompt = self.pair("caa9649f", category="portrait") + self.run_sql( + "UPDATE prompts SET created_at=?, updated_at=? WHERE id=?", + "2026-10-18T10:00:00.000100+00:00", + "2026-10-18T10:00:00.000180+00:00", + prompt, + ) + self.run_sql( + "UPDATE prompt_pairs SET created_at=? WHERE positive_prompt_id=?", + "2026-10-18T10:00:00.000400+00:00", + prompt, + ) + self.assertEqual(self.preview().targets, [[prompt, True, False]]) + + def test_edited_beats_conflict(self): + _a, prompt = self.pair("both", "n1", category="portrait") + self.pair("both", "n2", category="landscape") + self.edit_stamp(prompt) + draft = self.preview() + self.assertEqual(draft.protected["reasons"], ["1 edited after it was saved"]) + + def test_archived_prompt_is_protected(self): + _a, prompt = self.pair("archived", category="portrait") + self.run_sql( + "INSERT INTO prompt_archive(prompt_id, archived_at) VALUES(?, ?)", + prompt, + "2026-10-31T00:00:00+00:00", + ) + draft = self.preview() + self.assertEqual(draft.targets, []) + self.assertEqual(draft.protected["reasons"], ["1 archived in the Library"]) + self.assertIn( + ("archived", "Kept: archived in the Library"), self.details(draft) + ) + + def test_archived_pairs_are_no_source(self): + pair_id, prompt = self.pair("old pair", "n1", category="portrait") + self.db.archive_prompt_pair(pair_id) + draft = self.preview() + self.assertEqual(draft.targets, []) + self.assertEqual(draft.protected["count"], 0) + # An archived pair also never disagrees with a live one. + self.pair("old pair", "n2", category="landscape") + self.assertEqual(self.preview().targets, [[prompt, True, False]]) + self.assertIn("landscape", self.details(self.preview())[0][1]) + + def test_prompt_not_created_by_its_pair_is_out_of_scope(self): + prompt = self.save("saved by the node first") + self.run_sql( + "UPDATE prompts SET created_at=?, updated_at=? WHERE id=?", + "2026-01-01T00:00:00+00:00", + "2026-01-01T00:00:00+00:00", + prompt, + ) + self.db.save_prompt_pair("saved by the node first", category="portrait") + draft = self.preview() + self.assertEqual(draft.targets, []) + self.assertEqual(draft.protected["count"], 0) + + def test_negative_is_never_a_target_and_positive_role_sources_only(self): + _a, both = self.pair("dual", "shared negative", category="portrait") + neg = self.sql("SELECT id FROM prompts WHERE text='shared negative'")[0][0] + # The negative of pair A is the positive of pair B, made later: not + # Pair-created as a positive, so out of scope. + self.db.save_prompt_pair("shared negative", category="landscape") + draft = self.preview() + self.assertEqual(draft.targets, [[both, True, False]]) + self.assertNotIn(neg, [t[0] for t in draft.targets]) + + def test_positive_first_then_negative_is_filled_from_positive_pairs_only(self): + _a, prompt = self.pair("pos first", category="portrait") + self.db.save_prompt_pair("another", "pos first", category="landscape") + draft = self.preview() + self.assertIn([prompt, True, False], draft.targets) + self.assertIn("“portrait”", dict(self.details(draft))["pos first"]) + + def test_more_than_twenty_tags_is_protected(self): + tags = [f"t{n:02d}" for n in range(21)] + _a, prompt = self.pair("many tags", category="portrait", tags=tags) + draft = self.preview() + self.assertEqual(draft.targets, [[prompt, True, False]]) + self.assertEqual( + draft.protected["reasons"], ["1 whose pair has more than 20 tags"] + ) + self.assertIn( + ("many tags", "Kept: its pair has more than 20 tags"), self.details(draft) + ) + + def test_protected_rows_come_first_within_fifty(self): + _c, conflict = self.pair("zz conflict", "n1", category="x") + self.pair("zz conflict", "n2", category="y") + for n in range(60): + self.pair(f"fill {n:02d}", category="portrait") + draft = self.preview() + self.assertEqual(len(draft.details), maintenance.MAX_DETAILS) + self.assertEqual(draft.details[0]["label"], "zz conflict") + self.assertEqual(draft.details[-1]["label"], "Record") + self.assertEqual(draft.scope["count"], 60) + + def test_detail_rows_list_conflicts_before_other_kept_prompts(self): + _a, archived = self.pair("aa archived", category="portrait") + self.run_sql( + "INSERT INTO prompt_archive(prompt_id, archived_at) VALUES(?, ?)", + archived, + "2026-10-31T00:00:00+00:00", + ) + _b, edited = self.pair("bb edited", category="portrait") + self.edit_stamp(edited) + self.pair("zz conflict", "n1", category="x") + self.pair("zz conflict", "n2", category="y") + draft = self.preview() + self.assertEqual( + [label for label, _detail in self.details(draft)], + ["zz conflict", "bb edited", "aa archived"], + ) + # The reasons keep the precedence order. + self.assertEqual( + draft.protected["reasons"], + [ + "1 archived in the Library", + "1 edited after it was saved", + "1 where pairs disagree", + ], + ) + + def test_same_library_same_fingerprint_and_pair_use_does_not_change_it(self): + pair_id, _prompt = self.pair("fp", category="portrait", tags=["a"]) + first = self.preview().fingerprint + self.db.record_prompt_pair_use(pair_id) + self.db.update_prompt_pair_metadata(pair_id, notes="a note", title="t") + self.assertEqual(self.preview().fingerprint, first) + + +class TestPreviewWritesNothing(PairMetadataCase): + def test_preview_reads_one_read_only_snapshot(self): + self.pair("cat", category="portrait", tags=["a"]) + before = self.sql("SELECT * FROM prompts ORDER BY id") + tags = self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id, tag_id") + connect = maintenance.sqlite3.connect + opened = [] + + def spy(*args, **kwargs): + opened.append(args[0]) + return connect(*args, **kwargs) + + with patch.object(maintenance.sqlite3, "connect", side_effect=spy): + self.preview() + self.assertEqual(len(opened), 1) + self.assertTrue(str(opened[0]).endswith("?mode=ro")) + self.assertEqual(self.sql("SELECT * FROM prompts ORDER BY id"), before) + self.assertEqual( + self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id, tag_id"), tags + ) + + +class TestApply(PairMetadataCase): + def snapshot_pairs(self): + return ( + self.sql("SELECT * FROM prompt_pairs ORDER BY id"), + self.sql("SELECT * FROM prompt_pair_tags ORDER BY pair_id, tag_id"), + ) + + def test_apply_fills_only_empty_fields_and_leaves_pairs_alone(self): + _a, prompt = self.pair("apply me", "neg", category="portrait", tags=["a", "b"]) + _b, partial = self.pair("partial", "n1", category="portrait", tags=["a"]) + self.pair("partial", "n2", category="portrait", tags=["c"]) + neg = self.sql("SELECT id FROM prompts WHERE text='neg'")[0][0] + pairs = self.snapshot_pairs() + stamps = self.sql("SELECT id, created_at, updated_at FROM prompts ORDER BY id") + state = self.sql("SELECT * FROM prompt_library_state ORDER BY prompt_id") + tag_rows = self.sql("SELECT id, name FROM tags ORDER BY id") + draft = self.preview() + result = self.apply(draft) + self.assertEqual(result["changed"], 2) + self.assertEqual( + result["rows"], + [ + {"prompt_id": prompt, "category": "portrait", "tags": ["a", "b"]}, + {"prompt_id": partial, "category": "portrait"}, + ], + ) + self.assertEqual(result["categories"], 2) + self.assertEqual(result["tag_sets"], 1) + self.assertEqual( + (self.category(prompt), self.tags(prompt)), ("portrait", ["a", "b"]) + ) + self.assertEqual((self.category(partial), self.tags(partial)), ("portrait", [])) + self.assertEqual((self.category(neg), self.tags(neg)), (None, [])) + self.assertEqual(self.snapshot_pairs(), pairs) + self.assertEqual( + self.sql("SELECT id, created_at, updated_at FROM prompts ORDER BY id"), + stamps, + ) + self.assertEqual( + self.sql("SELECT * FROM prompt_library_state ORDER BY prompt_id"), state + ) + self.assertEqual(self.sql("SELECT id, name FROM tags ORDER BY id"), tag_rows) + + def test_apply_returns_one_kept_row_per_protected_prompt(self): + _a, conflict = self.pair("conflict", "n1", category="asian") + self.pair("conflict", "n2", category="Adult") + _b, edited = self.pair("edited", category="portrait") + self.edit_stamp(edited) + _c, both = self.pair( + "both", "n1", category="x", tags=[f"t{n}" for n in range(21)] + ) + self.pair("both", "n2", category="y", tags=[f"t{n}" for n in range(21)]) + _d, filled = self.pair("fill", category="portrait") + draft = self.preview() + result = self.apply(draft) + self.assertEqual( + result["kept"], + [ + { + "prompt_id": conflict, + "reasons": ["pairs disagree on category: Adult, asian"], + }, + { + "prompt_id": edited, + "reasons": [ + "edited after it was saved; its empty category or tags " + "may be intentional" + ], + }, + { + "prompt_id": both, + "reasons": [ + "pairs disagree on category: x, y", + "its pair has more than 20 tags", + ], + }, + ], + ) + self.assertEqual(len(result["kept"]), draft.protected["count"]) + self.assertEqual(self.category(filled), "portrait") + + def test_kept_rows_are_never_capped(self): + for n in range(205): + _p, prompt = self.pair(f"kept {n:03d}", category="portrait") + self.edit_stamp(prompt) + result = self.apply(self.preview()) + self.assertEqual(result["changed"], 0) + self.assertEqual(len(result["kept"]), 205) + + def test_second_run_finds_nothing_and_a_partial_fill_completes_later(self): + _a, prompt = self.pair("partial", "n1", category="portrait", tags=["a"]) + p2, _same = self.pair("partial", "n2", category="portrait", tags=["c"]) + self.apply(self.preview()) + again = self.preview() + self.assertEqual(again.scope["count"], 0) + self.assertEqual(again.protected["count"], 1) # tags still disagree + self.db.update_prompt_pair_metadata(p2, tags=["a"]) + self.apply(self.preview()) + self.assertEqual(self.tags(prompt), ["a"]) + self.assertEqual(self.preview().protected["count"], 0) + + def test_drift_raises_plan_changed_and_writes_nothing(self): + def pair_category(ids): + self.db.update_prompt_pair_metadata(ids[0], category="landscape") + + def prompt_category(ids): + self.run_sql("UPDATE prompts SET category='mine' WHERE id=?", ids[1]) + + def new_pair(ids): + self.db.save_prompt_pair("drift", "other neg", category="landscape") + + def tag_rename(ids): + tag = self.sql("SELECT id FROM tags WHERE name='a'")[0][0] + self.run_sql("UPDATE tags SET name='renamed' WHERE id=?", tag) + + def edited(ids): + self.edit_stamp(ids[1]) + + for change in (pair_category, prompt_category, new_pair, tag_rename, edited): + with self.subTest(change=change.__name__): + ids = self.pair("drift", "n", category="portrait", tags=["a"]) + draft = self.preview() + change(ids) + before = self.sql("SELECT id, category FROM prompts ORDER BY id") + tags = self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id") + with self.assertRaises(maintenance.PlanChanged): + self.apply(draft) + self.assertEqual( + self.sql("SELECT id, category FROM prompts ORDER BY id"), before + ) + self.assertEqual( + self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id"), tags + ) + self.run_sql("DELETE FROM prompt_pairs") + self.run_sql("DELETE FROM prompts") + self.run_sql("DELETE FROM tags") + + def test_pair_run_between_preview_and_apply_still_applies(self): + pair_id, prompt = self.pair("busy", category="portrait") + draft = self.preview() + self.db.record_prompt_pair_use(pair_id) + self.apply(draft) + self.assertEqual(self.category(prompt), "portrait") + + def test_guard_failure_rolls_back_everything(self): + _a, first = self.pair("first", category="portrait") + _b, second = self.pair("second", category="portrait") + draft = self.preview() + real = repair._fill_categories + + def racing(conn, fills): + # Another writer's value appears inside the transaction. + conn.execute("UPDATE prompts SET category='x' WHERE id=?", (second,)) + return real(conn, fills) + + with patch.object(repair, "_fill_categories", side_effect=racing): + with self.assertRaises(maintenance.PlanChanged): + self.apply(draft) + self.assertIsNone(self.category(first)) + self.assertIsNone(self.category(second)) + + def test_more_than_two_hundred_fills_are_all_returned(self): + for n in range(205): + self.pair(f"bulk {n:03d}", category="portrait") + result = self.apply(self.preview()) + self.assertEqual(result["changed"], 205) + self.assertEqual(len(result["rows"]), 205) + + def test_batches_bind_few_ids(self): + for n in range(5): + self.pair(f"batch {n}", category="portrait", tags=["a"]) + with patch.object(maintenance, "ID_BATCH", 2): + draft = self.preview() + result = self.apply(draft) + self.assertEqual(result["changed"], 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_saved_instructions.py b/tests/test_saved_instructions.py new file mode 100644 index 00000000..f7f4b09f --- /dev/null +++ b/tests/test_saved_instructions.py @@ -0,0 +1,379 @@ +"""Saved instructions with a default per language-model engine (#431, PR 2). + +The entry and default validation, the in-memory migration of the old single +``autotag.defaults.template`` at load (first upgrade and re-upgrade), the +load fallbacks, and ``AutotagConfig.default_instructions_text``. +""" + +import hashlib +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock + +# config.py imports ComfyUI's server module; stand it in (as test_config does). +_mock_server = MagicMock() +_mock_server.PromptServer.instance.routes = MagicMock() +sys.modules.setdefault("server", _mock_server) + +from py.config import AutotagConfig, PromptManagerConfig # noqa: E402 +from py.tagging_defaults import ( # noqa: E402 + BUILTIN_INSTRUCTIONS_ID, + DEFAULT_TAG_TEMPLATE, + MAX_SAVED_INSTRUCTIONS, + validate_default_instructions, + validate_instructions, +) + +ATTRS = ("MODEL_TYPE", "INSTRUCTIONS", "DEFAULT_INSTRUCTIONS", "_LEGACY_TEMPLATE") + + +def entry(id_, name, text="Tags only."): + return {"id": id_, "name": name, "text": text} + + +def sha12(text): + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12] + + +class TestValidation(unittest.TestCase): + def test_entries_are_kept_with_trimmed_names(self): + self.assertEqual( + validate_instructions([entry("0123456789ab", " Photo ", "Tags.")]), + [entry("0123456789ab", "Photo", "Tags.")], + ) + self.assertEqual(validate_instructions([]), []) + + def test_every_refusal_sentence(self): + ok = entry("0123456789ab", "One") + cases = ( + ("x", "Saved instructions must be a list."), + ( + [ + entry(f"{i:012x}", f"n{i}") + for i in range(MAX_SAVED_INSTRUCTIONS + 1) + ], + "Saved instructions: at most 20 entries besides Built-in.", + ), + ([ok, "x"], "Saved instructions: entry 2 has an invalid id."), + ( + [entry("ABCDEF012345", "A")], + "Saved instructions: entry 1 has an invalid id.", + ), + ([entry("builtin", "A")], "Saved instructions: entry 1 has an invalid id."), + ( + [{"id": "0123456789ab", "name": "A"}], + "Saved instructions: entry 1 has an invalid id.", + ), + ( + [ok, entry("0123456789ab", "Two")], + "Saved instructions: entry 2 repeats the id of entry 1.", + ), + ( + [entry("0123456789ab", " ")], + "Saved instructions: entry 1 needs a name.", + ), + ( + [entry("0123456789ab", "x" * 61)], + "Saved instructions: entry 1 name must be at most 60 characters.", + ), + ( + [entry("0123456789ab", "a\nb")], + "Saved instructions: entry 1 name must not contain a line break.", + ), + ( + [ok, entry("0123456789ac", "one")], + "Saved instructions: entry 2 name “one” is already used.", + ), + ( + [entry("0123456789ab", "BUILT-IN")], + "Saved instructions: the name “Built-in” is reserved.", + ), + ( + [entry("0123456789ab", "A", " ")], + "Saved instructions: entry 1 text must be non-empty text.", + ), + ( + [entry("0123456789ab", "A", "x" * 4001)], + "Saved instructions: entry 1 text must be at most 4000 characters.", + ), + ) + for value, message in cases: + with self.subTest(message=message): + with self.assertRaises(ValueError) as caught: + validate_instructions(value) + self.assertEqual(str(caught.exception), message) + + def test_defaults_name_built_in_or_a_saved_entry(self): + saved = [entry("0123456789ab", "One")] + self.assertEqual( + validate_default_instructions( + {"gguf": "builtin", "hf": "0123456789ab"}, saved + ), + {"gguf": "builtin", "hf": "0123456789ab"}, + ) + for value, message in ( + ( + {"gguf": "ffffffffffff", "hf": "builtin"}, + "Default for GGUF JoyCaption must be one of the saved instructions.", + ), + ( + {"gguf": "builtin", "hf": "nope"}, + "Default for HuggingFace JoyCaption must be one of the saved " + "instructions.", + ), + ( + {"gguf": "builtin"}, + "Default instructions must name GGUF and HuggingFace.", + ), + ( + {"gguf": [], "hf": "builtin"}, + "Default for GGUF JoyCaption must be one of the saved instructions.", + ), + ("builtin", "Default instructions must name GGUF and HuggingFace."), + ): + with self.subTest(value=value): + with self.assertRaises(ValueError) as caught: + validate_default_instructions(value, saved) + self.assertEqual(str(caught.exception), message) + + +class ConfigTestCase(unittest.TestCase): + def setUp(self): + saved = {attr: getattr(AutotagConfig, attr) for attr in ATTRS} + self.addCleanup( + lambda: [setattr(AutotagConfig, a, v) for a, v in saved.items()] + ) + AutotagConfig.update_config({"defaults": {}}) + + def load(self, defaults, warnings=0): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "config.json") + with open(path, "w", encoding="utf-8") as handle: + json.dump({"autotag": {"defaults": defaults}}, handle) + before = os.stat(path).st_mtime_ns, open(path, "rb").read() + if warnings: + with self.assertLogs("prompt_manager.config", level="WARNING") as logs: + PromptManagerConfig.load_from_file(path) + self.assertEqual(len(logs.records), warnings, logs.output) + self.logs = [record.getMessage() for record in logs.records] + else: + PromptManagerConfig.load_from_file(path) + # Nothing is written at load. + self.assertEqual( + (os.stat(path).st_mtime_ns, open(path, "rb").read()), before + ) + + def state(self): + return [dict(e) for e in AutotagConfig.INSTRUCTIONS], dict( + AutotagConfig.DEFAULT_INSTRUCTIONS + ) + + +class TestLoad(ConfigTestCase): + def test_a_new_install_has_built_in_only_for_both_engines(self): + self.load({}) + self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"})) + self.assertEqual( + AutotagConfig.default_instructions_text("gguf"), DEFAULT_TAG_TEMPLATE + ) + self.assertEqual( + AutotagConfig.all_instructions()[0], + { + "id": BUILTIN_INSTRUCTIONS_ID, + "name": "Built-in", + "text": DEFAULT_TAG_TEMPLATE, + }, + ) + + def test_saved_entries_and_per_engine_defaults_load(self): + saved = [ + entry("0123456789ab", "Photo", "Photo tags."), + entry("0123456789ac", "Anime", "Anime tags."), + ] + self.load( + { + "instructions": saved, + "default_instructions": {"gguf": "0123456789ac", "hf": "0123456789ab"}, + } + ) + self.assertEqual( + self.state(), (saved, {"gguf": "0123456789ac", "hf": "0123456789ab"}) + ) + self.assertEqual(AutotagConfig.default_instructions_text("gguf"), "Anime tags.") + self.assertEqual(AutotagConfig.default_instructions_text("hf"), "Photo tags.") + with self.assertRaises(ValueError): + AutotagConfig.default_instructions_text("wd14-vit") + + def test_a_dangling_default_falls_back_to_built_in_with_a_warning(self): + saved = [entry("0123456789ab", "Photo")] + self.load( + { + "instructions": saved, + "default_instructions": {"gguf": "ffffffffffff", "hf": "0123456789ab"}, + }, + warnings=1, + ) + self.assertEqual( + self.state(), (saved, {"gguf": "builtin", "hf": "0123456789ab"}) + ) + self.assertEqual( + self.logs, + [ + "autotag.defaults.default_instructions.gguf names no saved " + "instructions; using Built-in" + ], + ) + + def test_a_damaged_list_falls_back_to_built_in_only_and_the_defaults_follow(self): + self.load( + { + "instructions": [ + entry("0123456789ab", "A"), + entry("0123456789ab", "B"), + ], + "default_instructions": {"gguf": "0123456789ab", "hf": "builtin"}, + }, + warnings=1, # one warning; the defaults follow the list silently + ) + self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"})) + self.assertEqual( + self.logs[0], + "autotag.defaults.instructions entry 2 repeats the id of entry 1; " + "using Built-in only", + ) + + def test_damaged_defaults_fall_back_with_a_warning(self): + self.load({"default_instructions": "builtin"}, warnings=1) + self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"})) + + +class TestMigration(ConfigTestCase): + CUSTOM = "My own tags, please." + + def test_first_upgrade_makes_custom_template_the_default_for_both(self): + self.load({"template": self.CUSTOM}) + self.assertEqual( + self.state(), + ( + [entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)], + {"gguf": sha12(self.CUSTOM), "hf": sha12(self.CUSTOM)}, + ), + ) + # Deterministic: a second load gives the same ids. + first = self.state() + self.load({"template": self.CUSTOM}) + self.assertEqual(self.state(), first) + + def test_a_template_equal_to_built_in_creates_no_entry(self): + self.load({"template": DEFAULT_TAG_TEMPLATE}) + self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"})) + + def test_an_invalid_template_is_ignored_with_todays_warning(self): + self.load({"template": " "}, warnings=1) + self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"})) + self.assertIn("autotag.defaults.template", self.logs[0]) + + def test_a_stored_default_is_never_overwritten_by_the_first_upgrade(self): + self.load( + { + "template": self.CUSTOM, + "default_instructions": {"gguf": "builtin", "hf": "builtin"}, + }, + warnings=0, + ) + self.assertEqual( + self.state(), + ( + [entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)], + {"gguf": "builtin", "hf": "builtin"}, + ), + ) + + def test_the_mirror_of_a_saved_default_imports_nothing(self): + saved = [entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)] + both = {"gguf": sha12(self.CUSTOM), "hf": "builtin"} + self.load( + { + "template": self.CUSTOM, + "instructions": saved, + "default_instructions": both, + } + ) + self.assertEqual(self.state(), (saved, both)) + + def test_re_upgrade_imports_an_edit_made_on_an_older_build(self): + saved = [entry(sha12(self.CUSTOM), "Custom template", "Edited since.")] + both = {"gguf": sha12(self.CUSTOM), "hf": "builtin"} + with self.assertLogs("prompt_manager.config", level="INFO") as logs: + self.load( + { + "template": self.CUSTOM, + "instructions": saved, + "default_instructions": both, + } + ) + instructions, defaults = self.state() + # The text's own hash is taken by the edited entry: the next free one. + imported = hashlib.sha256((self.CUSTOM + "\n2").encode()).hexdigest()[:12] + self.assertEqual( + instructions, saved + [entry(imported, "Custom template (2)", self.CUSTOM)] + ) + self.assertEqual(defaults, both) + self.assertIn( + "Imported the tagging template edited on an older version as " + "'Custom template (2)'", + "\n".join(logs.output), + ) + + def test_re_upgrade_into_a_full_list_imports_nothing_and_warns(self): + saved = [entry(f"{i:012x}", f"n{i}") for i in range(MAX_SAVED_INSTRUCTIONS)] + self.load({"template": self.CUSTOM, "instructions": saved}, warnings=1) + self.assertEqual(self.state()[0], saved) + self.assertIn("full", self.logs[0]) + + +class TestGetConfig(ConfigTestCase): + def test_get_config_writes_the_lists_and_the_gguf_mirror(self): + AutotagConfig.INSTRUCTIONS = (entry("0123456789ab", "Photo", "Photo tags."),) + AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "0123456789ab", "hf": "builtin"} + defaults = AutotagConfig.get_config()["defaults"] + self.assertEqual( + defaults["instructions"], [entry("0123456789ab", "Photo", "Photo tags.")] + ) + self.assertEqual( + defaults["default_instructions"], {"gguf": "0123456789ab", "hf": "builtin"} + ) + self.assertEqual(defaults["template"], "Photo tags.") + AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"} + self.assertNotIn("template", AutotagConfig.get_config()["defaults"]) + + +class TestDamagedValues(ConfigTestCase): + def test_a_non_text_default_falls_back_with_a_warning(self): + self.load({"default_instructions": {"gguf": [], "hf": "builtin"}}, warnings=1) + self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"})) + + def test_a_damaged_list_warns_once_and_migrates_the_template_afresh(self): + custom = "My own tags, please." + self.load( + { + "template": custom, + "instructions": "broken", + "default_instructions": {"gguf": "0123456789ab", "hf": "builtin"}, + }, + warnings=1, + ) + self.assertEqual( + self.state(), + ( + [entry(sha12(custom), "Custom template", custom)], + {"gguf": sha12(custom), "hf": sha12(custom)}, + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_saved_instructions_routes.py b/tests/test_saved_instructions_routes.py new file mode 100644 index 00000000..0eee2e46 --- /dev/null +++ b/tests/test_saved_instructions_routes.py @@ -0,0 +1,224 @@ +"""Saved instructions through POST /settings and the legacy fallbacks (#431). + +Server-side pairing (a save carrying either key writes both, the missing one +from the running, migrated configuration), the downgrade mirror in the old +``template`` key, the refusals, GET /settings, and the legacy single route's +blank-prompt fallback per request engine. The "restart" is a reload of the +isolated config.json (``PROMPT_MANAGER_CONFIG_PATH`` in the temp tree). +""" + +import hashlib + +try: + from test_settings_sources import SourcesTestCase + from test_autotag_legacy import LegacyTestCase +except ImportError: + from tests.test_settings_sources import SourcesTestCase + from tests.test_autotag_legacy import LegacyTestCase + +from py.config import AutotagConfig, PromptManagerConfig +from py.tagging_defaults import DEFAULT_TAG_TEMPLATE + +ATTRS = ("MODEL_TYPE", "INSTRUCTIONS", "DEFAULT_INSTRUCTIONS", "_LEGACY_TEMPLATE") +SETTINGS = "/prompt_manager/settings" +CUSTOM = "My own tags, please." +CUSTOM_ID = hashlib.sha256(CUSTOM.encode()).hexdigest()[:12] + + +def entry(id_, name, text): + return {"id": id_, "name": name, "text": text} + + +class InstructionsTestCase(SourcesTestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + saved = {attr: getattr(AutotagConfig, attr) for attr in ATTRS} + self.addCleanup( + lambda: [setattr(AutotagConfig, a, v) for a, v in saved.items()] + ) + + def restart(self): + PromptManagerConfig.load_from_file(self.config_path) + + async def save(self, payload, revision=True): + if revision: + payload = {"expected_revision": (await self._get())["revision"], **payload} + return await self._post(SETTINGS, payload) + + def disk(self): + return self._read_config()["autotag"]["defaults"] + + +class TestMigrationPersists(InstructionsTestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self._write_config( + {"custom": {"keep": True}, "autotag": {"defaults": {"template": CUSTOM}}} + ) + self.restart() + + async def test_get_shows_the_migrated_entry_and_writes_nothing(self): + before = self._config_bytes() + settings = (await self._get())["settings"] + self.assertEqual( + settings["autotag_instructions"], + [entry(CUSTOM_ID, "Custom template", CUSTOM)], + ) + self.assertEqual( + settings["autotag_default_instructions"], + {"gguf": CUSTOM_ID, "hf": CUSTOM_ID}, + ) + self.assertEqual(settings["autotag_template"], CUSTOM) + self.assertEqual(settings["autotag_builtin_instructions"], DEFAULT_TAG_TEMPLATE) + self.assertEqual(self._config_bytes(), before) + + async def test_a_save_of_only_the_hf_default_writes_the_migrated_list_too(self): + status, data = await self.save( + {"autotag_default_instructions": {"gguf": CUSTOM_ID, "hf": "builtin"}} + ) + self.assertEqual(status, 200, data) + disk = self.disk() + self.assertEqual( + disk["instructions"], [entry(CUSTOM_ID, "Custom template", CUSTOM)] + ) + self.assertEqual( + disk["default_instructions"], {"gguf": CUSTOM_ID, "hf": "builtin"} + ) + self.assertEqual(disk["template"], CUSTOM) + self.assertEqual(self._read_config()["custom"], {"keep": True}) + self.restart() + self.assertEqual( + (list(AutotagConfig.INSTRUCTIONS), AutotagConfig.DEFAULT_INSTRUCTIONS), + ( + [entry(CUSTOM_ID, "Custom template", CUSTOM)], + {"gguf": CUSTOM_ID, "hf": "builtin"}, + ), + ) + + async def test_gguf_built_in_deletes_the_mirror_and_keeps_custom_template(self): + status, data = await self.save( + {"autotag_default_instructions": {"gguf": "builtin", "hf": CUSTOM_ID}} + ) + self.assertEqual(status, 200, data) + self.assertNotIn("template", self.disk()) + self.restart() + self.assertEqual( + list(AutotagConfig.INSTRUCTIONS), + [entry(CUSTOM_ID, "Custom template", CUSTOM)], + ) + self.assertEqual( + AutotagConfig.DEFAULT_INSTRUCTIONS, {"gguf": "builtin", "hf": CUSTOM_ID} + ) + self.assertEqual( + AutotagConfig.default_instructions_text("gguf"), DEFAULT_TAG_TEMPLATE + ) + + async def test_a_save_of_only_the_list_keeps_the_defaults_that_still_exist(self): + photo = entry("0123456789ab", "Photo", "Photo tags.") + status, data = await self.save( + { + "autotag_instructions": [ + entry(CUSTOM_ID, "Custom template", CUSTOM), + photo, + ] + } + ) + self.assertEqual(status, 200, data) + self.assertEqual( + self.disk()["default_instructions"], {"gguf": CUSTOM_ID, "hf": CUSTOM_ID} + ) + self.assertEqual(AutotagConfig.INSTRUCTIONS[1], photo) + # A list that drops a default without moving it is refused, nothing written. + before = self._config_bytes() + status, data = await self.save({"autotag_instructions": [photo]}) + self.assertEqual(status, 400, data) + self.assertEqual( + data["error"], + "Default for GGUF JoyCaption must be one of the saved instructions.", + ) + self.assertEqual(self._config_bytes(), before) + + +class TestRefusals(InstructionsTestCase): + async def test_the_old_template_key_is_refused(self): + before = self._config_bytes() + status, data = await self.save({"autotag_template": "Tags only."}) + self.assertEqual( + (status, data["error"]), + ( + 400, + "LLM tag template was replaced by Saved instructions. Reload the page.", + ), + ) + self.assertEqual(self._config_bytes(), before) + + async def test_instruction_keys_without_a_revision_are_refused(self): + for payload in ( + {"autotag_instructions": []}, + {"autotag_default_instructions": {"gguf": "builtin", "hf": "builtin"}}, + ): + with self.subTest(payload=payload): + status, data = await self.save(payload, revision=False) + self.assertEqual( + (status, data["error"]), (400, "Reload the page and try again.") + ) + + async def test_entry_refusals_name_the_entry(self): + status, data = await self.save( + { + "autotag_instructions": [ + entry("0123456789ab", "Photo", "Photo tags."), + entry("0123456789ac", "photo", "x"), + ] + } + ) + self.assertEqual( + (status, data["error"]), + (400, "Saved instructions: entry 2 name “photo” is already used."), + ) + + async def test_a_new_entry_can_become_a_default_in_one_save(self): + photo = entry("0123456789ab", "Photo", "Photo tags.") + status, data = await self.save( + { + "autotag_instructions": [photo], + "autotag_default_instructions": { + "gguf": "0123456789ab", + "hf": "builtin", + }, + } + ) + self.assertEqual(status, 200, data) + self.assertEqual(self.disk()["template"], "Photo tags.") + settings = (await self._get())["settings"] + self.assertEqual(settings["autotag_template"], "Photo tags.") + self.assertEqual(AutotagConfig.default_instructions_text("gguf"), "Photo tags.") + self.assertEqual(AutotagConfig._LEGACY_TEMPLATE, "Photo tags.") + + +class TestLegacySingleFallback(LegacyTestCase): + async def test_a_blank_prompt_uses_the_request_engines_default(self): + saved = {attr: getattr(AutotagConfig, attr) for attr in ATTRS} + self.addCleanup( + lambda: [setattr(AutotagConfig, a, v) for a, v in saved.items()] + ) + AutotagConfig.INSTRUCTIONS = (entry("0123456789ab", "Photo", "Photo tags."),) + AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"} + prompt = self.prompt("a prompt") + image = self.link(prompt, self.image("a.png", (1, 2, 3), sees=["bird"])) + for model_type, expected in ( + ("hf", "Photo tags."), + ("gguf", DEFAULT_TAG_TEMPLATE), + ): + with self.subTest(model_type=model_type): + status, data = await self.single( + {"image_id": image, "model_type": model_type, "prompt": " "} + ) + self.assertEqual(status, 200, data) + self.assertEqual(self.tagger.calls[-1]["prompt"], expected) + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/tests/test_settings_sources.py b/tests/test_settings_sources.py index ef05eea7..40cb326f 100644 --- a/tests/test_settings_sources.py +++ b/tests/test_settings_sources.py @@ -1023,16 +1023,17 @@ class TestAutotagDefaultsSettings(SourcesTestCase): "EXISTING", "GENERAL_THRESHOLD", "CHARACTER_THRESHOLD", - "TEMPLATE", "SUGGESTION_LIMIT", "SKIPPED_TAGS", + "INSTRUCTIONS", + "DEFAULT_INSTRUCTIONS", + "_LEGACY_TEMPLATE", ) SAVED = { "autotag_model_type": "gguf", "autotag_existing": "merge", "autotag_general_threshold": 0.4, "autotag_character_threshold": 0.8, - "autotag_template": "Tags only.", "autotag_suggestion_limit": 15, } @@ -1068,9 +1069,11 @@ class TestAutotagDefaultsSettings(SourcesTestCase): "EXISTING": "merge", "GENERAL_THRESHOLD": 0.4, "CHARACTER_THRESHOLD": 0.8, - "TEMPLATE": "Tags only.", "SUGGESTION_LIMIT": 15, "SKIPPED_TAGS": (), + "INSTRUCTIONS": (), + "DEFAULT_INSTRUCTIONS": {"gguf": "builtin", "hf": "builtin"}, + "_LEGACY_TEMPLATE": None, }, ) settings = (await self._get())["settings"] @@ -1141,11 +1144,11 @@ class TestAutotagDefaultsSettings(SourcesTestCase): "replace", "Existing tags must be skip, merge or replace_after_review.", ), - ("autotag_template", " ", "LLM tag template must be non-empty text."), + # #431: the old template is refused whatever it holds. ( "autotag_template", - "x" * 4001, - "LLM tag template must be at most 4000 characters.", + "Tags only.", + "LLM tag template was replaced by Saved instructions. Reload the page.", ), ( "autotag_suggestion_limit", diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 72ef55d5..dae0d0d9 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -629,6 +629,12 @@ "functions": 95, "statements": 98 }, + "workspace/system-instructions.mjs": { + "lines": 80, + "branches": 80, + "functions": 80, + "statements": 80 + }, "workspace/system-skipped-tags.mjs": { "lines": 80, "branches": 80, diff --git a/web/js/workspace/activity-job-view.mjs b/web/js/workspace/activity-job-view.mjs index 5d125636..cd2edf01 100644 --- a/web/js/workspace/activity-job-view.mjs +++ b/web/js/workspace/activity-job-view.mjs @@ -11,6 +11,9 @@ * - Results (`section.surface`): the outcome list (`button[aria-pressed]` with counts, in the mockup's order), a path * filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and * Already known open the file in the existing viewer through its listing identity (D57); the others are plain text. + * - #550: a row with no path is a record (a maintenance apply's "Prompt 12: category …"): its detail is shown as plain + * text, never opened; the filter reads it. A maintenance plan of records (every operation but Missing-file links) + * says "records" where a file job says "files" (`groupCopy`). * - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`. * - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`): * an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads @@ -44,6 +47,10 @@ export const DETAIL_INTERVAL_MS = 1000; export const NOT_FOUND = 'This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.'; export const EMPTY_GROUP = 'No files in this group.'; export const NO_MATCH = 'No listed file matches this filter.'; +export const EMPTY_RECORDS = 'No records in this group.'; +export const NO_RECORD_MATCH = 'No listed record matches this filter.'; +const FILE_FILTER = 'Filter by file path…'; +const RECORD_FILTER = 'Filter records…'; export const VIEWER_HINT = 'each row opens the image in the viewer'; export const FILE_GONE = 'This file is no longer in Images. Rescan from Import and try again.'; export const OPEN_FAILED = 'The file could not be opened. Try again.'; @@ -68,10 +75,11 @@ export function parseDetail(raw) { const groups = raw.outcomes !== null && typeof raw.outcomes === 'object' ? raw.outcomes : {}; for (const [group, rows] of Object.entries(groups)) { if (!Array.isArray(rows)) continue; - outcomes[group] = rows.filter(row => row && isText(row.path) && row.path) + // A file row has a path; a record row (#550) has none, only its detail. + outcomes[group] = rows.filter(row => row && isText(row.path) && (row.path || (isText(row.detail) && row.detail))) .map(row => ({ path: row.path, root: isText(row.root) ? row.root : '', root_index: Number.isSafeInteger(row.root_index) && row.root_index >= 0 ? row.root_index : null, - detail: isText(row.detail) ? row.detail : '' })); + detail: isText(row.detail) ? row.detail : '', record: !row.path })); } const log = Array.isArray(raw.log) ? raw.log.filter(isText) : []; return { job, outcomes, log }; @@ -129,11 +137,23 @@ export function resultsNote(shown, total, openable) { return parts.length === 1 && openable && shown >= total ? `${text[0].toUpperCase()}${text.slice(1)}.` : text; } -/** Rows whose path (or root label and path) holds the case-folded filter. */ +/** Rows whose path (or root label and path), or a record's detail (#550), holds the case-folded filter. */ export function filterRows(rows, filter) { const wanted = filter.trim().toLocaleLowerCase(); if (!wanted) return rows; - return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted)); + return rows.filter(row => (row.record ? row.detail : `${row.root}/${row.path}`).toLocaleLowerCase().includes(wanted)); +} + +/** #550: whether a job lists records (a maintenance plan of records), not files. */ +export function listsRecords(job) { + return job?.kind === 'maintenance' && job.scope?.operation !== 'missing_links'; +} + +/** The empty-group, no-match and filter copy: "records" for a records job, "files" otherwise (#550). */ +export function groupCopy(job) { + return listsRecords(job) + ? { empty: EMPTY_RECORDS, noMatch: NO_RECORD_MATCH, placeholder: RECORD_FILTER } + : { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: FILE_FILTER }; } /** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */ @@ -176,7 +196,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { const search = element('label', undefined, 'search-field'); const input = element('input'); input.type = 'search'; - input.placeholder = 'Filter by file path…'; + input.placeholder = FILE_FILTER; input.setAttribute('aria-label', 'Filter results'); search.append(input); resultsPart.heading.append(search); @@ -280,9 +300,13 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { // Listed rows only ever grow, so a row keeps its node across reads: a focused row (or the viewer's trigger) stays. let rowNodes = new Map(); function rowNode(row, index, openable) { - const key = `${selected}\n${index}\n${row.root}\n${row.path}`; + const key = `${selected}\n${index}\n${row.root}\n${row.path}\n${row.record ? row.detail : ''}`; let node = rowNodes.get(key); - if (!node) { + if (!node && row.record) { + // #550: a record is its detail, plain text, with no open or locate action. + node = element('div', undefined, 'file-row record-row'); + node.append(element('span', row.detail, 'why')); + } else if (!node) { node = openable ? createButton('', 'file-row') : element('div', undefined, 'file-row'); node.append(element('span', row.path, 'path'), element('span', row.detail, 'why')); if (openable) node.row = row; @@ -300,8 +324,11 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { const nodes = [...kept.values()]; // The counter is exact; at most 200 of its files are listed, and the filter narrows those. const total = Math.max(job.counters[selected] ?? 0, rows.length); - if (!nodes.length) nodes.push(element('p', rows.length ? NO_MATCH : EMPTY_GROUP, 'empty-state')); - const note = wanted.length ? resultsNote(wanted.length, total, openable) : ''; + const copy = groupCopy(job); + input.placeholder = copy.placeholder; + if (!nodes.length) nodes.push(element('p', rows.length ? copy.noMatch : copy.empty, 'empty-state')); + const opens = openable && wanted.some(row => !row.record); + const note = wanted.length ? resultsNote(wanted.length, total, opens) : ''; if (note) nodes.push(element('p', note, 'small')); for (const child of [...files.children]) if (!nodes.includes(child)) child.remove(); nodes.forEach((node, index) => { diff --git a/web/js/workspace/image-tagging-state.mjs b/web/js/workspace/image-tagging-state.mjs index b1703a11..c6474e1e 100644 --- a/web/js/workspace/image-tagging-state.mjs +++ b/web/js/workspace/image-tagging-state.mjs @@ -31,6 +31,26 @@ const SOURCES = new Set(['images', 'library', 'review', 'tags', 'bulk']); const OPTION_KEYS = new Set(['general_threshold', 'character_threshold', 'instructions', 'suggestion_limit']); const DEFAULTS = Object.freeze({general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10}); const MAX_SUGGESTIONS = 100; +/** + * #431: Built-in's text (`DEFAULT_TAG_TEMPLATE` in py/tagging_defaults.py, pinned equal by + * tests/test_saved_instructions.py), for when `/autotag/models` cannot be read. + */ +export const BUILTIN_INSTRUCTIONS = 'Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).\n' + + 'Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, etc), age_approx (teen, young_woman), ' + + 'body_attributes, face_features, hairstyle, appearance, clothing, accessories, pose, expression, action, camera_angle, ' + + 'camera_shot, scene_composition (wide_shot, long_shot, dutch_angle), environment, background_elements, lighting, mood.\n' + + 'Prioritize visual accuracy.\n' + + 'No sentences, no commentary, no captions. Only tags. Keep length short but descriptive.'; +export const BUILTIN_ID = 'builtin'; +/** The saved instructions when the server cannot say (D204): Built-in only, the default of both engines. */ +const FALLBACK_CHOICES = Object.freeze({ + entries: Object.freeze([Object.freeze({id: BUILTIN_ID, name: 'Built-in', text: BUILTIN_INSTRUCTIONS})]), + defaults: Object.freeze({gguf: BUILTIN_ID, hf: BUILTIN_ID}), +}); +/** The value and label of the select's extra option while the text differs from the chosen entry (D204). */ +export const EDITED_VALUE = 'edited'; +export const EDITED_LABEL = 'Edited for this run'; +export const EDITED_NOTE = 'Edits apply to this run only. Manage saved instructions in System › Auto-tagging.'; const MAX_INSTRUCTION_CHARS = 4000; const EDITABLE = new Set(['ready', 'generate-error', 'write-error', 'conflict']); // Answers after which only a fresh observation can save (the capture or epoch is gone); Retry observes again (D116). @@ -182,27 +202,54 @@ function validateReceipt(value) { return value; } +/** #431: the saved instructions of `/autotag/models` (Built-in first) and each engine's default, else Built-in only. */ +export function readChoices(value) { + const entries = Array.isArray(value?.instructions) ? value.instructions.filter(entry => object(entry) + && typeof entry.id === 'string' && typeof entry.name === 'string' && typeof entry.text === 'string') : []; + if (entries[0]?.id !== BUILTIN_ID) return FALLBACK_CHOICES; + const ids = new Set(entries.map(entry => entry.id)); + const defaults = Object.fromEntries(['gguf', 'hf'].map(engine => { + const id = value?.default_instructions?.[engine]; + return [engine, ids.has(id) ? id : BUILTIN_ID]; + })); + return Object.freeze({entries: Object.freeze(entries.map(entry => Object.freeze({...entry}))), defaults: Object.freeze(defaults)}); +} + +/** The language-model engine whose default a model reads (GGUF for a WD14 model, which takes none). */ +const engineOf = model => (model === 'hf' ? 'hf' : 'gguf'); +/** The default entry of `model`'s engine. */ +export const engineDefault = (choices, model) => choices.entries.find(entry => entry.id === choices.defaults[engineOf(model)]) + ?? choices.entries[0]; +/** The entry to show for `text` (a preset): the one with that text, else the engine's default. */ +const entryFor = (choices, model, text) => choices.entries.find(entry => entry.text === text) ?? engineDefault(choices, model); +/** What the instruction fields hold for `model` with its engine's default (#431). */ +const instructionsFor = (choices, model) => { + const entry = engineDefault(choices, model); + return {instructionId: entry.id, text: entry.text}; +}; + /** * Thresholds, instructions and suggestion count from the server, else the documented defaults, the models' - * download status (`models`, null when unknown) and the saved tagging model (`model`, null when unknown; #328). + * download status (`models`, null when unknown), the saved tagging model (`model`, null when unknown; #328) and the + * saved instructions (`choices`, #431). */ async function readDefaults(api, signal) { try { const value = await api.get(MODELS_URL, {signal, cache: 'no-store'}); - if (!object(value)) return {defaults: DEFAULTS, models: null, model: null}; + if (!object(value)) return {defaults: DEFAULTS, models: null, model: null, choices: FALLBACK_CHOICES}; return { + choices: readChoices(value), model: typeof value.model_type === 'string' && modelOf(value.model_type) ? value.model_type : null, defaults: { general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold, character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold, - instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions, suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit, }, models: object(value.models) ? value.models : null, }; } catch { // The defaults keep the dialog usable; Generate reports a real failure itself. - return {defaults: DEFAULTS, models: null, model: null}; + return {defaults: DEFAULTS, models: null, model: null, choices: FALLBACK_CHOICES}; } } @@ -258,6 +305,8 @@ function initialState(source, preset) { model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null, skippedCount: 0, + // #431 (D204): the saved instructions, each engine's default and the entry the select shows. + instructionChoices: FALLBACK_CHOICES, instructionId: BUILTIN_ID, // Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing. optionsRevision: 0, draftRevision: 0, }; @@ -269,6 +318,13 @@ export function skippedNote(count) { return count === 1 ? ' 1 skipped tag was not suggested.' : ` ${count.toLocaleString('en-US')} skipped tags were not suggested.`; } +/** D204: whether a language model's instructions differ from the chosen saved entry's text (a one-off edit). */ +export function instructionsEdited(state) { + if (modelOf(state.model)?.classifier !== false) return false; + const entry = state.instructionChoices?.entries.find(item => item.id === state.instructionId); + return !entry || entry.text !== state.options.instructions; +} + /** Derived labels and permissions; never stored independently of the fields they follow. */ function derive(state) { const editable = EDITABLE.has(state.phase); @@ -277,6 +333,7 @@ function derive(state) { primaryLabel: state.generated ? 'Approve selected tags' : 'Save manual tags', countText: state.generated ? `${state.suggestions.length} ${noun(state.suggestions.length, 'suggestion')}` : 'Not generated', chipHint: state.suggestions.length ? COPY.chipHint + skippedNote(state.skippedCount) : '', + instructionsEdited: instructionsEdited(state), canSave: editable, canGenerate: editable && state.generateSupported, canRetry: RETRYABLE.has(state.phase), @@ -394,6 +451,10 @@ export function createImageTaggingController({api, target, source = 'images', on if (closed || serial !== openSerial) return; // The saved model (System › Auto-tagging) unless a preset or the user already chose one (#328). const savedModel = !chosen && !modelPicked && read.model ? {model: read.model} : {}; + // #431: the instructions start from the model's engine default; a preset's text wins (D204). + const model = savedModel.model ?? state.model; + const presetText = chosen?.options.instructions; + const entry = presetText === undefined ? engineDefault(read.choices, model) : entryFor(read.choices, model, presetText); observedOnce = true; const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture; // The draft is read now, so text typed while observing is kept (#515). A kept draft stays as typed unless @@ -410,7 +471,8 @@ export function createImageTaggingController({api, target, source = 'images', on savedTags: value.tags.slice(), tagRevision: value.tag_revision, manual: draft === null ? value.tags.join(', ') : changed || unseen ? mergeCurrent(value.tags, draft) : draft, draftRevision: state.draftRevision + 1, - ...savedModel, options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1, + ...savedModel, options: {...defaults, instructions: entry.text, ...chosen?.options}, optionsRevision: state.optionsRevision + 1, + instructionChoices: read.choices, instructionId: entry.id, generateSupported: generate.supported, generateReason: generate.reason, statusText: changed ? `${COPY.changedElsewhere} ${savedNow(value.tags)}${reason ? ` ${reason}` : ''}` : reason, }); @@ -428,15 +490,26 @@ export function createImageTaggingController({api, target, source = 'images', on if (id === state.model) return; stopGenerating(); const phase = state.phase === 'generating' || state.phase === 'generate-error' ? 'ready' : state.phase; + const {instructionId, text} = instructionsFor(state.instructionChoices, id); set({ phase, model: id, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: reasonText(), optionsRevision: state.optionsRevision + 1, - // Like the mock, a model change re-renders its fields with the defaults; the count is kept (D91). + // Like the mock, a model change re-renders its fields with the defaults; the count is kept (D91). The + // instructions start from the new engine's default, dropping a one-off edit (D204). options: {...state.options, general_threshold: defaults.general_threshold, - character_threshold: defaults.character_threshold, instructions: defaults.instructions}, + character_threshold: defaults.character_threshold, instructions: text}, + instructionId, }); } + /** D204: choose a saved entry; its text replaces the instructions (and a one-off edit). */ + function pickInstructions(id) { + if (closed) return; + const entry = state.instructionChoices.entries.find(item => item.id === id); + if (!entry) return; + set({instructionId: id, options: {...state.options, instructions: entry.text}, optionsRevision: state.optionsRevision + 1}); + } + function setOption(key, value) { requireValue(OPTION_KEYS.has(key), 'Unknown tagging option'); set({options: {...state.options, [key]: value}}); @@ -593,7 +666,7 @@ export function createImageTaggingController({api, target, source = 'images', on return { get state() {return state;}, get target() {return imageTarget;}, - subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, retarget, cancel, dispose, + subscribe, open, setManual, setModel, setOption, pickInstructions, generate, remove, save, retry, retarget, cancel, dispose, }; } @@ -612,8 +685,9 @@ export function createTaggingOptionsController({api} = {}) { let abort = null; let modelPicked = false; const deriveOptions = value => ({...value, canGenerate: false, unavailable: modelUnavailable(models, value.model), - canStart: value.phase === 'ready' && !modelUnavailable(models, value.model)}); - let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0})); + canStart: value.phase === 'ready' && !modelUnavailable(models, value.model), instructionsEdited: instructionsEdited(value)}); + let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0, + instructionChoices: FALLBACK_CHOICES, instructionId: BUILTIN_ID})); function set(patch) { if (closed) return; @@ -628,17 +702,28 @@ export function createTaggingOptionsController({api} = {}) { ({defaults, models} = read); // The saved model (System › Auto-tagging) unless the user already chose one (#328). const savedModel = !modelPicked && read.model ? {model: read.model} : {}; - set({phase: 'ready', ...savedModel, options: {...defaults}, optionsRevision: state.optionsRevision + 1}); + const {instructionId, text} = instructionsFor(read.choices, savedModel.model ?? state.model); + set({phase: 'ready', ...savedModel, options: {...defaults, instructions: text}, optionsRevision: state.optionsRevision + 1, + instructionChoices: read.choices, instructionId}); } function setModel(id) { requireValue(modelOf(id), 'Unknown tagging model'); if (closed) return; modelPicked = true; if (id === state.model) return; - // As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91). - set({model: id, optionsRevision: state.optionsRevision + 1, + // As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91), and + // the instructions from the new engine's default (D204). + const {instructionId, text} = instructionsFor(state.instructionChoices, id); + set({model: id, optionsRevision: state.optionsRevision + 1, instructionId, options: {...state.options, general_threshold: defaults.general_threshold, - character_threshold: defaults.character_threshold, instructions: defaults.instructions}}); + character_threshold: defaults.character_threshold, instructions: text}}); + } + /** D204: choose a saved entry; its text replaces the instructions. */ + function pickInstructions(id) { + if (closed) return; + const entry = state.instructionChoices.entries.find(item => item.id === id); + if (!entry) return; + set({instructionId: id, options: {...state.options, instructions: entry.text}, optionsRevision: state.optionsRevision + 1}); } function setOption(key, value) { requireValue(OPTION_KEYS.has(key), 'Unknown tagging option'); @@ -661,6 +746,6 @@ export function createTaggingOptionsController({api} = {}) { } return { get state() {return state;}, - subscribe, open, setModel, setOption, request, dispose, + subscribe, open, setModel, setOption, pickInstructions, request, dispose, }; } diff --git a/web/js/workspace/image-tagging.mjs b/web/js/workspace/image-tagging.mjs index d02699be..6b77ce77 100644 --- a/web/js/workspace/image-tagging.mjs +++ b/web/js/workspace/image-tagging.mjs @@ -17,7 +17,8 @@ * flight at close keeps its capture until it settles, and its outcome is still toasted and announced. */ import { createButton, createScope } from '../shared/toolkit/core.js'; -import { createImageTaggingController, MODEL_OPTIONS, optionError } from './image-tagging-state.mjs'; +import { createImageTaggingController, EDITED_LABEL, EDITED_NOTE, EDITED_VALUE, MODEL_OPTIONS, engineDefault, optionError } + from './image-tagging-state.mjs'; import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { showWorkspaceToast } from './toast.mjs'; @@ -107,9 +108,13 @@ function numberInput(value, {min, max, step}) { * It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog, D85, without Generate: * `generate: false`). Returns the `element`, `validate()` (the fields' native validity, then what the controller would * refuse; true when valid, else the first invalid field reports) and `requestGenerate()`, Generate's own path (field - * validity first), for a Retry after a failed Generate. + * validity first), for a Retry after a failed Generate. D204 (#431): for a language model, a "Saved instructions" select + * (`pickId`) sits above "Tagging instructions": it starts at the engine's default (" · default"), a pick replaces + * the text, and while the text differs from the picked entry it shows "Edited for this run" (not choosable) with a + * muted note under the textarea. */ -export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true}) { +export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true, + pickId = 'image-tag-instructions-pick'}) { const panel = el('div', undefined, 'tag-model-panel'); const select = el('select'); for (const option of MODEL_OPTIONS) { @@ -125,9 +130,35 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im if (withGenerate) panel.append(generate); const inputs = new Map(); let shownModel = null, shownOptions = null; + // D204: the Saved instructions select and the one-off note, while a language model is chosen. + let pick = null, editedNote = null, shownEdited = null; + + /** The select's options: every entry (the engine default marked), plus "Edited for this run" while edited. */ + function fillPick(state) { + shownEdited = state.instructionsEdited; + const fallback = engineDefault(state.instructionChoices, state.model).id; + pick.replaceChildren(...state.instructionChoices.entries.map(entry => { + const option = el('option', entry.id === fallback ? `${entry.name} · default` : entry.name); + option.value = entry.id; + return option; + })); + if (state.instructionsEdited) { + const option = el('option', EDITED_LABEL); + option.value = EDITED_VALUE; + option.disabled = true; + pick.append(option); + } + pick.value = state.instructionsEdited ? EDITED_VALUE : state.instructionId; + editedNote.hidden = !state.instructionsEdited; + // The note describes the textarea while it shows. + const area = inputs.get('instructions'); + if (state.instructionsEdited) area?.setAttribute('aria-describedby', editedNote.id); + else area?.removeAttribute('aria-describedby'); + } function renderOptions(state) { inputs.clear(); + pick = editedNote = null; const classifier = MODEL_OPTIONS.find(option => option.id === state.model).classifier; const rows = []; if (classifier) { @@ -139,10 +170,17 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im } rows.push(pair); } else { + pick = el('select'); + pick.addEventListener('change', () => { if (pick.value !== EDITED_VALUE) controller.pickInstructions(pick.value); }); + rows.push(field('Saved instructions', pick, pickId)); const area = el('textarea'); area.value = state.options.instructions; inputs.set('instructions', area); rows.push(field('Tagging instructions', area, OPTION_IDS.instructions)); + editedNote = el('p', EDITED_NOTE, 'small instructions-edited'); + editedNote.id = `${pickId}-note`; + rows.push(editedNote); + fillPick(state); } // D91: the number of suggestions, for every model. const count = numberInput(state.options.suggestion_limit, {min: '1', max: '100', step: '1'}); @@ -162,7 +200,13 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im shownModel = state.model; shownOptions = state.optionsRevision; select.value = state.model; + // A pick re-renders the fields; keyboard focus stays on the select it was made with. + const picking = pick !== null && document.activeElement === pick; renderOptions(state); + if (picking) pick?.focus(); + } else if (pick && state.instructionsEdited !== shownEdited) { + // Typing never re-renders the fields (I1): only the select and the note follow an edit. + fillPick(state); } setDisabled(generate, !state.canGenerate); } diff --git a/web/js/workspace/images-tagging-bulk.mjs b/web/js/workspace/images-tagging-bulk.mjs index 31fd6294..161392ac 100644 --- a/web/js/workspace/images-tagging-bulk.mjs +++ b/web/js/workspace/images-tagging-bulk.mjs @@ -167,7 +167,8 @@ export function openAutoTag({api, scope, store, images, trigger, onTagged = () = const options = createTaggingOptionsController({api}); // D85: the Tag image dialog's model panel without Generate; its Suggestions field is the job's count (D91). const {element: panel, validate} = renderModelPanel({scope: local, controller: options, - heading: `Auto-tag ${countOf(filter ? count : listed.length, 'image')}`, generate: false}); // D118 + heading: `Auto-tag ${countOf(filter ? count : listed.length, 'image')}`, generate: false, // D118 + pickId: 'bulk-tag-instructions-pick'}); // D204 let choice = APPLY_CHOICES[0]; const actions = el('div', undefined, 'dialog-actions'); const cancel = createButton('Cancel'); diff --git a/web/js/workspace/maintenance-review.mjs b/web/js/workspace/maintenance-review.mjs index 75fcd91a..1a97b4d7 100644 --- a/web/js/workspace/maintenance-review.mjs +++ b/web/js/workspace/maintenance-review.mjs @@ -49,6 +49,8 @@ export const OPERATION_TITLES = Object.freeze({ duplicate_prompts: 'Duplicate prompt text', optimize: 'Optimize database', unused_components: 'Unused prompt components', + // D192 (#550): beyond the concept, System › Maintenance › Database. + pair_metadata: 'Pair category and tags', missing_links: 'Missing-file links', duplicate_files: 'Duplicate image files', // Reviewed and applied through the API and seen in Activity; Maintenance has no row for it (the concept has none). diff --git a/web/js/workspace/system-instructions.mjs b/web/js/workspace/system-instructions.mjs new file mode 100644 index 00000000..ee3e6bfc --- /dev/null +++ b/web/js/workspace/system-instructions.mjs @@ -0,0 +1,351 @@ +/** + * System › Auto-tagging "Saved instructions" (#431, D203): named language-model instructions and the default of each + * language-model engine. It replaces the concept's "LLM tag template" textarea. + * + * A `.setting-block` (`data-setting="tag-instructions"`, `data-key="autotag_instructions"`): the label, the + * description with the count ("N of 20 saved · Built\u2011in is not counted."), a `role="listbox"` list (Built-in first, + * "Read-only", then the saved entries, each with its "Default for GGUF" / "Default for HuggingFace" chips), "+ New", + * "Duplicate" and "Delete", and the editor beside the list (under it below 700 px): "Name" (60 characters, live count) + * and "Text" ("N of 4,000 characters"), both read-only for Built-in with its note. Then two `.setting-row` selects, + * "Default for GGUF JoyCaption" and "Default for HuggingFace JoyCaption" (`data-key="autotag_default_instructions"`), + * and the note a deleted default leaves ("GGUF now uses Built-in."). Host drafts: `autotag_instructions` (the saved + * entries, never Built-in) and `autotag_default_instructions`; the server pairs them on save. Built-in's text is the + * read-only `autotag_builtin_instructions` of GET /settings. Ids are 12 lowercase hex characters made here. + */ +import { createButton } from '../shared/toolkit/core.js'; +import { settingRow } from './system-controls.mjs'; + +export const INSTRUCTIONS_KEY = 'autotag_instructions'; +export const DEFAULTS_KEY = 'autotag_default_instructions'; +export const BUILTIN_TEXT_KEY = 'autotag_builtin_instructions'; +export const BUILTIN_ID = 'builtin'; +export const BUILTIN_NAME = 'Built-in'; +export const MAX_SAVED = 20; +export const MAX_NAME_CHARS = 60; +export const MAX_TEXT_CHARS = 4000; +export const ENGINES = Object.freeze([['gguf', 'GGUF', 'GGUF JoyCaption'], ['hf', 'HuggingFace', 'HuggingFace JoyCaption']]); + +export const INSTRUCTIONS_COPY = Object.freeze({ + label: 'Saved instructions', + description: 'Instructions for the language-model engines. Built-in can’t be changed; duplicate it to make your own.', + count: saved => `${saved} of ${MAX_SAVED} saved · Built\u2011in is not counted.`, + readOnly: 'Read-only', + defaultFor: short => `Default for ${short}`, + newLabel: '+ New', + duplicate: 'Duplicate', + delete: 'Delete', + cap: `At most ${MAX_SAVED} saved instructions.`, + name: 'Name', + text: 'Text', + nameCount: length => `${length} of ${MAX_NAME_CHARS}`, + textCount: length => `${length.toLocaleString('en-US')} of ${MAX_TEXT_CHARS.toLocaleString('en-US')} characters`, + builtinNote: 'Built-in instructions can’t be changed. Duplicate them to make your own.', + newName: 'New instructions', + copyName: name => `${name} copy`, + defaultRow: long => `Default for ${long}`, + defaultDescription: 'The Tag image dialog and batches start from this.', + reset: shorts => `${shorts.join(' and ')} now ${shorts.length > 1 ? 'use' : 'uses'} Built-in.`, +}); + +/** Characters as Python's `len` counts them (code points). */ +const characters = text => [...String(text ?? '')].length; +const fold = text => String(text ?? '').trim().toLowerCase(); + +/** The saved entries of the drafts (`[]` when unread or damaged). */ +export const savedEntries = values => (Array.isArray(values?.[INSTRUCTIONS_KEY]) ? values[INSTRUCTIONS_KEY] : []); + +/** Built-in (its text from GET /settings) and then the saved entries. */ +export function allEntries(values) { + const text = typeof values?.[BUILTIN_TEXT_KEY] === 'string' ? values[BUILTIN_TEXT_KEY] : ''; + return [{ id: BUILTIN_ID, name: BUILTIN_NAME, text }, ...savedEntries(values)]; +} + +/** The drafted default entry of `engine` (`gguf` or `hf`); Built-in when the id names nothing. */ +export function defaultEntry(values, engine) { + const entries = allEntries(values); + const id = values?.[DEFAULTS_KEY]?.[engine]; + return entries.find(entry => entry.id === id) ?? entries[0]; +} + +/** `base`, else `base (2)`, `base (3)`… whichever no entry uses (case-insensitive). */ +export function freeName(base, entries) { + const taken = new Set(entries.map(entry => fold(entry.name))); + if (!taken.has(fold(base))) return base; + for (let n = 2; ; n += 1) if (!taken.has(fold(`${base} (${n})`))) return `${base} (${n})`; +} + +/** " copy" (then "(2)"…), the name cut so the whole stays within the 60-character limit. */ +export function copyName(name, entries) { + for (let room = MAX_NAME_CHARS - ' copy'.length; room > 0; room -= 1) { + const base = [...String(name ?? '').trim()].slice(0, room).join('').trimEnd(); + const candidate = freeName(INSTRUCTIONS_COPY.copyName(base), entries); + if (characters(candidate) <= MAX_NAME_CHARS) return candidate; + } + return freeName(INSTRUCTIONS_COPY.copyName(BUILTIN_NAME), entries); +} + +/** A new entry id: 12 lowercase hex characters from `random` (crypto.getRandomValues), never one already used. */ +export function newId(entries, random = bytes => globalThis.crypto.getRandomValues(bytes)) { + const used = new Set(entries.map(entry => entry.id)); + for (;;) { + const id = [...random(new Uint8Array(6))].map(byte => byte.toString(16).padStart(2, '0')).join(''); + if (!used.has(id)) return id; + } +} + +/** The editor's note for a saved entry's name and text (the mock's short sentences), or ''. */ +export function entryNote(entry, entries) { + const name = String(entry.name ?? '').trim(); + if (!name) return 'Needs a name.'; + if (characters(name) > MAX_NAME_CHARS) return `The name must be at most ${MAX_NAME_CHARS} characters.`; + if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(name)) return 'The name must not contain a line break.'; + if (fold(name) === fold(BUILTIN_NAME)) return 'The name “Built-in” is reserved.'; + if (entries.some(other => other !== entry && other.id !== BUILTIN_ID && fold(other.name) === fold(name))) { + return `The name “${name}” is already used.`; + } + if (!String(entry.text ?? '').trim()) return 'The text must not be empty.'; + if (characters(entry.text) > MAX_TEXT_CHARS) return `The text must be at most ${MAX_TEXT_CHARS.toLocaleString('en-US')} characters.`; + return ''; +} + +/** + * The server's sentence for the first saved entry it would refuse (Built-in is not counted), else null. The Save row + * shows the server's own refusal; this one holds back Preview tagging review and the batch Review. + */ +export function listProblem(values) { + const entries = savedEntries(values); + for (const [index, entry] of entries.entries()) { + // As the server checks them: a repeated name is the later entry's problem. + const note = entryNote(entry, entries.slice(0, index + 1)); + if (!note) continue; + const n = index + 1; + if (note.startsWith('Needs')) return `Saved instructions: entry ${n} needs a name.`; + if (note.includes('reserved')) return 'Saved instructions: the name “Built-in” is reserved.'; + if (note.includes('already used')) return `Saved instructions: entry ${n} name “${String(entry.name).trim()}” is already used.`; + if (note.includes('line break')) return `Saved instructions: entry ${n} name must not contain a line break.`; + if (note.startsWith('The name')) return `Saved instructions: entry ${n} name must be at most ${MAX_NAME_CHARS} characters.`; + if (note.includes('empty')) return `Saved instructions: entry ${n} text must be non-empty text.`; + return `Saved instructions: entry ${n} text must be at most ${MAX_TEXT_CHARS} characters.`; + } + return null; +} + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** A `.field` with a label row (text and a muted count) and its control. */ +function field(id, label, control) { + const wrap = el('div', undefined, 'field'); + const caption = el('label'); + caption.htmlFor = id; + const count = el('span', '', 'small muted'); + caption.append(el('span', label), count); + control.id = id; + wrap.append(caption, control); + return { wrap, count }; +} + +/** + * Build the block and the two default rows; `drafts` is the System draft store. Returns `{nodes, repaint}`: System + * appends `nodes` in order and calls `repaint` whenever the drafts change. + */ +export function savedInstructionsBlock({ scope, drafts, random }) { + const block = el('div', undefined, 'setting-block'); + block.dataset.setting = 'tag-instructions'; + block.dataset.key = INSTRUCTIONS_KEY; + const text = el('div', undefined, 'setting-block-text'); + const label = el('label', INSTRUCTIONS_COPY.label); + label.id = 'instructions-label'; + const description = el('p', `${INSTRUCTIONS_COPY.description} `); + const count = el('span', ''); + count.id = 'instructions-count'; + description.append(count); + text.append(label, description); + + const layout = el('div', undefined, 'instructions-layout'); + const side = el('div', undefined, 'instructions-side'); + const list = el('div', undefined, 'instruction-list'); + list.id = 'instruction-list'; + list.setAttribute('role', 'listbox'); + list.setAttribute('aria-labelledby', 'instructions-label'); + list.tabIndex = 0; + const actions = el('div', undefined, 'row-actions instruction-actions'); + const create = createButton(INSTRUCTIONS_COPY.newLabel); + create.id = 'instruction-new'; + const duplicate = createButton(INSTRUCTIONS_COPY.duplicate); + duplicate.id = 'instruction-duplicate'; + const remove = createButton(INSTRUCTIONS_COPY.delete, 'danger'); + remove.id = 'instruction-delete'; + actions.append(create, duplicate, remove); + const cap = el('p', INSTRUCTIONS_COPY.cap, 'small'); + cap.id = 'instruction-cap'; + cap.hidden = true; + side.append(list, actions, cap); + + const editor = el('div', undefined, 'instruction-editor'); + const nameInput = el('input'); + nameInput.autocomplete = 'off'; + const name = field('instruction-name', INSTRUCTIONS_COPY.name, nameInput); + const textArea = el('textarea'); + textArea.rows = 7; + const body = field('instruction-text', INSTRUCTIONS_COPY.text, textArea); + const note = el('p', '', 'small'); + note.id = 'instruction-note'; + note.setAttribute('role', 'status'); + editor.append(name.wrap, body.wrap, note); + layout.append(side, editor); + block.append(text, layout); + + const selects = ENGINES.map(([engine, , long]) => { + const row = settingRow({ id: `instruction-default-${engine}`, label: INSTRUCTIONS_COPY.defaultRow(long), + description: INSTRUCTIONS_COPY.defaultDescription, control: { type: 'select', options: [[BUILTIN_ID, BUILTIN_NAME]] } }); + const select = row.children[1]; + select.dataset.key = DEFAULTS_KEY; + return { engine, select, row }; + }); + const reset = el('p', '', 'small instruction-reset'); + reset.id = 'instruction-reset'; + reset.setAttribute('role', 'status'); + + let selectedId = BUILTIN_ID; + const values = () => ({ [INSTRUCTIONS_KEY]: drafts.get('host', INSTRUCTIONS_KEY), + [DEFAULTS_KEY]: drafts.get('host', DEFAULTS_KEY), [BUILTIN_TEXT_KEY]: drafts.get('host', BUILTIN_TEXT_KEY) }); + const ready = () => Array.isArray(drafts.get('host', INSTRUCTIONS_KEY)) && drafts.get('host', DEFAULTS_KEY) !== undefined; + const defaults = () => ({ ...(drafts.get('host', DEFAULTS_KEY) ?? {}) }); + const setEntries = next => drafts.set('host', INSTRUCTIONS_KEY, next); + const selected = () => allEntries(values()).find(entry => entry.id === selectedId) ?? allEntries(values())[0]; + + /** Replace the selected saved entry's `key` with `value` (a new list of new objects). */ + function edit(key, value) { + if (selectedId === BUILTIN_ID) return; + setEntries(savedEntries(values()).map(entry => (entry.id === selectedId ? { ...entry, [key]: value } : entry))); + } + + function add(entry) { + const entries = savedEntries(values()); + if (!ready() || entries.length >= MAX_SAVED) return; + selectedId = entry.id; + reset.textContent = ''; + setEntries([...entries, entry]); + } + + function deleteSelected() { + const entries = savedEntries(values()); + const index = entries.findIndex(entry => entry.id === selectedId); + if (index < 0) return; + const gone = entries[index]; + const next = defaults(); + const shorts = []; + for (const [engine, short] of ENGINES) { + if (next[engine] === gone.id) { next[engine] = BUILTIN_ID; shorts.push(short); } + } + const left = entries.filter(entry => entry.id !== gone.id); + selectedId = (left[index] ?? left[index - 1])?.id ?? BUILTIN_ID; + reset.textContent = shorts.length ? INSTRUCTIONS_COPY.reset(shorts) : ''; + if (shorts.length) drafts.set('host', DEFAULTS_KEY, next); + setEntries(left); + } + + function select(id) { + selectedId = id; + repaint(); + } + + function renderList(entries, chosen) { + list.replaceChildren(...entries.map(entry => { + const option = el('div'); + option.id = `instruction-option-${entry.id}`; + option.dataset.id = entry.id; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', String(entry.id === selectedId)); + option.append(el('span', entry.name)); + const badges = el('span', undefined, 'badges'); + if (entry.id === BUILTIN_ID) badges.append(el('span', INSTRUCTIONS_COPY.readOnly, 'chip')); + for (const [engine, short] of ENGINES) { + if (chosen[engine] === entry.id) badges.append(el('span', INSTRUCTIONS_COPY.defaultFor(short), 'chip blue')); + } + if (badges.children.length) option.append(badges); + return option; + })); + list.setAttribute('aria-activedescendant', `instruction-option-${selectedId}`); + } + + function repaint() { + // The note a deleted default left goes once the defaults are saved (or set back). + if (!Object.hasOwn(drafts.changes('host'), DEFAULTS_KEY)) reset.textContent = ''; + const loaded = ready(); + for (const control of [create, duplicate, remove, nameInput, textArea, ...selects.map(item => item.select)]) { + control.disabled = !loaded; + } + if (!loaded) return; + const current = values(); + const entries = allEntries(current); + if (!entries.some(entry => entry.id === selectedId)) selectedId = BUILTIN_ID; + const chosen = defaults(); + const saved = savedEntries(current); + renderList(entries, chosen); + const entry = selected(); + const builtin = entry.id === BUILTIN_ID; + // A focused field keeps what the user is typing. + if (document.activeElement !== nameInput) nameInput.value = entry.name; + if (document.activeElement !== textArea) textArea.value = entry.text; + nameInput.readOnly = textArea.readOnly = builtin; + name.count.textContent = builtin ? '' : INSTRUCTIONS_COPY.nameCount(characters(entry.name)); + body.count.textContent = INSTRUCTIONS_COPY.textCount(characters(entry.text)); + const problem = builtin ? '' : entryNote(entry, entries); + note.textContent = builtin ? INSTRUCTIONS_COPY.builtinNote : problem; + note.className = problem ? 'small skip-problem' : 'small'; + const full = saved.length >= MAX_SAVED; + create.disabled = duplicate.disabled = full; + remove.disabled = builtin; + cap.hidden = !full; + count.textContent = INSTRUCTIONS_COPY.count(saved.length); + for (const { engine, select: menu } of selects) { + menu.replaceChildren(...entries.map(item => { + const option = el('option', item.name); + option.value = item.id; + return option; + })); + menu.value = chosen[engine] ?? BUILTIN_ID; + } + } + + scope.listen(create, 'click', () => { + const entries = allEntries(values()); + add({ id: newId(entries, random), name: freeName(INSTRUCTIONS_COPY.newName, entries), text: '' }); + textArea.focus?.(); + }); + scope.listen(duplicate, 'click', () => { + const entries = allEntries(values()); + const source = selected(); + add({ id: newId(entries, random), name: copyName(source.name, entries), text: source.text }); + }); + scope.listen(remove, 'click', deleteSelected); + // One listener for every option, however often the list is rebuilt. + scope.listen(list, 'click', event => { + let node = event.target; + while (node && node !== list && node.getAttribute?.('role') !== 'option') node = node.parentNode; + if (node && node !== list) select(node.dataset.id); + }); + scope.listen(nameInput, 'input', () => edit('name', nameInput.value)); + scope.listen(textArea, 'input', () => edit('text', textArea.value)); + scope.listen(list, 'keydown', event => { + const ids = allEntries(values()).map(entry => entry.id); + const at = ids.indexOf(selectedId); + const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: ids.length - 1 }[event.key]; + if (next === undefined) return; + event.preventDefault?.(); + select(ids[Math.max(0, Math.min(ids.length - 1, next))]); + }); + for (const { engine, select: menu } of selects) { + scope.listen(menu, 'change', () => { + reset.textContent = ''; + drafts.set('host', DEFAULTS_KEY, { ...defaults(), [engine]: menu.value }); + }); + } + return { nodes: [block, ...selects.map(item => item.row), reset], repaint }; +} diff --git a/web/js/workspace/system-maintenance.mjs b/web/js/workspace/system-maintenance.mjs index db40cffe..f8b371df 100644 --- a/web/js/workspace/system-maintenance.mjs +++ b/web/js/workspace/system-maintenance.mjs @@ -2,10 +2,12 @@ * System › Maintenance (#249 System S7 Task 19): the concept's `renderSystem` "maintenance" body (app.js 688–690). * * - The notice "Check first. Review changes. Then apply." with "Run health checks" (D123), then the surfaces - * "Database" and "Images & cache" with the concept's eight operations, in its order, labels and danger marks. + * "Database" and "Images & cache" with the concept's eight operations, in its order, labels and danger marks, plus + * D192 (#550, beyond the concept): "Pair category and tags" after "Unused prompt components". * - Integrity & relationship checks → Check runs the same read-only health check and opens the Health report (D95); * its "Review missing links" reviews a fresh Missing-file links plan, never a cleanup. - * - Duplicate prompt text, Optimize database, Unused prompt components and Missing-file links → Review plan open the + * - Duplicate prompt text, Optimize database, Unused prompt components, Pair category and tags (`pair_metadata`: fill + * old Pair prompts' empty category and tags from their saved pairs) and Missing-file links → Review plan open the * D100 plan review (maintenance-review.mjs), which applies only what it showed, as an Activity job. * - Duplicate image files → Find duplicates starts the duplicate search as an Activity job and opens it; its view * offers Review groups (D101) once it found some. @@ -39,6 +41,9 @@ export const MAINTENANCE_SURFACES = Object.freeze([ ['Optimize database', 'Reclaim unused SQLite space. This does not delete prompts or media.', 'Review plan', 'optimize'], ['Unused prompt components', 'Preview unreferenced components. Pair references and history remain protected.', 'Review plan', 'unused_components', true], + // D192 (#550): beyond the concept. Fills only empty fields, deletes nothing (no danger mark). + ['Pair category and tags', 'Fill an empty category or tags on prompts older Pair node runs saved, from their saved ' + + 'pairs. Only empty fields of never-edited prompts.', 'Review plan', 'pair_metadata'], ]], ['Images & cache', [ ['Missing-file links', 'Remove stale database links only. Files on disk are never deleted by this operation.', 'Review plan', diff --git a/web/js/workspace/system-tagging.mjs b/web/js/workspace/system-tagging.mjs index a958b22a..3201d9c4 100644 --- a/web/js/workspace/system-tagging.mjs +++ b/web/js/workspace/system-tagging.mjs @@ -10,7 +10,8 @@ * verbatim (they state the shipped defaults); host keys `autotag_existing`, `autotag_general_threshold`, * `autotag_character_threshold`. D133 adds "Suggestions per image" (`autotag_suggestion_limit`, 1–100) after them. * - D202 (#431): the "Skipped tags" block after it (system-skipped-tags.mjs, host key `autotag_skipped_tags`). - * - The `.field` "LLM tag template" (`#tag-template`, `autotag_template`). + * - D203 (#431): "Saved instructions" and the two per-engine default rows (system-instructions.mjs, host keys + * `autotag_instructions` and `autotag_default_instructions`) replace the concept's "LLM tag template" field. * - `.row-actions`: "Preview tagging review" (`ca-primary`) opens the Tag image dialog for the image selected in Images * with the section's current model and values, aria-disabled without one (the D115 sentence) or with an invalid * value (the toast names it). D132: the concept's two simulated job buttons are "Download" (or @@ -37,6 +38,8 @@ import { NO_SELECTION } from './vocabulary.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { navigateTo } from './state.mjs'; import { skippedTagsBlock } from './system-skipped-tags.mjs'; +import { BUILTIN_TEXT_KEY, DEFAULTS_KEY, INSTRUCTIONS_KEY, defaultEntry, listProblem, savedInstructionsBlock } + from './system-instructions.mjs'; export const MODELS_URL = '/prompt_manager/autotag/models'; export const UNLOAD_URL = '/prompt_manager/autotag/unload'; @@ -70,10 +73,10 @@ export const SETTING_KEYS = Object.freeze({ 'threshold-general': 'autotag_general_threshold', 'threshold-character': 'autotag_character_threshold', 'tag-suggestions': 'autotag_suggestion_limit', - 'tag-template': 'autotag_template', }); +/** #431: the values the instruction readers take besides the rows' (D203, D205). */ +const INSTRUCTION_VALUES = Object.freeze([INSTRUCTIONS_KEY, DEFAULTS_KEY, BUILTIN_TEXT_KEY]); const NUMERIC = new Set(['autotag_general_threshold', 'autotag_character_threshold', 'autotag_suggestion_limit']); -const MAX_TEMPLATE_CHARS = 4000; export const STATUS_COPY = Object.freeze({ loading: 'Loading model status…', @@ -93,9 +96,6 @@ export const SETTING_PROBLEMS = Object.freeze({ autotag_general_threshold_step: 'General-tag confidence must be a multiple of 0.05.', autotag_character_threshold_step: 'Character-tag confidence must be a multiple of 0.05.', autotag_suggestion_limit: 'Suggestions per image must be a whole number from 1 to 100.', - // The server's two template sentences (`validate_template`), blank first. - autotag_template: 'LLM tag template must be non-empty text.', - autotag_template_length: `LLM tag template must be at most ${MAX_TEMPLATE_CHARS} characters.`, }); /** Characters as Python's `len` counts them (code points), not UTF-16 units. */ export const characters = text => [...String(text ?? '')].length; @@ -193,20 +193,23 @@ const onStep = value => Math.abs(value * 20 - Math.round(value * 20)) <= 1e-12; // A threshold on the grid, handed on exactly as the server stores it (anything else is left for draftProblem to refuse). const snapped = value => (unit(value) && onStep(value) ? Math.round(value * 20) / 20 : value); +/** #431 (D205): the drafted default instructions of the drafted language-model engine. */ +export const engineInstructions = values => defaultEntry(values, values.autotag_model_type === 'hf' ? 'hf' : 'gguf'); + /** * The first setting the chosen model would use that the section cannot send, as `{key, message}`, else null: WD14 - * reads both thresholds (0 to 1 in steps of 0.05, #369), the language models the template; Generate also reads the - * suggestion count (`limit`). + * reads both thresholds (0 to 1 in steps of 0.05, #369), the language models the saved instructions (#431: the server's + * sentence for the first entry it would refuse); Generate also reads the suggestion count (`limit`). */ export function draftProblem(values, { limit = true } = {}) { const model = values.autotag_model_type; - const keys = CLASSIFIERS.has(model) ? ['autotag_general_threshold', 'autotag_character_threshold'] : ['autotag_template']; + const keys = CLASSIFIERS.has(model) ? ['autotag_general_threshold', 'autotag_character_threshold'] : [INSTRUCTIONS_KEY]; if (limit) keys.push('autotag_suggestion_limit'); for (const key of keys) { const value = values[key]; - if (key === 'autotag_template') { - if (typeof value !== 'string' || value.trim() === '') return { key, message: SETTING_PROBLEMS.autotag_template }; - if (characters(value) > MAX_TEMPLATE_CHARS) return { key, message: SETTING_PROBLEMS.autotag_template_length }; + if (key === INSTRUCTIONS_KEY) { + const message = listProblem(values); + if (message) return { key, message }; continue; } const valid = key === 'autotag_suggestion_limit' ? Number.isInteger(value) && value >= 1 && value <= 100 : unit(value); @@ -222,7 +225,7 @@ export function taggingPreset(values) { const options = CLASSIFIERS.has(model) ? { general_threshold: snapped(values.autotag_general_threshold), character_threshold: snapped(values.autotag_character_threshold) } - : { instructions: values.autotag_template }; + : { instructions: engineInstructions(values).text }; return { model, options: { ...options, suggestion_limit: values.autotag_suggestion_limit } }; } @@ -232,7 +235,7 @@ export function batchBody(values) { const options = CLASSIFIERS.has(model) ? { general_threshold: snapped(values.autotag_general_threshold), character_threshold: snapped(values.autotag_character_threshold) } - : { instructions: values.autotag_template }; + : { instructions: engineInstructions(values).text }; return { model_type: model, existing: values.autotag_existing, options }; } @@ -287,14 +290,20 @@ export function timeText(plan) { return durationText(Math.ceil(seconds / 60)) + (plan.estimate_device === 'gpu' ? TIME_ON_GPU : TIME_ON_CPU); } -/** D134: the review's rows from the server's preview and the drafts it was made with (image counts, #379). */ +/** + * D134: the review's rows from the server's preview and the drafts it was made with (image counts, #379). D205 (#431): + * a language-model engine shows its default instructions by name in an "Instructions" row, and Settings reads "Up to L + * tags per image". + */ export function batchGrid(plan, values) { const id = plan.model?.type ?? values.autotag_model_type; - let settings = CLASSIFIERS.has(id) - ? `General ${values.autotag_general_threshold} · Character ${values.autotag_character_threshold}` - : `Template of ${number(characters(values.autotag_template))} characters`; + const classifier = CLASSIFIERS.has(id); // The saved count the run uses (the preview's), not an unsaved draft. - if (Number.isSafeInteger(plan.suggestion_limit)) settings += ` · up to ${number(plan.suggestion_limit)} tags per image`; + const limit = Number.isSafeInteger(plan.suggestion_limit) ? `up to ${number(plan.suggestion_limit)} tags per image` : ''; + const settings = classifier + ? `General ${values.autotag_general_threshold} · Character ${values.autotag_character_threshold}${limit ? ` · ${limit}` : ''}` + : limit.charAt(0).toUpperCase() + limit.slice(1); + const instructions = classifier ? [] : [['Instructions', defaultEntry(values, id === 'hf' ? 'hf' : 'gguf').name]]; return [ ['Model', `${modelLabel(id, plan.model)} · ${modelSize(id, plan.model)}`], ['Existing tags', existingLabel(plan.existing ?? values.autotag_existing)], @@ -302,6 +311,7 @@ export function batchGrid(plan, values) { ['Already auto-tagged', number(plan.already_tagged)], ['Images to tag', `about ${number(plan.to_tag)}`], ['Time', timeText(plan)], + ...instructions, ['Settings', settings], ['Backup', backupText(plan.backup)], ]; @@ -416,16 +426,9 @@ export function mountTagging(container, { api, activity, scope, drafts, notify = const skipped = skippedTagsBlock({ api, scope, drafts }); surface.append(skipped.node); - const field = el('div', undefined, 'field'); - const templateLabel = el('label', 'LLM tag template'); - templateLabel.htmlFor = 'tag-template'; - const template = el('textarea'); - template.id = 'tag-template'; - template.dataset.setting = 'tag-template'; - template.dataset.key = SETTING_KEYS['tag-template']; - field.append(templateLabel, template); - surface.append(field); - controls.push({ id: 'tag-template', key: SETTING_KEYS['tag-template'], input: template, text: null }); + // D203 (#431): the saved instructions and each language-model engine's default replace the template field. + const instructions = savedInstructionsBlock({ scope, drafts }); + surface.append(...instructions.nodes); for (const { key, input } of controls) { // Text controls draft while typing (the Save row follows at once); selects and numbers on change. @@ -454,12 +457,14 @@ export function mountTagging(container, { api, activity, scope, drafts, notify = // The "could not be read" line under each row, while the server settings could not be read. const notes = new Map(); const image = selected(); - const current = () => Object.fromEntries(Object.values(SETTING_KEYS).map(key => [key, drafts.get('host', key)])); + const current = () => Object.fromEntries([...Object.values(SETTING_KEYS), ...INSTRUCTION_VALUES] + .map(key => [key, drafts.get('host', key)])); const chosen = () => drafts.get('host', 'autotag_model_type'); const known = () => MODEL_IDS.includes(chosen()); const repaint = () => { skipped.repaint(); + instructions.repaint(); const values = current(); const unread = values.autotag_model_type === undefined; const failed = drafts.hostState?.() === 'failed' && unread; diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 67aac066..8e55b1ee 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -513,15 +513,40 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc .skip-add { max-width: none; /* D202 */ } } +/* D203 (#431): "Saved instructions": the list (Built-in first, its chips) beside the Name/Text editor, stacked under + it below 700px, and the note a deleted default leaves under the two default rows. */ +.instructions-layout { display: grid; grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); gap: 18px; margin-top: 12px; /* D203 */ } +.instruction-list { border: 1px solid var(--line); border-radius: 6px; background: var(--ca-field); max-height: 300px; overflow: auto; /* D203 */ } +.instruction-list [role="option"] { display: flex; flex-direction: column; gap: 4px; padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--line); font-size: 12px; /* D203 */ } +.instruction-list [role="option"]:last-child { border-bottom: 0; /* D203 */ } +.instruction-list [role="option"][aria-selected="true"] { background: var(--soft-blue); box-shadow: inset 3px 0 0 var(--ca-accent); /* D203 */ } +.instruction-list .badges { display: flex; flex-wrap: wrap; gap: 4px; /* D203 */ } +.instruction-actions { margin-top: 10px; flex-wrap: wrap; /* D203 */ } +.instruction-editor .field:first-child { margin-top: 0; /* D203 */ } +.instruction-editor input[readonly], .instruction-editor textarea[readonly] { color: var(--ca-muted); /* D203 */ } +.instruction-editor label { display: flex; justify-content: space-between; gap: 8px; /* D203 */ } +.instruction-reset { color: var(--warning); margin: 0 0 14px; /* D203 */ } +.instruction-reset:empty { display: none; /* D203 */ } +@media (max-width: 700px) { + .instructions-layout { grid-template-columns: 1fr; /* D203 */ } +} + +/* D204 (#431): the one-off edit's note under "Tagging instructions" in the Tag image and Auto-tag selected dialogs. */ +.tag-model-panel .instructions-edited { margin: -6px 0 12px; /* D204 */ } +.tag-model-panel .instructions-edited[hidden] { display: none; /* D204 */ } + /* D132: at phone width the concept's 120px cap cuts every engine label ("WD14 SwinV2 · ~400 MB"); the Tagging model select alone keeps its natural width there (its widest engine label) and, with no room beside the row text, wraps under it. Existing tags (#398) never shrinks for its row text and, past the cap, keeps its natural width beside the text ("Replace after review"), bounded by its row like the engine select. */ .setting-row #tag-existing { flex-shrink: 0; /* D132 */ } +/* D203: the default-instructions selects show their chosen name whole, as Existing tags does (#398). */ +.setting-row #instruction-default-gguf, .setting-row #instruction-default-hf { flex-shrink: 0; /* D203 */ } @media (max-width: 700px) { #workspace-main .setting-row:has(> #tag-model) { flex-wrap: wrap; /* D132 */ } #workspace-main .setting-row #tag-model { flex-shrink: 0; max-width: 100% !important; /* D132 */ } #workspace-main .setting-row #tag-existing { max-width: 100% !important; /* D132 */ } + #workspace-main .setting-row #instruction-default-gguf, #workspace-main .setting-row #instruction-default-hf { max-width: 100% !important; /* D203 */ } } /* D109: System › Diagnostics & logs' added Lines field sits in the concept's toolbar like its Level select, and a value