US03-05: Automate Phase C End-to-End Acceptance (#65)
This commit was merged in pull request #65.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user