refactor: delete the legacy session model

Two session models shared one process. `/v1/*` and `/v1/stream` are the live
one; `/api/sessions*` and `/api/compare_live_vs_still` were the earlier model,
still constructed on every `create_app()` through `build_legacy_scans` even
though nothing called them. The web client and `stream_client.py` speak only
`/v1`.

Deleted: `observe.py` (ScanService/PerceptionService/PerceptionAdapter),
`yolo_adapter.py` (imported only by the legacy builder), `pipeline.py` and
`test_suite.py` (the old single-image CLI, reachable only from each other),
`test_observe.py` (its Session* tests), the eight legacy route handlers,
`build_legacy_scans` with its two facades, `create_fastapi_app`, and the
`scans` parameter on `create_app`.

`IdentityRankingTests` moved to `test_identity.py` before `test_observe.py` was
deleted: those five ranking rules are the live path's contract, not the legacy
model's. `test_api.py` keeps every `/v1` and WebSocket test.

Verified: 246 tests OK (2 skipped), and a route dump of a freshly built app
shows no `/api/*` path with all nine `/v1` routes and the WebSocket intact.
This commit is contained in:
kiyo9w 2026-09-28 17:47:50 +09:00
parent 004212a8cf
commit eca03e0c91
10 changed files with 54 additions and 1348 deletions

View file

@ -250,39 +250,6 @@ def get_available_shelf_images(add_dir: str = "add") -> List[Dict[str, str]]:
return items
def build_legacy_scans(engine: RecognitionEngine):
"""The /api/sessions* routes kept from the earlier session model, now backed
by the real engine instead of the fake adapter."""
from observe import PerceptionService, ScanService
from yolo_adapter import YoloMatcherAdapter
class _DetectorFacade:
def __init__(self, engine: RecognitionEngine):
self.engine = engine
self.weights_path = engine.settings.weights_path
def detect(self, image, conf_threshold: float = 0.25, iou_threshold: float = 0.45):
boxes = self.engine.detect(image, conf=conf_threshold)
return {"status": "success", "boxes": boxes, "total_detected": len(boxes)}
def detect_empty_slots(self, image_shape, boxes, min_gap_ratio: float = 0.75):
return self.engine.empty_slots(image_shape, boxes)
def crop_roi(self, image, box):
return self.engine.crop(image, box)
class _MatcherFacade:
def __init__(self, engine: RecognitionEngine):
self.engine = engine
def classify_crop(self, crop, method: str = "hybrid"):
scores = self.engine.score_crop(crop, method=method)
from identity import decide_identity
return decide_identity(scores, method=method)
adapter = YoloMatcherAdapter(_DetectorFacade(engine), _MatcherFacade(engine), method="hybrid")
return ScanService(PerceptionService(adapter))
# ---------------------------------------------------------------------------
@ -295,7 +262,6 @@ def create_app(
settings: Optional[ServerSettings] = None,
*,
web_dir: Optional[str] = None,
scans: Any = None,
) -> FastAPI:
settings = settings or server_settings_from_env()
@asynccontextmanager
@ -323,7 +289,6 @@ def create_app(
allow_headers=["Authorization", "Content-Type"],
)
scans = scans if scans is not None else build_legacy_scans(engine)
# A live session can be recorded so a real scan becomes data to label later.
recorder = FrameRecorder(
settings.record_frames,
@ -994,127 +959,6 @@ def create_app(
async with sessions_lock:
sessions.pop(session_id, None)
# ---------------------------------------------------------------- legacy session routes
@app.post("/api/sessions", dependencies=[Depends(authorize)])
async def legacy_start_session(payload: Dict[str, Any] = None) -> Dict[str, Any]:
payload = payload or {}
session = scans.start_session(
zone_id=str(payload.get("zone_id") or "zone-1"),
catalog_version=str(payload.get("catalog_version") or "stock-v1"),
capture_kind=str(payload.get("capture_kind") or "sampled"),
)
return {"status": "success", "session": session.snapshot()}
@app.post("/api/sessions/{session_id}/frames", dependencies=[Depends(authorize)])
async def legacy_observe_frame(session_id: str, payload: Dict[str, Any] = None) -> Dict[str, Any]:
from observe import FrameRequest, ObserveError
payload = payload or {}
try:
session = scans.get_session(session_id)
raw = payload.get("frame_bytes_b64") or payload.get("image_base64") or ""
if not isinstance(raw, str):
raw = ""
if "," in raw:
raw = raw.split(",", 1)[1]
# Base64 expands ~4/3. Reject the text before decoding it.
if len(raw) > (MAX_FRAME_BYTES * 4) // 3 + 8:
raise _error("PAYLOAD_TOO_LARGE", f"frame larger than {MAX_FRAME_BYTES} bytes", 413)
loop = asyncio.get_running_loop()
try:
frame_bytes = (
await loop.run_in_executor(None, lambda: base64.b64decode(raw, validate=True))
if raw else b""
)
except (ValueError, base64.binascii.Error):
raise _error("BAD_REQUEST", "frame_bytes_b64 is not valid base64", 400)
if len(frame_bytes) > MAX_FRAME_BYTES:
raise _error("PAYLOAD_TOO_LARGE", f"frame larger than {MAX_FRAME_BYTES} bytes", 413)
request = FrameRequest(
frame_id=str(payload.get("frame_id") or ""),
frame_bytes=frame_bytes,
sequence=int(payload.get("sequence", 0)),
captured_at=float(payload.get("captured_at") or 0),
session_id=session.session_id,
zone_id=session.zone_id,
catalog_version=session.catalog_version,
capture_kind=str(payload.get("capture_kind") or session.capture_kind),
width=payload.get("width"),
height=payload.get("height"),
)
if not request.frame_id:
raise _error("BAD_REQUEST", "frame_id required", 400)
observation = await loop.run_in_executor(None, scans.observe_frame, request)
return {"status": "success", "observation": observation.as_dict(), "session": session.snapshot()}
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
@app.get("/api/sessions/{session_id}", dependencies=[Depends(authorize)])
async def legacy_get_session(session_id: str) -> Dict[str, Any]:
from observe import ObserveError
try:
return {"status": "success", "session": scans.get_session(session_id).snapshot()}
except ObserveError as exc:
raise _error("NOT_FOUND", str(exc), 404)
@app.post("/api/sessions/{session_id}/review", dependencies=[Depends(authorize)])
async def legacy_review(session_id: str, payload: Dict[str, Any] = None) -> Dict[str, Any]:
from observe import ObserveError
payload = payload or {}
try:
snapshot = scans.review(
session_id,
actor=str(payload.get("actor") or "operator"),
reason=str(payload.get("reason") or "manual-review"),
note=payload.get("note"),
)
return {"status": "success", "session": snapshot}
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
@app.post("/api/sessions/{session_id}/finalize", dependencies=[Depends(authorize)])
async def legacy_finalize(session_id: str) -> Dict[str, Any]:
from observe import ObserveError
try:
return {"status": "success", "session": scans.finalize(session_id)}
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
@app.post("/api/sessions/{session_id}/cancel", dependencies=[Depends(authorize)])
async def legacy_cancel(session_id: str) -> Dict[str, Any]:
from observe import ObserveError
try:
return {"status": "success", "session": scans.cancel(session_id)}
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
@app.post("/api/sessions/{session_id}/rescan", dependencies=[Depends(authorize)])
async def legacy_rescan(session_id: str) -> Dict[str, Any]:
from observe import ObserveError
try:
return {"status": "success", "session": scans.rescan(session_id).snapshot()}
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
@app.post("/api/compare_live_vs_still", dependencies=[Depends(authorize)])
async def legacy_compare(payload: Dict[str, Any] = None) -> Dict[str, Any]:
from observe import ObserveError
payload = payload or {}
try:
comparison = scans.compare_live_vs_still(
str(payload.get("sampled_session_id")), str(payload.get("still_session_id"))
)
return {"status": "success", "comparison": comparison}
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
# ---------------------------------------------------------------- errors
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
@ -1148,25 +992,12 @@ def create_app(
if os.path.isdir(resolved_web_dir):
app.mount("/", StaticFiles(directory=resolved_web_dir, html=True), name="web")
return app
def create_fastapi_app(
engine: RecognitionEngine,
scans: Any = None,
settings: Optional[ServerSettings] = None,
) -> FastAPI:
"""Back-compat entry point; the server entry point is create_app()."""
return create_app(engine, settings, scans=scans)
# ---------------------------------------------------------------------------
# entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Dorayaki - shelf recognition server")
parser.add_argument("--host", type=str, default=None)