feat: add visual pair audit
This commit is contained in:
parent
474cdaf8c7
commit
dcd6bc9aea
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user