Files
EricRollei-Local_LLM_Prompt…/prompt_database.py
T
Eric HissandClaude Fable 5.1 4459af9ea6 V2.0.0: wildcard engine, platform profiles, MiniMax H3 default, API models
Every node id, input name and existing widget position is unchanged, so saved
workflows load; new inputs are appended only.

Wildcards
- core/wildcards: Dynamic Prompts / Impact Pack grammar, tag queries, variables,
  conditionals, concept / verbatim / loose bindings, reproducible seeds.
  Wired into every enhancer node; reads ComfyUI/models/wildcards.
- New nodes: Wildcard Processor, Semantic Variation (direction axes),
  Concept Survival Check. Starter lists in wildcards/.
- Random choices use a private generator; global random state is untouched.

Platform profiles
- core/profiles + profiles/*.yaml: one file per model (format, length,
  negative prompt or none, quality tokens, text and dialogue syntax, vendor
  rewriter prompt fetched once and cached). profiles_user/ overrides by id.
- New node: Platform Prompt Rewriter (prose, tags, edit instructions, JSON,
  MiniMax H3 fields, Hailuo brackets), with validators for each format.
- Text-to-Image and Image-to-Image read profiles through platforms.py;
  corrected facts for FLUX, Kontext, Chroma, Wan, AuraFlow, Kolors, NoobAI,
  Qwen-Image, HunyuanImage. Negative prompt is empty for models without one.
- Hosted / API models: Seedream 5, Nano Banana, GPT Image, Midjourney V8,
  FLUX.2 pro, Ideogram 3, Recraft V4, Seedance 2.0 / 2.5, Kling 3.0, Veo 3.1,
  Gemini Omni Flash, Runway, Wan 3.0, Luma, hosted H3, Grok, Vidu, PixVerse.
  Reports say how to switch the vendor's own prompt rewriting off.
- Illustrious: spaced tags, quality tags last, 25-80 words (OnomaAI paper).

Video nodes
- target_model and duration_seconds appended to the three video expanders.
  Default target is MiniMax H3; wan22_video is the original path, unchanged.
  A workflow saved earlier loads with the default until the input is set.

Also included: the Alice, Ideogram Magic Prompt and prompt-database nodes that
were already registered in __init__.py but had not been committed.

Fixes: v1 {animal} / {label:a|b} wildcards were inert in the video nodes;
Image-to-Video save_to_file raised NameError; T2I padded short-prompt models.

.gitattributes added (text=auto) so CRLF working files stop showing as
whole-file changes. prompt_database.db and the cached Ideogram prompt are
git-ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VpCi6nw5UECMdZ7ayYJvN
2026-09-21 00:28:58 -07:00

580 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Prompt Database Backend
SQLite-based storage and retrieval for prompts with full-text search.
Author: Eric Hiss (GitHub: EricRollei)
"""
import sqlite3
import os
import json
import csv
import logging
import re
from datetime import datetime
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# Database file lives next to this module
DB_PATH = os.path.join(os.path.dirname(__file__), "prompt_database.db")
# Platforms matching platforms.py keys
PLATFORMS = [
"any",
"flux",
"flux_kontex",
"pixart_sigma",
"aura_flow",
"noobai",
"kolors",
"hunyuan_image",
"qwen_image",
"qwen_image_edit",
"sd_xl",
"pony",
"illustrious",
"chroma",
"wan_image",
"wan_video",
"alice_t2v",
"other",
]
def _with_profile_platforms(names):
"""The list above, unchanged and in order, plus every platform profile id it does not have yet."""
try:
from .profile_support import profile_ids
return list(names) + [profile_id for profile_id in profile_ids() if profile_id not in names]
except Exception as error: # the database must work even if the profiles folder is missing
print(f"[Prompt Database] platform profiles unavailable, using the built-in list: {error}")
return list(names)
PLATFORMS = _with_profile_platforms(PLATFORMS)
CATEGORIES = [
"any",
"character",
"landscape",
"architecture",
"abstract",
"portrait",
"action",
"still_life",
"fantasy",
"sci_fi",
"horror",
"romantic",
"cinematic",
"product",
"concept_art",
"photography",
"animation",
"other",
]
class PromptDatabase:
"""SQLite prompt database with FTS5 full-text search."""
def __init__(self, db_path: str = DB_PATH):
self.db_path = db_path
self._conn: Optional[sqlite3.Connection] = None
self._ensure_db()
# ------------------------------------------------------------------
# Initialisation
# ------------------------------------------------------------------
def _ensure_db(self):
"""Create tables if they don't exist."""
conn = self._connect()
conn.executescript("""
CREATE TABLE IF NOT EXISTS prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt_text TEXT NOT NULL,
negative_prompt TEXT DEFAULT '',
category TEXT DEFAULT 'any',
platform TEXT DEFAULT 'any',
tags TEXT DEFAULT '',
rating INTEGER DEFAULT 0,
source TEXT DEFAULT 'manual',
notes TEXT DEFAULT '',
favorite INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_prompts_category ON prompts(category);
CREATE INDEX IF NOT EXISTS idx_prompts_platform ON prompts(platform);
CREATE INDEX IF NOT EXISTS idx_prompts_rating ON prompts(rating);
CREATE INDEX IF NOT EXISTS idx_prompts_favorite ON prompts(favorite);
""")
# FTS5 virtual table for fast keyword search
# Check whether it already exists before creating
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='prompts_fts'"
).fetchone()
if row is None:
conn.execute("""
CREATE VIRTUAL TABLE prompts_fts USING fts5(
prompt_text,
negative_prompt,
tags,
notes,
content='prompts',
content_rowid='id'
);
""")
# Populate FTS from any pre-existing rows
conn.execute("""
INSERT INTO prompts_fts(rowid, prompt_text, negative_prompt, tags, notes)
SELECT id, prompt_text, negative_prompt, tags, notes FROM prompts;
""")
# Triggers to keep FTS in sync
conn.executescript("""
CREATE TRIGGER IF NOT EXISTS prompts_ai AFTER INSERT ON prompts BEGIN
INSERT INTO prompts_fts(rowid, prompt_text, negative_prompt, tags, notes)
VALUES (new.id, new.prompt_text, new.negative_prompt, new.tags, new.notes);
END;
CREATE TRIGGER IF NOT EXISTS prompts_ad AFTER DELETE ON prompts BEGIN
INSERT INTO prompts_fts(prompts_fts, rowid, prompt_text, negative_prompt, tags, notes)
VALUES ('delete', old.id, old.prompt_text, old.negative_prompt, old.tags, old.notes);
END;
CREATE TRIGGER IF NOT EXISTS prompts_au AFTER UPDATE ON prompts BEGIN
INSERT INTO prompts_fts(prompts_fts, rowid, prompt_text, negative_prompt, tags, notes)
VALUES ('delete', old.id, old.prompt_text, old.negative_prompt, old.tags, old.notes);
INSERT INTO prompts_fts(rowid, prompt_text, negative_prompt, tags, notes)
VALUES (new.id, new.prompt_text, new.negative_prompt, new.tags, new.notes);
END;
""")
conn.commit()
def _connect(self) -> sqlite3.Connection:
"""Return the persistent connection (create if needed)."""
if self._conn is None:
self._conn = sqlite3.connect(self.db_path)
self._conn.row_factory = sqlite3.Row
return self._conn
# ------------------------------------------------------------------
# CRUD
# ------------------------------------------------------------------
def save_prompt(
self,
prompt_text: str,
negative_prompt: str = "",
category: str = "any",
platform: str = "any",
tags: str = "",
rating: int = 0,
source: str = "manual",
notes: str = "",
favorite: bool = False,
) -> int:
"""Insert a prompt and return its id."""
now = datetime.now().isoformat(timespec="seconds")
conn = self._connect()
cur = conn.execute(
"""INSERT INTO prompts
(prompt_text, negative_prompt, category, platform,
tags, rating, source, notes, favorite, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
prompt_text.strip(),
negative_prompt.strip(),
category,
platform,
tags.strip(),
max(0, min(5, rating)),
source,
notes.strip(),
int(favorite),
now,
now,
),
)
conn.commit()
return cur.lastrowid
def update_prompt(self, prompt_id: int, **fields) -> bool:
"""Update specified fields on a prompt row."""
allowed = {
"prompt_text", "negative_prompt", "category", "platform",
"tags", "rating", "source", "notes", "favorite",
}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
return False
updates["updated_at"] = datetime.now().isoformat(timespec="seconds")
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [prompt_id]
conn = self._connect()
conn.execute(f"UPDATE prompts SET {set_clause} WHERE id = ?", values)
conn.commit()
return True
def delete_prompt(self, prompt_id: int) -> bool:
"""Delete a prompt by id."""
conn = self._connect()
cur = conn.execute("DELETE FROM prompts WHERE id = ?", (prompt_id,))
conn.commit()
return cur.rowcount > 0
def get_prompt(self, prompt_id: int) -> Optional[Dict]:
"""Get a single prompt by id."""
conn = self._connect()
row = conn.execute("SELECT * FROM prompts WHERE id = ?", (prompt_id,)).fetchone()
return dict(row) if row else None
# ------------------------------------------------------------------
# Search
# ------------------------------------------------------------------
def search(
self,
query: str = "",
category: str = "any",
platform: str = "any",
min_rating: int = 0,
favorites_only: bool = False,
tags_filter: str = "",
limit: int = 20,
offset: int = 0,
order_by: str = "newest",
) -> Tuple[List[Dict], int]:
"""
Search prompts. Returns (results, total_count).
query: FTS5 keyword search (supports AND, OR, NOT, "phrases")
category / platform: filter or 'any' for all
min_rating: 0-5
tags_filter: comma-separated tags – all must be present
order_by: newest | oldest | rating | random
"""
conditions = []
params: list = []
use_fts = bool(query.strip())
if use_fts:
# Sanitise the user query for FTS5
safe_query = self._sanitise_fts_query(query)
conditions.append("p.id IN (SELECT rowid FROM prompts_fts WHERE prompts_fts MATCH ?)")
params.append(safe_query)
if category and category != "any":
conditions.append("p.category = ?")
params.append(category)
if platform and platform != "any":
conditions.append("p.platform = ?")
params.append(platform)
if min_rating > 0:
conditions.append("p.rating >= ?")
params.append(min_rating)
if favorites_only:
conditions.append("p.favorite = 1")
if tags_filter.strip():
for tag in [t.strip().lower() for t in tags_filter.split(",") if t.strip()]:
conditions.append("LOWER(p.tags) LIKE ?")
params.append(f"%{tag}%")
where = " AND ".join(conditions) if conditions else "1=1"
order_map = {
"newest": "p.created_at DESC",
"oldest": "p.created_at ASC",
"rating": "p.rating DESC, p.created_at DESC",
"random": "RANDOM()",
}
order = order_map.get(order_by, "p.created_at DESC")
conn = self._connect()
# Count
count_row = conn.execute(
f"SELECT COUNT(*) FROM prompts p WHERE {where}", params
).fetchone()
total = count_row[0] if count_row else 0
# Results
rows = conn.execute(
f"SELECT p.* FROM prompts p WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?",
params + [limit, offset],
).fetchall()
results = [dict(r) for r in rows]
return results, total
@staticmethod
def _sanitise_fts_query(raw: str) -> str:
"""
Turn a user-typed search string into a valid FTS5 query.
Wraps each word with quotes and joins with OR so partial phrases
are forgiving. Quoted phrases from the user are kept intact.
"""
# Preserve user-quoted phrases
phrases = re.findall(r'"([^"]+)"', raw)
remainder = re.sub(r'"[^"]*"', '', raw)
tokens = [t.strip() for t in remainder.split() if t.strip()]
parts = [f'"{p}"' for p in phrases]
parts += [f'"{t}"' for t in tokens]
return " OR ".join(parts) if parts else '""'
# ------------------------------------------------------------------
# Import
# ------------------------------------------------------------------
def import_from_text_file(
self,
file_path: str,
category: str = "any",
platform: str = "any",
tags: str = "",
delimiter: str = "newline",
skip_duplicates: bool = True,
) -> Tuple[int, int, List[str]]:
"""
Import prompts from a text file.
delimiter:
'newline' – one prompt per line
'double_newline' – prompts separated by blank lines
'paragraph' – same as double_newline
Returns (imported_count, skipped_count, errors)
"""
if not os.path.isfile(file_path):
return 0, 0, [f"File not found: {file_path}"]
errors: List[str] = []
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
return 0, 0, [f"Error reading file: {e}"]
if delimiter in ("double_newline", "paragraph"):
raw_prompts = re.split(r"\n\s*\n", content)
else:
raw_prompts = content.splitlines()
prompts = [p.strip() for p in raw_prompts if p.strip()]
return self._bulk_insert(prompts, category, platform, tags, "import_txt", skip_duplicates, errors)
def import_from_csv(
self,
file_path: str,
category: str = "any",
platform: str = "any",
tags: str = "",
skip_duplicates: bool = True,
) -> Tuple[int, int, List[str]]:
"""
Import from CSV. Expected columns (header row required):
prompt_text (required), negative_prompt, category, platform, tags, rating, notes
Missing columns use defaults.
"""
if not os.path.isfile(file_path):
return 0, 0, [f"File not found: {file_path}"]
errors: List[str] = []
imported = 0
skipped = 0
try:
with open(file_path, "r", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
for row_num, row in enumerate(reader, start=2):
text = (row.get("prompt_text") or "").strip()
if not text:
errors.append(f"Row {row_num}: empty prompt_text, skipped")
skipped += 1
continue
if skip_duplicates and self._exists(text):
skipped += 1
continue
self.save_prompt(
prompt_text=text,
negative_prompt=(row.get("negative_prompt") or "").strip(),
category=row.get("category", category).strip() or category,
platform=row.get("platform", platform).strip() or platform,
tags=row.get("tags", tags).strip() or tags,
rating=int(row.get("rating", 0) or 0),
source="import_csv",
notes=(row.get("notes") or "").strip(),
)
imported += 1
except Exception as e:
errors.append(f"CSV parse error: {e}")
return imported, skipped, errors
def import_from_json(
self,
file_path: str,
category: str = "any",
platform: str = "any",
tags: str = "",
skip_duplicates: bool = True,
) -> Tuple[int, int, List[str]]:
"""
Import from JSON. Expects either:
- A JSON array of strings (plain prompts)
- A JSON array of objects with at least {"prompt_text": "..."}
"""
if not os.path.isfile(file_path):
return 0, 0, [f"File not found: {file_path}"]
errors: List[str] = []
imported = 0
skipped = 0
try:
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
return 0, 0, [f"JSON parse error: {e}"]
if not isinstance(data, list):
return 0, 0, ["JSON root must be an array"]
for i, item in enumerate(data):
if isinstance(item, str):
text = item.strip()
neg = ""
item_cat = category
item_plat = platform
item_tags = tags
item_rating = 0
item_notes = ""
elif isinstance(item, dict):
text = (item.get("prompt_text") or item.get("prompt") or "").strip()
neg = (item.get("negative_prompt") or "").strip()
item_cat = (item.get("category") or category).strip()
item_plat = (item.get("platform") or platform).strip()
item_tags = (item.get("tags") or tags).strip()
item_rating = int(item.get("rating", 0) or 0)
item_notes = (item.get("notes") or "").strip()
else:
errors.append(f"Item {i}: unexpected type {type(item).__name__}, skipped")
skipped += 1
continue
if not text:
skipped += 1
continue
if skip_duplicates and self._exists(text):
skipped += 1
continue
self.save_prompt(
prompt_text=text,
negative_prompt=neg,
category=item_cat,
platform=item_plat,
tags=item_tags,
rating=item_rating,
source="import_json",
notes=item_notes,
)
imported += 1
return imported, skipped, errors
# ------------------------------------------------------------------
# Stats / helpers
# ------------------------------------------------------------------
def stats(self) -> Dict:
"""Return summary statistics about the database."""
conn = self._connect()
total = conn.execute("SELECT COUNT(*) FROM prompts").fetchone()[0]
favorites = conn.execute("SELECT COUNT(*) FROM prompts WHERE favorite=1").fetchone()[0]
by_cat = {
r[0]: r[1]
for r in conn.execute(
"SELECT category, COUNT(*) FROM prompts GROUP BY category ORDER BY COUNT(*) DESC"
).fetchall()
}
by_plat = {
r[0]: r[1]
for r in conn.execute(
"SELECT platform, COUNT(*) FROM prompts GROUP BY platform ORDER BY COUNT(*) DESC"
).fetchall()
}
return {
"total_prompts": total,
"favorites": favorites,
"by_category": by_cat,
"by_platform": by_plat,
}
def _exists(self, prompt_text: str) -> bool:
"""Check if exact prompt_text already present."""
conn = self._connect()
row = conn.execute(
"SELECT 1 FROM prompts WHERE prompt_text = ? LIMIT 1",
(prompt_text.strip(),),
).fetchone()
return row is not None
def _bulk_insert(
self,
prompts: List[str],
category: str,
platform: str,
tags: str,
source: str,
skip_duplicates: bool,
errors: List[str],
) -> Tuple[int, int, List[str]]:
"""Insert a list of plain-text prompts."""
imported = 0
skipped = 0
for text in prompts:
if skip_duplicates and self._exists(text):
skipped += 1
continue
try:
self.save_prompt(
prompt_text=text,
category=category,
platform=platform,
tags=tags,
source=source,
)
imported += 1
except Exception as e:
errors.append(f"Insert error: {e}")
skipped += 1
return imported, skipped, errors
def export_to_json(self, file_path: str, **search_kwargs) -> int:
"""Export search results (or everything) to a JSON file."""
results, _ = self.search(limit=999999, **search_kwargs)
with open(file_path, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
return len(results)
def export_to_txt(self, file_path: str, **search_kwargs) -> int:
"""Export prompt texts to a plain text file, one per line."""
results, _ = self.search(limit=999999, **search_kwargs)
with open(file_path, "w", encoding="utf-8") as f:
for r in results:
f.write(r["prompt_text"] + "\n")
return len(results)