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. 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 - **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. 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 - **Preview Blob Lifecycle.** Character-library and Rehearser local voice previews now revoke replaced or completed
object URLs, reducing retained browser memory during repeated auditioning. object URLs, reducing retained browser memory during repeated auditioning.
- **Character Library Smoke Coverage.** Added deterministic browser coverage for opening Characters / Cast, profile - **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 from __future__ import annotations
import base64 import base64
import hashlib
import re import re
from fastapi import APIRouter, HTTPException, Request 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}") @router.get("/api/characters/{char_id:path}")
async def characters_get(char_id: str): async def characters_get(char_id: str):
rec = char_get(char_id) rec = char_get(char_id)

View File

@ -143,6 +143,22 @@ class SecurityValidationTests(unittest.TestCase):
finally: finally:
tts._VOICE_DESIGN_JOBS.pop(job_id, None) 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): def test_fish_cache_eviction_is_bounded(self):
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory