tag-builder quick-wins: fix guaranteed-500s, ranking poison, paginator, FK orphans, dead code

- error_response import (NameError 500 on /detect-outfit & /detect-pose) + guard float(threshold)
- clamp per_page on /characters (0 -> ZeroDivisionError 500; huge -> full-table dump)
- fix /outfits & /poses paginator param-bind order
- get_db PRAGMA foreign_keys=ON; one-time orphan cleanup (358 rows)
- _safe_thumb_segment reject ':' (Windows drive-letter THUMBS_ROOT escape)
- findRegionBlocks depth-tracks braces so inline {a|b} wildcards don't corrupt region inserts
- delete poisoning danbooru_tags 'TAG' header row; add 10 indexes
- drop dead character_series.character_count column + work_queue table
- remove orphaned tag-builder-conflict-resolver.js
This commit is contained in:
chrmc40
2026-06-28 21:43:59 -07:00
parent 9fd6498ba6
commit 4d8539ad92
4 changed files with 31 additions and 65 deletions
+20 -4
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from aiohttp import web
import server
from .api_utils import parse_json
from .api_utils import parse_json, error_response
# Shares the AI debug channel so `match-characters` traces sit alongside
# the rest of the Prompt Generator pipeline in comfyui.log.
@@ -47,6 +47,9 @@ def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.text_factory = lambda x: x.decode("utf-8", "replace")
# FK enforcement is off by default in sqlite, so CASCADE deletes were
# inert and orphan preset rows accumulated. Per-connection pragma.
conn.execute("PRAGMA foreign_keys=ON")
_ensure_generic_outfits_schema(conn)
_normalize_full_outfits_sort_order(conn)
_normalize_scene_locations_sort_order(conn)
@@ -3218,6 +3221,10 @@ def _safe_thumb_segment(value: str) -> bool:
return False
if "/" in value or "\\" in value:
return False
# A ':' lets a Windows drive-letter token (e.g. "C:") reset the Path join
# and escape THUMBS_ROOT.
if ":" in value:
return False
if ".." in value or value.startswith("."):
return False
return True
@@ -3334,6 +3341,9 @@ async def _api_characters(request):
per_page = int(request.query.get("per_page", 50))
except (ValueError, TypeError):
return web.json_response({"error": "invalid page/per_page"}, status=400)
# Clamp so per_page=0 can't ZeroDivisionError the page-count math and a huge
# value can't dump the whole 11.5k-row table.
per_page = max(1, min(per_page, 200))
search = request.query.get("search", "").strip()
sort = request.query.get("sort", "display")
status = request.query.get("status", "")
@@ -3747,7 +3757,7 @@ def _api_presets_query(request, table, name_col, tags_col, natlang_col, extra_co
rows = db.execute(
f"SELECT {base_cols} FROM {table} p JOIN characters c ON c.tag = p.character_tag"
f"{where_sql} ORDER BY {order_sql} LIMIT ? OFFSET ?",
order_params + params + [per_page, offset],
params + order_params + [per_page, offset],
).fetchall()
# Attach per-row chip overrides so the picker can pre-load them onto
@@ -3944,7 +3954,10 @@ async def _api_detect_outfit(request):
if not input_set:
return web.json_response({"matches": []})
character_tag = (body.get("character_tag") or "").strip() or None
threshold = float(body.get("threshold", 0.8))
try:
threshold = float(body.get("threshold", 0.8))
except (TypeError, ValueError):
return error_response("threshold must be a number")
db = get_db()
where = ""
@@ -3995,7 +4008,10 @@ async def _api_detect_pose(request):
if not input_set:
return web.json_response({"matches": []})
character_tag = (body.get("character_tag") or "").strip() or None
threshold = float(body.get("threshold", 0.8))
try:
threshold = float(body.get("threshold", 0.8))
except (TypeError, ValueError):
return error_response("threshold must be a number")
db = get_db()
where = ""
Binary file not shown.
+11 -3
View File
@@ -53,8 +53,10 @@ function findInlineWildcardRange(doc, pos) {
}
// Regional-conditioning blocks: `$name { ... }`. Grammar mirrors
// region-highlight.js — `$[A-Za-z]\w*` then optional whitespace then `{`,
// and since regions don't nest the closer is simply the next `}`. Returns
// region-highlight.js — `$[A-Za-z]\w*` then optional whitespace then `{`.
// Regions don't nest, but a `{a|b}` wildcard can sit inside the body, so the
// closer is found by depth-tracking braces — not the first `}`, which would
// stop short at the wildcard and corrupt the prompt on insert. Returns
// each block's wrapper bounds, interior bounds, and the indent of its first
// content line (so re-wrapping on insert preserves the user's layout).
const REGION_OPEN = /\$[A-Za-z]\w*\s*\{/g;
@@ -64,7 +66,13 @@ function findRegionBlocks(doc) {
let m;
while ((m = REGION_OPEN.exec(doc)) !== null) {
const openBrace = m.index + m[0].length - 1;
const close = doc.indexOf("}", openBrace + 1);
let depth = 1, i = openBrace + 1;
for (; i < doc.length && depth > 0; i++) {
const ch = doc[i];
if (ch === "{") depth++;
else if (ch === "}") depth--;
}
const close = depth === 0 ? i - 1 : -1;
if (close === -1) continue; // unterminated block — leave it alone
const indentMatch = doc.slice(openBrace + 1, close).match(/\n([ \t]+)\S/);
blocks.push({
@@ -1,58 +0,0 @@
// Conflict resolver: when user-added clothing items occupy the same slot
// as phrases baked into a character's selected outfit, strip the outfit
// phrases and route them into negatives so the model is pushed off the
// character's trained-in defaults.
//
// Inputs:
// outfitSlots: array of { slot, item, color, source_phrase }
// userClothingSelections: object keyed by slot name (state.selections.clothing)
//
// Output:
// { stripPhrases: Set<string>, negatives: string[] }
//
// Slots like 'accessories' and 'modifiers' are additive — never auto-stripped
// since the outfit may legitimately wear multiple of them alongside user picks.
const ADDITIVE_SLOTS = new Set(["accessories", "modifiers"]);
export function resolveOutfitConflicts(outfitSlots, userClothingSelections) {
const stripPhrases = new Set();
const negatives = [];
if (!outfitSlots?.length || !userClothingSelections) {
return { stripPhrases, negatives };
}
const occupiedByUser = new Set();
for (const groupName in userClothingSelections) {
const sel = userClothingSelections[groupName];
const items = Array.isArray(sel) ? sel : [sel];
if (items.some(it => it && (it.tag || it.tags))) {
occupiedByUser.add(groupName.toLowerCase());
}
}
for (const row of outfitSlots) {
const slot = (row.slot || "").toLowerCase();
if (!slot || ADDITIVE_SLOTS.has(slot)) continue;
if (!occupiedByUser.has(slot)) continue;
if (!row.source_phrase) continue;
stripPhrases.add(row.source_phrase);
negatives.push(row.source_phrase);
}
return { stripPhrases, negatives };
}
// Strip a set of comma-separated tag phrases from a tag blob without
// breaking surrounding tags. Handles weighted forms like (tag:1.1) too.
export function stripPhrasesFromTagBlob(tags, stripPhrases) {
if (!tags || !stripPhrases?.size) return tags;
let result = tags;
for (const phrase of stripPhrases) {
const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
result = result.replace(new RegExp(`\\(${escaped}:[^)]+\\),?\\s*`, "gi"), "");
result = result.replace(new RegExp(`(^|,\\s*)${escaped}(?=,|$)`, "gi"), (m, sep) => sep ? "" : "");
}
return result.replace(/,\s*,/g, ",").replace(/^\s*,\s*/, "").replace(/,\s*$/, "").trim();
}