* feat(review): offer inspection-profile choices for untraceable batches Qwen image-edit batches run several KSamplers, each with its own TextEncodeQwenImageEdit prompt, all saved under one prefix. The scan's default profile knows neither the encoder nor the string nodes, so every candidate was unresolved and the files left Needs review as "could not be traced", although the inspection profile resolves every text. When the default profile is ambiguous without a choice and nothing links the file, the scan now resolves it with the inspection profile and, with at least two choosable candidates, queues the item from that evidence. The evidence records profile "inspection" and the semantics table version (SHA-256 of its canonical JSON); the item's resolver_version folds the profile, RESOLVER_VERSION and the table version, so a table change is handled like a resolver change (I2). Apply re-resolves the capture with the recorded profile, re-checks the chosen candidate hash and links through the compatible-only ingestion. The inspection profile never links on its own, ingestion keeps the default profile, and the live monitor never queues. * feat(review): supersede stale items and inspect each file state once A semantics table or resolver version change no longer leaves two pending items for one file. A new item supersedes the other items of the same bytes when every one is pending and undecided (revision 0, no decision row, the self-heal's safe delete). Beside a decided or advanced item nothing new is stored, and the rescan's stat shortcut skips those bytes under any version. The inspection item version now folds a narrower fingerprint: the table restricted to the graph's node classes plus its class-independent parts, so an edit about other classes leaves every item alone. The inspection profile runs once per file state. The scan resolves the untraced file at batch level, skips files already queued or decided at their current size and mtime under their version, and remembers files it found untraced in this process. The capture reuses the scan's result when it holds the same records. * feat(review): contain inspection failures, version the inspection resolver Final-review fixes for the inspection-profile review choices. - An inspection failure on one file is logged, counted in review_errors and reported unresolved; the scan goes on. - INSPECTION_RESOLVER_VERSION and the inspection profile's budgets are folded into the item version, so an inspection-only resolver change re-inspects and supersedes the item. An older version's pending, undecided item of bytes now untraced leaves the queue, so an item never stays stuck at 409. - Fingerprint tests: an edit of the encoder's own entry moves the version, an unused dict-keyed class leaves it. - The untraced memory is a plain dict of 16-byte digests (8.6 MiB measured at its 65,536-entry cap). - Only scans add items: the monitor's pass records nothing and only clears the items of outputs it links. Tests and the browser review fixture record through the scan's recorder. - record_review_item documents the byte-record refresh it commits when it refuses a new item. * fix(review): an unusable semantics table never retires recorded items
400 lines
17 KiB
Python
400 lines
17 KiB
Python
"""Needs review recording for the output scan and the live monitor (#249).
|
|
|
|
The rule (maintainer test of #284/#288): a file is queued only when its
|
|
final outcome is ambiguous and unlinked. The strict resolver must find it
|
|
``ambiguous``, the scan's legacy fallback (the browser parser,
|
|
``_graph_prompt_text``) must not link it, and no saved row may link it
|
|
already. A file the fallback links, an exact pair the ingester stored and an
|
|
already linked file are never queued. Neither is an ambiguous file with
|
|
nothing to choose: fewer than two of its candidates carry a prompt
|
|
(``utils.review_capture.reviewable``); the scan reports it unresolved ("could
|
|
not be traced") unless the inspection profile finds a choice in it.
|
|
|
|
Only scans add items (maintainer ruling, 2026-10-23). The monitor's pass
|
|
records nothing: it only removes the pending items of an output it linked.
|
|
|
|
The queue heals itself: a pending item whose file is now linked (by the
|
|
fallback, a later resolution or the monitor) is removed by the next scan
|
|
that lists the file or by the monitor's next pass over it. The next scan
|
|
also removes a pending item without a choice whose file is still ambiguous
|
|
without one (``settle_untraced_review_items``), and an older version's
|
|
item of bytes now untraced (``settle_stale_review_items``). Only an undecided item goes
|
|
(``pending``, revision 0, no decision row); a decision is never deleted or
|
|
changed (``settle_linked_review_items``).
|
|
|
|
The scan records through ``_record_scanned_review`` (best effort: a failure
|
|
is logged and counted), which goes through
|
|
``utils.review_capture.record_ambiguous_file`` on the API's capture store;
|
|
the monitor calls ``record_review_file`` from its worker thread after its
|
|
own link. Nothing here links a file.
|
|
|
|
Recording changes records, so it follows the registry's ``records``
|
|
admission (#289). The scan records under its own ``records`` job. The
|
|
monitor's pass lasts one file and is not a job: it is refused while a
|
|
``records`` or ``guarded`` job runs (``deferred``), and the next scan reads
|
|
the file again. Thumbnail work (the job and the gallery's generate and clear
|
|
holds, kind ``thumbnails``) is ``guarded`` but only writes and removes preview
|
|
files under ``thumbnails/`` and never touches the database, so it does not
|
|
defer the recording (#249 S5 M7): a long rebuild no longer holds Needs review
|
|
back for its whole run.
|
|
"""
|
|
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from .admin import SCAN_LOG_PREFIXES, _plural, _scan_location, _scan_roots
|
|
from .jobs import THUMBNAILS_JOB_KIND
|
|
|
|
# The kind the monitor's recording checks admission under (never registered).
|
|
REVIEW_RECORDING_KIND = "review_recording"
|
|
# Guarded kinds that change no records, so the recording may run beside them.
|
|
PREVIEW_ONLY_KINDS = (THUMBNAILS_JOB_KIND,)
|
|
# record_ambiguous_file outcomes that leave the file in the pending queue.
|
|
QUEUED_OUTCOMES = ("recorded", "limited", "duplicate")
|
|
NEW_OUTCOMES = ("recorded", "limited")
|
|
|
|
try:
|
|
from ...utils import review_capture
|
|
from ...utils.review_capture import record_ambiguous_file
|
|
except ImportError:
|
|
from utils import review_capture
|
|
from utils.review_capture import record_ambiguous_file
|
|
|
|
# Untraced files remembered per process so a rescan of an unchanged file does
|
|
# not run the inspection profile again (oldest forgotten first). A plain dict
|
|
# of 16-byte digests (the locator, and its size, mtime_ns and item version):
|
|
# measured at 8.6 MiB with 65,536 entries (tracemalloc, CPython 3.14).
|
|
MAX_UNTRACED_MEMORY = 65_536
|
|
|
|
|
|
def _digest(text: str) -> bytes:
|
|
data = text.encode("utf-8", "surrogatepass")
|
|
return hashlib.blake2b(data, digest_size=16).digest()
|
|
|
|
|
|
def _memory_key(locator: str) -> bytes:
|
|
"""A 16-byte digest of ``locator`` (the untraced memory's key)."""
|
|
return _digest(locator)
|
|
|
|
|
|
# The scan log's reason for items that leave the queue with nothing to choose.
|
|
NOTHING_TO_CHOOSE = "nothing to choose"
|
|
|
|
|
|
class ReviewRecordingMixin:
|
|
"""Record the scan's and the monitor's ambiguous files for Needs review."""
|
|
|
|
def _record_scanned_review(self, media_file, roots, review=None, scanned=None):
|
|
"""Record one ambiguous, unlinked scanned file (logged and counted).
|
|
|
|
``scanned`` is the scan's ``(metadata, inspection_resolution)``.
|
|
"""
|
|
try:
|
|
outcome = self._record_review_sync(
|
|
media_file, roots, review, inspection=True, scanned=scanned
|
|
)
|
|
except Exception:
|
|
self.logger.warning(
|
|
f"{media_file.name} could not be added to Needs review",
|
|
exc_info=True,
|
|
)
|
|
if review is not None:
|
|
review["errors"] += 1
|
|
return
|
|
if review is None:
|
|
return
|
|
if outcome in NEW_OUTCOMES:
|
|
review["added"] += 1
|
|
if outcome in QUEUED_OUTCOMES:
|
|
review["queued"] += 1
|
|
|
|
def _record_review_sync(
|
|
self, media_file, roots, review=None, inspection=False, scanned=None
|
|
):
|
|
"""``record_ambiguous_file`` for a file under one of ``roots`` (blocking).
|
|
|
|
``inspection`` (the scan only) lets a file without a choice under the
|
|
default profile be recorded from the inspection profile's choices.
|
|
An ``untraced`` file (its captured bytes offer nothing to choose) also
|
|
loses its pending items that offer nothing either.
|
|
"""
|
|
located = self._review_location(media_file, roots)
|
|
if located is None:
|
|
return "skipped"
|
|
root, index, relative, _spelled = located
|
|
outcome = record_ambiguous_file(
|
|
self.db,
|
|
self._capture_store(),
|
|
Path(root),
|
|
index,
|
|
relative,
|
|
inspection=inspection,
|
|
scanned=scanned,
|
|
)
|
|
if outcome == "untraced":
|
|
self._settle_untraced_reviews([media_file], roots, review)
|
|
return outcome
|
|
|
|
def _untraced_scan_outcome(self, media_file, roots, canonical, review=None):
|
|
"""The scan status of an unlinked file the default profile left
|
|
ambiguous without a choice (blocking).
|
|
|
|
``ambiguous`` when the inspection profile offers a choice: the file is
|
|
recorded for review from that profile (never linked), or ``unchanged``
|
|
when a saved row links it already. Otherwise ``unresolved``, and its
|
|
pending items without a choice leave the queue. The profile runs at
|
|
most once per file state: not for bytes already queued (or decided)
|
|
at the file's current size and ``mtime_ns`` under the version that
|
|
its records depend on, and not again for a state it already found
|
|
untraced in this process.
|
|
"""
|
|
try:
|
|
choice = self._inspection_choice(media_file, roots, canonical)
|
|
except Exception:
|
|
# One bad file never stops the scan: it stays "could not be traced".
|
|
self.logger.warning(
|
|
f"{media_file.name} could not be inspected for Needs review",
|
|
exc_info=True,
|
|
)
|
|
if review is not None:
|
|
review["errors"] += 1
|
|
return "unresolved"
|
|
if choice is None:
|
|
self._settle_untraced_reviews([media_file], roots, review)
|
|
self._settle_stale_reviews(media_file, roots, canonical, review)
|
|
return "unresolved"
|
|
if self._review_file_linked(media_file, roots):
|
|
return "unchanged"
|
|
self._record_scanned_review(media_file, roots, review, scanned=choice)
|
|
return "ambiguous"
|
|
|
|
def _inspection_choice(self, media_file, roots, canonical):
|
|
"""``(canonical, resolution_or_None)`` when the file offers a choice.
|
|
|
|
The resolution is None when the file's current state is already in
|
|
the queue (so the profile did not run). None when it offers none.
|
|
"""
|
|
if canonical is None:
|
|
return None
|
|
version = review_capture.inspection_review_version(canonical)
|
|
located = self._review_location(media_file, roots)
|
|
if version is None or located is None:
|
|
return None
|
|
locator = os.path.normcase(located[3])
|
|
try:
|
|
facts = os.lstat(located[3])
|
|
except OSError:
|
|
facts = None
|
|
state = None
|
|
if facts is not None:
|
|
state = _digest(f"{facts.st_size}:{facts.st_mtime_ns}:{version}")
|
|
if self.db.review_decided_at(
|
|
locator, facts.st_size, facts.st_mtime_ns
|
|
) or self.db.review_fingerprint_queued(
|
|
locator, facts.st_size, facts.st_mtime_ns, version
|
|
):
|
|
return canonical, None
|
|
memory = self._untraced_memory()
|
|
key = _memory_key(locator)
|
|
if state is not None and memory.get(key) == state:
|
|
return None
|
|
resolution = review_capture.inspection_choice(canonical)
|
|
if resolution is not None:
|
|
memory.pop(key, None)
|
|
return canonical, resolution
|
|
if state is not None:
|
|
memory.pop(key, None) # re-inserted last: the newest state
|
|
memory[key] = state
|
|
while len(memory) > MAX_UNTRACED_MEMORY:
|
|
del memory[next(iter(memory))]
|
|
return None
|
|
|
|
def _settle_stale_reviews(self, media_file, roots, canonical, review=None):
|
|
"""Remove the pending, undecided items of an untraced file's current
|
|
bytes recorded under another version (best effort, counted in
|
|
``review["untraced"]``).
|
|
|
|
The current resolver and table offer nothing to choose in these
|
|
bytes, so an item of an older version would only refuse Apply.
|
|
"""
|
|
located = self._review_location(media_file, roots)
|
|
if located is None or canonical is None:
|
|
return 0
|
|
# An unusable semantics table says nothing about the current
|
|
# versions; never let it retire items a working table recorded.
|
|
if review_capture.inspection_review_version(canonical) is None:
|
|
return 0
|
|
try:
|
|
facts = os.lstat(located[3])
|
|
removed = self.db.settle_stale_review_items(
|
|
os.path.normcase(located[3]),
|
|
facts.st_size,
|
|
facts.st_mtime_ns,
|
|
review_capture.item_versions(canonical),
|
|
)
|
|
except Exception:
|
|
self.logger.warning(
|
|
f"Stale Needs review items of {media_file.name} could not be "
|
|
"removed",
|
|
exc_info=True,
|
|
)
|
|
if review is not None:
|
|
review["errors"] += 1
|
|
return 0
|
|
if review is not None:
|
|
review["untraced"] += removed
|
|
return removed
|
|
|
|
def _untraced_memory(self):
|
|
memory = getattr(self, "_untraced_inspections", None)
|
|
if memory is None:
|
|
memory = self._untraced_inspections = {}
|
|
return memory
|
|
|
|
@staticmethod
|
|
def _review_location(media_file, roots, real_roots=None):
|
|
"""``(root, root_index, relative, spelled)`` of a listed file, or None.
|
|
|
|
``spelled`` is the capture's spelling of the file (the real root joined
|
|
with the relative path); ``os.path.normcase`` of it is the locator
|
|
review items are stored under. ``real_roots`` caches each root's
|
|
``realpath`` across a batch.
|
|
"""
|
|
relative, _label, index = _scan_location(media_file, roots)
|
|
root = next((r for r, _l, i in roots if i == index), None)
|
|
if index is None or root is None:
|
|
return None
|
|
if real_roots is None:
|
|
real_roots = {}
|
|
real = real_roots.get(index)
|
|
if real is None:
|
|
real = real_roots[index] = os.path.realpath(os.fspath(root))
|
|
spelled = os.path.join(real, *relative.split("/"))
|
|
return root, index, relative, spelled
|
|
|
|
def _review_file_linked(self, media_file, roots):
|
|
"""True when a saved row links the scanned file (any spelling)."""
|
|
located = self._review_location(media_file, roots)
|
|
spellings = (str(media_file),) + ((located[3],) if located else ())
|
|
return self.db.review_file_linked(spellings)
|
|
|
|
def _settle_scanned_reviews(self, media_files, roots, review=None):
|
|
"""Remove the pending items of ``media_files`` that are now linked.
|
|
|
|
Blocking; best effort like recording (a failure is logged and
|
|
counted in ``review["errors"]``). Returns how many items left.
|
|
"""
|
|
real_roots = {}
|
|
files = []
|
|
for media_file in media_files:
|
|
located = self._review_location(media_file, roots, real_roots)
|
|
if located is not None:
|
|
spelled = located[3]
|
|
files.append((os.path.normcase(spelled), (str(media_file), spelled)))
|
|
try:
|
|
settled = self.db.settle_linked_review_items(files) if files else 0
|
|
except Exception:
|
|
self.logger.warning(
|
|
"Needs review items of linked files could not be removed",
|
|
exc_info=True,
|
|
)
|
|
if review is not None:
|
|
review["errors"] += 1
|
|
return 0
|
|
if review is not None:
|
|
review["settled"] += settled
|
|
return settled
|
|
|
|
def _settle_untraced_reviews(self, media_files, roots, review=None):
|
|
"""Remove the pending items without a choice of untraced ``media_files``.
|
|
|
|
``media_files`` are files found ambiguous with fewer than two
|
|
candidates that carry a prompt. Blocking; best effort like the linked
|
|
settle (a failure is logged and counted in ``review["errors"]``).
|
|
Returns how many items left.
|
|
"""
|
|
locators = []
|
|
for media_file in media_files:
|
|
located = self._review_location(media_file, roots)
|
|
if located is not None:
|
|
locators.append(os.path.normcase(located[3]))
|
|
try:
|
|
removed = self.db.settle_untraced_review_items(locators) if locators else 0
|
|
except Exception:
|
|
self.logger.warning(
|
|
"Needs review items without a choice could not be removed",
|
|
exc_info=True,
|
|
)
|
|
if review is not None:
|
|
review["errors"] += 1
|
|
return 0
|
|
if review is not None:
|
|
review["untraced"] += removed
|
|
return removed
|
|
|
|
def record_review_file(self, image_path):
|
|
"""Apply the Needs review rule to one monitor output (its worker thread).
|
|
|
|
Only scans add items (maintainer ruling, 2026-10-23): the monitor
|
|
only clears the items of outputs it links. Maps ``image_path`` to the
|
|
gallery root that lists it; a file under no current root is
|
|
``skipped``. A linked file's pending items are removed and the answer
|
|
is ``linked``. Any other file is ``skipped``: nothing is captured or
|
|
recorded, and the next scan reads it. While a job runs that a
|
|
``records`` job may not run beside, other than thumbnail work
|
|
(``PREVIEW_ONLY_KINDS``), nothing is written and the answer is
|
|
``deferred``. Errors propagate: the monitor logs them and keeps its
|
|
link.
|
|
"""
|
|
busy = self._jobs.blocker(
|
|
REVIEW_RECORDING_KIND, admission="records", compatible=PREVIEW_ONLY_KINDS
|
|
)
|
|
if busy is not None:
|
|
self.logger.info(
|
|
f"Needs review was not updated for "
|
|
f"{os.path.basename(str(image_path))} while {busy.title} runs; "
|
|
"the next scan reads it again"
|
|
)
|
|
return "deferred"
|
|
full = os.path.abspath(str(image_path))
|
|
located = Path(os.path.realpath(os.path.dirname(full))) / os.path.basename(full)
|
|
roots = _scan_roots(self._get_all_output_dirs())
|
|
if self._review_location(located, roots) is None:
|
|
return "skipped"
|
|
if self._review_file_linked(located, roots):
|
|
self._settle_scanned_reviews([located], roots)
|
|
return "linked"
|
|
return "skipped"
|
|
|
|
@staticmethod
|
|
def _log_review_items(job, review):
|
|
"""The scan log's Needs review summary lines (none when nothing moved)."""
|
|
prefix = SCAN_LOG_PREFIXES["review"]
|
|
if review["added"]:
|
|
job.log_line(
|
|
f"{prefix} {_plural(review['added'], 'image')} added to Needs review"
|
|
)
|
|
already = review["queued"] - review["added"]
|
|
if already:
|
|
job.log_line(
|
|
f"{prefix} {_plural(already, 'image')} already in Needs review"
|
|
)
|
|
if review["settled"]:
|
|
job.log_line(
|
|
f"{SCAN_LOG_PREFIXES['known']} "
|
|
f"{_plural(review['settled'], 'image')} left Needs review · now linked"
|
|
)
|
|
if review["untraced"]:
|
|
job.log_line(
|
|
f"{SCAN_LOG_PREFIXES['known']} "
|
|
f"{_plural(review['untraced'], 'image')} left Needs review · "
|
|
f"{NOTHING_TO_CHOOSE}"
|
|
)
|
|
if review["errors"]:
|
|
job.log_line(
|
|
f"{SCAN_LOG_PREFIXES['errors']} "
|
|
f"{_plural(review['errors'], 'image')} could not be added to "
|
|
"Needs review"
|
|
)
|