US03-05: Automate Phase C End-to-End Acceptance (#65)

This commit was merged in pull request #65.
This commit is contained in:
2026-08-15 14:32:11 +02:00
parent 703ed3167d
commit 2d4dd7395c
6 changed files with 420 additions and 8 deletions

View File

@@ -24,6 +24,7 @@ approval is refused until it is regenerated.
from __future__ import annotations
import json
import os
import uuid
from datetime import datetime, timezone
from typing import Protocol
@@ -79,6 +80,47 @@ def _now() -> datetime:
return datetime.now(timezone.utc)
def _default_provider() -> ProposalProvider | None:
"""The provider a server process uses when none is injected.
Test seam (concept §18: deterministic fakes at the integration boundary, enabled
only by test configuration). When ``PHOTO_PIPELINE_FAKE_NAMING_LOG`` names a
writable file, the API uses the recording fake below so end-to-end tests can
assert exactly what the provider was shown. Otherwise there is no naming
provider yet and generation falls back to the deterministic policy name.
"""
log_path = os.environ.get("PHOTO_PIPELINE_FAKE_NAMING_LOG")
return _RecordingFakeNaming(log_path) if log_path else None
class _RecordingFakeNaming:
"""Deterministic naming fake for end-to-end tests. Appends every summary it is
shown to its log (one JSON object per line) so a test can assert the provider's
inputs, and returns a stable name derived from the evidence. An album whose
folder name contains ``boom`` yields a malformed response, exercising the
quarantine path through the real service."""
model_name = "fake-naming"
def __init__(self, log_path: str) -> None:
self._log_path = log_path
def propose(self, summary: dict) -> dict:
with open(self._log_path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(summary, ensure_ascii=False, sort_keys=True) + "\n")
folder = summary.get("folder_name") or "album"
if "boom" in folder:
return {"rationale": "deliberately missing a name"}
parts = [str(summary["dominant_year"])] if summary.get("dominant_year") else []
parts += (summary.get("locations") or [])[:1]
parts.append(folder.title())
return {
"name": "".join(parts),
"rationale": f"Named from {summary.get('analyzed_count', 0)} analysed photos.",
"confidence": 0.9,
}
def evidence_summary(evidence: dict) -> dict:
"""The minimum aggregated evidence a naming provider may see. Bounded and free of
asset IDs and file paths."""
@@ -131,7 +173,7 @@ class ProposalService:
policy: NamingPolicy | None = None,
) -> None:
self._session_factory = session_factory
self._provider = provider
self._provider = provider if provider is not None else _default_provider()
self._albums = AlbumService(session_factory, library_roots=library_roots)
self._policy = policy or NamingPolicy()
@@ -201,7 +243,9 @@ class ProposalService:
row.status = PROPOSED
row.error_code = row.error_message = None
row.evidence_version = evidence_version
row.model = MODEL if self._provider is not None else "policy"
# Record the model that actually produced the name, so a fake or the
# offline policy fallback is never filed under the real model's name.
row.model = getattr(self._provider, "model_name", MODEL) if self._provider else "policy"
row.prompt_version = PROMPT_VERSION
row.raw_response = result.get("raw")
row.approved_at = None