From dcd6bc9aea57541d503e7516e55b6ea1695c8860 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 09:13:51 +0200 Subject: [PATCH] feat: add visual pair audit --- CHANGELOG.md | 2 ++ routes/characters.py | 59 ++++++++++++++++++++++++++++++++++++++++++ tests/test_security.py | 16 ++++++++++++ 3 files changed, 77 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 477c96a..93d6361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/routes/characters.py b/routes/characters.py index cfa39da..836763e 100644 --- a/routes/characters.py +++ b/routes/characters.py @@ -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) diff --git a/tests/test_security.py b/tests/test_security.py index 0ee47df..dbdecb9 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -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