Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,35 @@ API_HOST=127.0.0.1
API_PORT=8000
AI_INFERENCE_HOST=127.0.0.1
AI_INFERENCE_PORT=8001
AI_INFERENCE_URL=http://127.0.0.1:8001
REDIS_URL=redis://localhost:6379/0
# Identity and foundation APIs are opt-in; use local HTTPS with this exact origin.
# Development validate_image dispatch and opt-in general consumer (separate processes).
WORKER_DISPATCH_ENABLED=0
WORKER_IMAGE_VALIDATION_ENABLED=0
WORKER_INFERENCE_ENABLED=0
WORKER_RULE_EVALUATION_ENABLED=0
# Identity, foundation and image-job query APIs are opt-in; use this exact HTTPS origin.
API_IDENTITY_ENABLED=0
PUBLIC_ORIGIN=https://labsafe.example.org
CSRF_KEY_FILE=.local-secrets/csrf-key
DATABASE_URL_FILE=.local-secrets/database-url
DATABASE_SCHEMA=labsafe_dev_local

# Dev/test upload grants, validation acceptance and image metadata only. Requires a private,
# versioned bucket, least-privilege API credentials and same-origin HTTPS:443. No consumer yet.
# Dev/test upload grants, validation acceptance, image metadata and download signing. Requires a private,
# versioned bucket, least-privilege API credentials and same-origin HTTPS:443.
# New manual image-job replay also requires this API storage configuration.
# Image downloads use the same API credentials with O/A GetObjectVersion permission.
API_UPLOADS_ENABLED=0
S3_ENDPOINT=http://127.0.0.1:9000
S3_PUBLIC_ENDPOINT=https://labsafe.example.org
S3_BUCKET=labsafe-private
S3_REGION=us-east-1
S3_ACCESS_KEY_FILE=.local-secrets/s3-api-access-key
S3_SECRET_KEY_FILE=.local-secrets/s3-api-secret-key
# General Worker credentials: staging GET/version; original/analysis GET/version + PUT.
# No Delete, ListBucket or administration; never fall back to API credentials.
S3_WORKER_ACCESS_KEY_FILE=.local-secrets/s3-worker-access-key
S3_WORKER_SECRET_KEY_FILE=.local-secrets/s3-worker-secret-key

AI_MODE=mock
AI_TOKEN_FILE=.local-secrets/ai-token
Expand Down
96 changes: 88 additions & 8 deletions DEVELOPMENT.md

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions apps/api/app/inference_runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Inspection item submission route."""

from typing import Annotated, Literal
from uuid import UUID

from fastapi import APIRouter, Request
from pydantic import Field

from apps.api.app.foundations import Identifier
from apps.api.app.identity import Body, CanonicalJSONResponse
from packages.application.inference_runs import InferenceRunApplication
from packages.domain.security import ServiceError
from packages.persistence.security import SESSION_COOKIE


class ImageSelectionBody(Body):
image_id: Identifier
role: Literal["overview", "detail"]
parent_image_id: Identifier | None


class ItemSubmitBody(Body):
expected_version: Annotated[int, Field(ge=1, le=2147483647)]
images: list[ImageSelectionBody] = Field(min_length=1, max_length=3)


def mount_inference_runs(app, identity):
app.state.inference_runs = InferenceRunApplication(identity) if identity is not None else None
router = APIRouter(prefix="/api/v1")

@router.post(
"/inspection-items/{item_id}/submit", status_code=202, operation_id="submitInspectionItem"
)
def submit_item(item_id: UUID, body: ItemSubmitBody, request: Request):
if request.query_params:
raise ServiceError("VALIDATION_ERROR", 422, "Submit takes no query parameters")
if app.state.inference_runs is None:
raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Inference service not configured")
value = body.model_dump(mode="json")
status, response = app.state.inference_runs.submit(
request.cookies.get(SESSION_COOKIE),
request.headers["x-csrf-token"],
request.headers["idempotency-key"],
str(item_id),
value,
request.state.request_id,
)
return CanonicalJSONResponse(response, status_code=status)

app.include_router(router)
58 changes: 58 additions & 0 deletions apps/api/app/jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Strict job metadata and administrator replay adapters; validate_image branch only."""

from uuid import UUID

from fastapi import APIRouter, Request

from apps.api.app.identity import CanonicalJSONResponse, VersionCommand, pagination
from packages.application.jobs import JobApplication
from packages.domain.security import ServiceError
from packages.persistence.security import SESSION_COOKIE


def mount_jobs(app, identity, storage):
app.state.jobs = JobApplication(identity, storage) if identity is not None else None
router = APIRouter(prefix="/api/v1")

def context(request, allowed=()):
if any(
k not in allowed or len(request.query_params.getlist(k)) != 1
for k in request.query_params
):
raise ServiceError("VALIDATION_ERROR", 422, "Unexpected or duplicate query parameter")
if app.state.jobs is None:
raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Job service not configured")
return app.state.jobs

@router.get("/jobs/{job_id}", operation_id="getJob")
def get_job(job_id: UUID, request: Request):
return context(request).read(
"get", request.cookies.get(SESSION_COOKIE), request.state.request_id, job_id=str(job_id)
)

@router.get("/dead-letters", operation_id="listDeadLetters")
def dead_letters(request: Request, laboratory_id: UUID | None = None):
service = context(request, ("page", "page_size", "laboratory_id"))
page, size = pagination(request)
return service.read(
"dead_letters",
request.cookies.get(SESSION_COOKIE),
request.state.request_id,
page=page,
page_size=size,
laboratory_id=str(laboratory_id) if laboratory_id else None,
)

@router.post("/dead-letters/{job_id}/replay", status_code=202, operation_id="replayJob")
def replay(job_id: UUID, body: VersionCommand, request: Request):
status, response = context(request).replay(
request.cookies.get(SESSION_COOKIE),
request.headers["x-csrf-token"],
request.headers["idempotency-key"],
str(job_id),
body.model_dump(),
request.state.request_id,
)
return CanonicalJSONResponse(response, status_code=status)

app.include_router(router)
19 changes: 15 additions & 4 deletions apps/api/app/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Development-only business API with opt-in upload validation acceptance."""
"""Development-only business API with image-job queries and opt-in storage replay."""

import os
from contextlib import asynccontextmanager
Expand All @@ -7,7 +7,11 @@

from apps.api.app.foundations import mount_foundations
from apps.api.app.identity import configured_identity, mount_identity
from apps.api.app.inference_runs import mount_inference_runs
from apps.api.app.item_queries import mount_item_queries
from apps.api.app.jobs import mount_jobs
from apps.api.app.review import mount_review
from apps.api.app.rules import mount_rules
from apps.api.app.uploads import mount_uploads
from packages.shared.environment import development_environment

Expand Down Expand Up @@ -54,7 +58,11 @@ async def lifespan(app):
mount_identity(app, identity, public_origin)
mount_foundations(app, identity)
mount_item_queries(app, identity)
mount_inference_runs(app, identity)
mount_uploads(app, identity, storage)
mount_jobs(app, identity, storage)
mount_rules(app, identity)
mount_review(app, identity)

@app.get("/health")
def health() -> dict[str, str | bool]:
Expand All @@ -69,9 +77,12 @@ def ready() -> dict[str, str | bool]:
"environment": environment,
"is_simulated": True,
"scope": (
"I-02B/C identity, I-02D foundations, I-02E item queries "
"and optional I-02F1/F2 upload signing and validation acceptance only; "
"storage, workflow and real-model readiness not implemented"
"I-02B/C identity, I-02D foundations, I-02E item queries, "
"I-03A3 image-job queries and optional replay, I-02G1 submit enqueue, "
"optional I-02F1/F2 upload signing and validation acceptance only, "
"optional I-02F4 image download signing, and development rule "
"configuration lifecycle; "
"storage, inference execution and real-model readiness not implemented"
),
}
return {
Expand Down
173 changes: 173 additions & 0 deletions apps/api/app/review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""Facts and finding review API adapters."""

from typing import Annotated, Literal
from uuid import UUID

from fastapi import APIRouter, Request
from pydantic import Field

from apps.api.app.foundations import Identifier
from apps.api.app.identity import Body, CanonicalJSONResponse, VersionCommand, pagination
from packages.application.review import ReviewApplication
from packages.domain.security import ServiceError
from packages.persistence.security import SESSION_COOKIE


class EvidenceRefBody(Body):
image_id: Identifier
detection_id: Identifier | None
crop_id: Identifier | None


class EntityFactBody(Body):
detection_id: Identifier
entity_id: Identifier | None
resolution: Literal["resolved", "candidate", "unknown"]
source: Literal["model", "human"]
evidence: Annotated[list[EvidenceRefBody], Field(min_length=1, max_length=10)]


class RelationFactBody(Body):
source_detection_id: Identifier
target_detection_id: Identifier
relation: Literal["adjacent", "not_adjacent", "unknown"]
same_location: bool | None
source: Literal["model", "human"]
evidence: Annotated[list[EvidenceRefBody], Field(min_length=1, max_length=10)]


class DateFactBody(Body):
detection_id: Identifier
kind: Literal["expiry", "production", "opened", "unknown"]
value: Annotated[str, Field(pattern=r"^\d{4}-\d{2}-\d{2}$")] | None
source: Literal["model", "human"]
evidence: Annotated[list[EvidenceRefBody], Field(min_length=1, max_length=10)]


class FactsEditBody(Body):
expected_version: Annotated[int, Field(ge=1, le=2147483647)]
entities: Annotated[list[EntityFactBody], Field(max_length=100)]
relations: Annotated[list[RelationFactBody], Field(max_length=200)]
dates: Annotated[list[DateFactBody], Field(max_length=100)]
reason: Annotated[str, Field(min_length=1, max_length=2000, pattern=r"\S")]


class FindingUncertainBody(VersionCommand):
reason_code: Literal[
"blur",
"occluded",
"label_unreadable",
"rule_missing",
"insufficient_evidence",
"other",
]


def mount_review(app, identity):
app.state.review = ReviewApplication(identity) if identity is not None else None
router = APIRouter(prefix="/api/v1")

def context(request, allowed=()):
if any(
key not in allowed or len(request.query_params.getlist(key)) != 1
for key in request.query_params
):
raise ServiceError("VALIDATION_ERROR", 422, "Unexpected or duplicate query parameter")
if app.state.review is None:
raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Review service not configured")
return app.state.review

def read(request, kind, resource_id=None):
allowed = (
()
if resource_id
else (
"page",
"page_size",
"laboratory_id",
"current_only",
"status",
"severity",
)
)
service = context(request, allowed)
page, size = pagination(request)
laboratory_id = request.query_params.get("laboratory_id")
if laboratory_id is not None:
try:
laboratory_id = str(UUID(laboratory_id))
except (TypeError, ValueError):
raise ServiceError("VALIDATION_ERROR", 422, "Invalid laboratory_id") from None
raw_current = request.query_params.get("current_only", "true")
if raw_current not in {"true", "false"}:
raise ServiceError("VALIDATION_ERROR", 422, "Invalid current_only")
status = request.query_params.get("status")
if status is not None and status not in {
"needs_review",
"confirmed",
"rejected",
"cannot_determine",
"dispatched",
"closed",
}:
raise ServiceError("VALIDATION_ERROR", 422, "Invalid finding status")
severity = request.query_params.get("severity")
if severity is not None and severity not in {"low", "medium", "high", "critical"}:
raise ServiceError("VALIDATION_ERROR", 422, "Invalid finding severity")
return service.read(
kind,
request.cookies.get(SESSION_COOKIE),
request.state.request_id,
resource_id=str(resource_id) if resource_id else None,
laboratory_id=laboratory_id,
page=page,
page_size=size,
current_only=raw_current == "true",
status=status,
severity=severity,
)

def write(request, operation, resource_id, body):
service = context(request)
status, response = service.write(
operation,
request.cookies.get(SESSION_COOKIE),
request.headers["x-csrf-token"],
request.headers["idempotency-key"],
str(resource_id),
body.model_dump(mode="json"),
request.state.request_id,
)
return CanonicalJSONResponse(response, status_code=status)

@router.get("/inspection-items/{item_id}/facts", operation_id="getCurrentFacts")
def get_current_facts(item_id: UUID, request: Request):
return read(request, "facts", item_id)

@router.post(
"/inspection-items/{item_id}/facts", status_code=202, operation_id="factsInspectionItem"
)
def edit_facts(item_id: UUID, body: FactsEditBody, request: Request):
return write(request, "facts", item_id, body)

@router.get("/findings", operation_id="listFindings")
def list_findings(request: Request):
return read(request, "findings")

@router.get("/findings/{finding_id}", operation_id="getFinding")
def get_finding(finding_id: UUID, request: Request):
return read(request, "finding", finding_id)

@router.post("/findings/{finding_id}/confirm", operation_id="confirmFinding")
def confirm_finding(finding_id: UUID, body: VersionCommand, request: Request):
return write(request, "confirm", finding_id, body)

@router.post("/findings/{finding_id}/reject", operation_id="rejectFinding")
def reject_finding(finding_id: UUID, body: VersionCommand, request: Request):
return write(request, "reject", finding_id, body)

@router.post("/findings/{finding_id}/cannot-determine", operation_id="cannotdetermineFinding")
def cannot_determine(finding_id: UUID, body: FindingUncertainBody, request: Request):
return write(request, "cannot-determine", finding_id, body)

app.include_router(router)
Loading
Loading