feat(ingestion): store a file's prompt in one transaction, one prompt per file

database/scanned_prompts.py links a file to the prompt of one text, creating
it when new, inside one BEGIN IMMEDIATE transaction: a file linked to
another prompt is a conflict, a failed link leaves no prompt behind and two
writers store one prompt. pair_ingestion gains file_link_rows,
link_conflict and Marks/mark_created; ingest_resolved_pair takes optional
marks for the components it creates. recorded_runs and named_prompt in
run_attribution become public (#247).
This commit is contained in:
Vito Sansevero
2026-10-28 06:11:20 -07:00
parent 9bf8362d22
commit e3191853c8
5 changed files with 378 additions and 22 deletions
+95 -15
View File
@@ -1,7 +1,14 @@
"""Atomic metadata enrichment for a single known generation context."""
"""Atomic metadata enrichment for a single known generation context.
Also the helpers both ingestion store paths share (#247): the file's link
rows and the one-prompt-per-file rule (``file_link_rows``,
``link_conflict``) and the marks a prompt created by ingestion carries
(``Marks``, ``mark_created``).
"""
import os
from typing import Any, Dict
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from .models import normalize_image_path
from .operations import _coerce_file_info
@@ -37,16 +44,69 @@ def blank_positive(resolution: Dict[str, Any]) -> bool:
return not (isinstance(text, str) and text.strip())
@dataclass(frozen=True)
class Marks:
"""What a prompt ingestion creates carries, so it can be found and removed.
``note`` is formatted with ``name``, the file's name.
"""
category: str
tags: Tuple[str, ...]
note: str
SCAN_MARKS = Marks("scanned", ("auto-scanned",), "Auto-scanned from {name}")
RUN_MARKS = Marks("scanned", ("auto-scanned",), "Saved from a workflow run: {name}")
def mark_created(conn: Any, db: Any, prompt_id: int, marks: Marks, name: str) -> None:
"""Apply ``marks`` to a prompt row the caller just created on ``conn``."""
conn.execute(
"UPDATE prompts SET category = ?, notes = ? WHERE id = ?",
(marks.category, marks.note.format(name=name), prompt_id),
)
db._sync_prompt_tags(conn, prompt_id, list(marks.tags))
def file_link_rows(conn: Any, image_path: str) -> List[Any]:
"""The file's link rows (either spelling), each with its prompt's hash."""
return conn.execute(
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
"JOIN prompts p ON p.id=gi.prompt_id "
"WHERE gi.file_path=? OR gi.image_path=?",
(normalize_image_path(image_path), image_path),
).fetchall()
def link_conflict(rows: List[Any], prompt_hash: str) -> bool:
"""One prompt per file: more than one row, or a row of another prompt."""
return len(rows) > 1 or bool(rows and rows[0]["positive_hash"] != prompt_hash)
def ingest_resolved_pair(
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
db: Any,
image_path: str,
metadata: Dict[str, Any],
resolution: Dict[str, Any],
*,
marks: Optional[Marks] = None,
) -> Dict[str, Any]:
"""Store a resolved pair and link the file to it in one transaction.
With ``marks`` (ingestion from files), each pair component this call
creates carries them; without (the PromptManager node path) components
are created bare, as always.
"""
state = resolution.get("state", "unresolved")
if state == "resolved" and blank_positive(resolution):
state = "unresolved"
if state != "resolved":
return {"status": state, "pairs_added": 0, "links_added": 0}
with db._pair_transaction() as conn:
return _ingest_resolved_pair_in(conn, db, image_path, metadata, resolution)
return _ingest_resolved_pair_in(
conn, db, image_path, metadata, resolution, marks=marks
)
def _conflict() -> Dict[str, Any]:
@@ -61,6 +121,7 @@ def _ingest_resolved_pair_in(
resolution: Dict[str, Any],
*,
provenance: Dict[str, Any] | None = None,
marks: Optional[Marks] = None,
) -> Dict[str, Any]:
"""``ingest_resolved_pair`` inside the caller's open ``_pair_transaction``.
@@ -69,7 +130,8 @@ def _ingest_resolved_pair_in(
negative is a ``conflict`` and nothing is written. The caller owns the
transaction, so its own later failure rolls the link back too.
``provenance`` replaces the link's default resolver provenance (Needs
review records the user's selection there).
review records the user's selection there). ``marks`` as in
``ingest_resolved_pair``.
"""
positive, negative = resolution["positive"], resolution["negative"]
positive_text = positive.get("storage_text", positive["text"])
@@ -82,15 +144,8 @@ def _ingest_resolved_pair_in(
"negative": negative,
}
encoded = dumps_strict(provenance, sort_keys=True, separators=(",", ":"))
images = conn.execute(
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
"JOIN prompts p ON p.id=gi.prompt_id "
"WHERE gi.file_path=? OR gi.image_path=?",
(normalized, image_path),
).fetchall()
if len(images) > 1 or (
images and images[0]["positive_hash"] != generate_prompt_hash(positive_text)
):
images = file_link_rows(conn, image_path)
if link_conflict(images, generate_prompt_hash(positive_text)):
return _conflict()
existing = images[0] if images else None
if existing:
@@ -115,7 +170,12 @@ def _ingest_resolved_pair_in(
generate_prompt_hash(negative_text) if negative_text.strip() else None,
),
).fetchone()
pair_id = db._save_pair(conn, positive_text, negative_text)
if marks is None:
pair_id = db._save_pair(conn, positive_text, negative_text)
else:
pair_id = _save_marked_pair(
conn, db, positive_text, negative_text, marks, os.path.basename(image_path)
)
pair = conn.execute("SELECT * FROM prompt_pairs WHERE id=?", (pair_id,)).fetchone()
pairs_added = int(prior_pair is None)
file_size, width, height, image_format = _coerce_file_info(metadata)
@@ -197,6 +257,26 @@ def _ingest_resolved_pair_in(
}
def _save_marked_pair(
conn: Any,
db: Any,
positive_text: str,
negative_text: str,
marks: Marks,
name: str,
) -> int:
"""``_save_pair`` that marks each component it creates."""
texts = [positive_text] + ([negative_text] if negative_text.strip() else [])
ids = []
for text in texts:
prompt_id, created = db._pair_component_outcome(conn, text)
if created:
mark_created(conn, db, prompt_id, marks, name)
ids.append(prompt_id)
negative_id = ids[1] if len(ids) > 1 else None
return db._save_pair_components(conn, ids[0], negative_id)[0]
def _document_form(conn: Any, values: Dict[str, Any]) -> Dict[str, Any]:
"""``values`` with the workflow and prompt graph texts stored once.
+7 -6
View File
@@ -164,7 +164,7 @@ def _runs_by_location(db: Any, names: List[str]) -> Optional[Dict[tuple, dict]]:
return found
def _recorded_runs(
def recorded_runs(
db: Any,
image_paths: Iterable[str],
output_root: Optional[str],
@@ -201,7 +201,8 @@ def _recorded_runs(
return attributed
def _named_prompt(db: Any, run_id: str) -> Optional[int]:
def named_prompt(db: Any, run_id: str) -> Optional[int]:
"""The one stored prompt every positive context of ``run_id`` names, else None."""
contexts = db.get_execution_contexts(run_id).get("contexts") or []
named = {
context.get("positive_prompt_id")
@@ -228,11 +229,11 @@ def stored_run_prompts(
paths = list(dict.fromkeys(path for path in image_paths if path))
result: Dict[str, Optional[int]] = dict.fromkeys(paths)
prompts: Dict[str, Optional[int]] = {}
for path, (run_id, _nodes) in _recorded_runs(
for path, (run_id, _nodes) in recorded_runs(
db, paths, output_root, path_style
).items():
if run_id not in prompts:
prompts[run_id] = _named_prompt(db, run_id)
prompts[run_id] = named_prompt(db, run_id)
result[path] = prompts[run_id]
return result
@@ -275,7 +276,7 @@ def recorded_output(
) -> Optional[RecordedOutput]:
"""The recorded run facts for ``image_path``, else None (#386).
The file must be attributed to one run (``_recorded_runs``: one run
The file must be attributed to one run (``recorded_runs``: one run
reported its location and it was written during that run). Read-only; it
never links anything.
"""
@@ -286,7 +287,7 @@ def recorded_output(
if own_transaction: # outputs and texts from one state of history
conn.execute("BEGIN")
try:
found = _recorded_runs(db, [image_path], output_root, path_style)
found = recorded_runs(db, [image_path], output_root, path_style)
if image_path not in found:
return None
run_id, nodes = found[image_path]
+57
View File
@@ -0,0 +1,57 @@
"""Link an output file to the prompt of one text, creating it when new (#247).
The store step of ingestion from file metadata when the decision is a
positive text (``utils.file_prompt``). The prompt insert and the link run in
one ``BEGIN IMMEDIATE`` transaction: two writers never store one text twice,
a failed link leaves no new prompt behind, and a file stays linked to one
prompt (a file already linked to another prompt is a ``conflict``).
"""
import logging
import os
from typing import Any, Dict, Optional, Tuple
from .pair_ingestion import Marks, file_link_rows, link_conflict, mark_created
try:
from ..utils.hashing import generate_prompt_hash
except ImportError:
from utils.hashing import generate_prompt_hash
_LOGGER = logging.getLogger("prompt_manager.database.scanned_prompts")
def store_scanned_prompt_or_raise(
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
) -> Tuple[str, Optional[int]]:
"""``(status, prompt id)`` after linking ``path`` to ``text``'s prompt.
``added`` (prompt created, carrying ``marks``), ``linked`` (stored text
reused), ``unchanged`` (already linked to it) or ``conflict`` (linked to
another prompt; nothing written, id None). Database errors are raised
and roll the whole store back, so a caller can run it again.
"""
with db._pair_transaction() as conn:
rows = file_link_rows(conn, path)
if link_conflict(rows, generate_prompt_hash(text)):
return "conflict", None
if rows:
return "unchanged", rows[0]["prompt_id"]
prompt_id, created = db._pair_component_outcome(conn, text)
if created:
mark_created(conn, db, prompt_id, marks, os.path.basename(path))
db._insert_image_link(conn, prompt_id, path, canonical)
return ("added" if created else "linked"), prompt_id
def store_scanned_prompt(
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
) -> Tuple[str, Optional[int]]:
"""The scan's form: an error is logged and reported as ``("found", None)``."""
try:
return store_scanned_prompt_or_raise(db, path, canonical, text, marks)
except Exception as error:
_LOGGER.error(
f"Failed to store the prompt of {os.path.basename(path)}: {error}"
)
return "found", None