feat: add visual pair audit

This commit is contained in:
mARTin-B78 2026-09-04 09:13:51 +02:00
parent 474cdaf8c7
commit dcd6bc9aea
3 changed files with 77 additions and 0 deletions

View File

@ -78,6 +78,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
approximately 4 GB RAM or less, while images remain lazy-loaded.
- **Visual Pair Review Warning.** Character details now flag profile/reference pairs when consistency metadata is
missing, stale, changed after profile edits, or explicitly marked inconsistent—even when the prompt version is current.
- **Visual Pair Audit.** Added a read-only character-library review endpoint that detects incomplete pairs, reused or
identical profile/reference assets, legacy metadata, changed appearance fields, and explicitly inconsistent pairs.
- **Preview Blob Lifecycle.** Character-library and Rehearser local voice previews now revoke replaced or completed
object URLs, reducing retained browser memory during repeated auditioning.
- **Character Library Smoke Coverage.** Added deterministic browser coverage for opening Characters / Cast, profile

View File

@ -10,6 +10,7 @@ Mirrors the IndexedDB API in characters-library.js so the JS swap is mechanical:
from __future__ import annotations
import base64
import hashlib
import re
from fastapi import APIRouter, HTTPException, Request
@ -98,6 +99,64 @@ async def characters_concept_art_image(char_id: str):
)
def _visual_asset_digest(value: object) -> str:
match = _DATA_URL_RE.match(str(value or ""))
if not match:
return ""
try:
return hashlib.sha256(base64.b64decode(match.group(2), validate=True)).hexdigest()
except Exception:
return ""
@router.get("/api/characters/visual-pair-review")
async def characters_visual_pair_review():
"""Report stored profile/reference identity problems without rewriting records."""
from routes.conversation import _CHARACTER_VISUAL_PROMPT_VERSION, _character_identity_fingerprint
records = char_get_all()
digest_owners: dict[str, list[str]] = {}
reports = []
for record in records:
sheet = record.get("sheet") if isinstance(record.get("sheet"), dict) else {}
profile = record.get("image") or sheet.get("image")
reference = sheet.get("concept_art_image")
profile_digest = _visual_asset_digest(profile)
reference_digest = _visual_asset_digest(reference)
for kind, digest in (("profile", profile_digest), ("reference", reference_digest)):
if digest:
digest_owners.setdefault(digest, []).append(f"{record.get('id')}:{kind}")
issues = []
if bool(profile) != bool(reference):
issues.append("incomplete_pair")
if profile and reference:
if profile_digest and profile_digest == reference_digest:
issues.append("same_asset_for_profile_and_reference")
expected = _character_identity_fingerprint(sheet)
if sheet.get("consistent_prompt_version") != _CHARACTER_VISUAL_PROMPT_VERSION:
issues.append("legacy_or_missing_prompt_version")
if not sheet.get("consistent_identity_fingerprint"):
issues.append("missing_identity_fingerprint")
elif sheet.get("consistent_identity_fingerprint") != expected:
issues.append("appearance_changed_since_generation")
if sheet.get("identity_consistent") is False:
issues.append("marked_inconsistent")
if issues:
reports.append({"id": record.get("id"), "name": record.get("name", ""), "issues": issues})
for owners in digest_owners.values():
character_ids = {owner.rsplit(":", 1)[0] for owner in owners}
if len(character_ids) > 1:
for owner in owners:
char_id = owner.rsplit(":", 1)[0]
report = next((item for item in reports if item["id"] == char_id), None)
if report is None:
report = {"id": char_id, "name": next((r.get("name", "") for r in records if r.get("id") == char_id), ""), "issues": []}
reports.append(report)
if "asset_reused_across_characters" not in report["issues"]:
report["issues"].append("asset_reused_across_characters")
return {"ok": not reports, "checked": len(records), "issues": reports}
@router.get("/api/characters/{char_id:path}")
async def characters_get(char_id: str):
rec = char_get(char_id)

View File

@ -143,6 +143,22 @@ class SecurityValidationTests(unittest.TestCase):
finally:
tts._VOICE_DESIGN_JOBS.pop(job_id, None)
def test_visual_pair_review_detects_reused_assets(self):
import base64
from unittest.mock import patch
import routes.characters as characters
from routes.characters import characters_visual_pair_review
image = "data:image/png;base64," + base64.b64encode(b"same-image").decode()
records = [
{"id": "c1", "name": "One", "image": image, "sheet": {"concept_art_image": image}},
{"id": "c2", "name": "Two", "image": image, "sheet": {"concept_art_image": ""}},
]
with patch.object(characters, "char_get_all", return_value=records):
result = asyncio.run(characters_visual_pair_review())
self.assertFalse(result["ok"])
self.assertIn("same_asset_for_profile_and_reference", result["issues"][0]["issues"])
self.assertIn("asset_reused_across_characters", result["issues"][0]["issues"])
def test_fish_cache_eviction_is_bounded(self):
from pathlib import Path
from tempfile import TemporaryDirectory