keble-data-infra-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keble_data_infra_api/__init__.py +2 -0
- keble_data_infra_api/api/__init__.py +2 -0
- keble_data_infra_api/api/error_handlers.py +126 -0
- keble_data_infra_api/api/router.py +177 -0
- keble_data_infra_api/api/video_enrichment_router.py +169 -0
- keble_data_infra_api/integrations/__init__.py +2 -0
- keble_data_infra_api/integrations/video_enrichment/__init__.py +2 -0
- keble_data_infra_api/integrations/video_enrichment/service.py +452 -0
- keble_data_infra_api/main.py +66 -0
- keble_data_infra_api/runtime/__init__.py +1 -0
- keble_data_infra_api/runtime/bootstrap.py +347 -0
- keble_data_infra_api/runtime/observability.py +24 -0
- keble_data_infra_api/runtime/provider_registry.py +78 -0
- keble_data_infra_api/runtime/settings.py +216 -0
- keble_data_infra_api/runtime/video_enrichment_client.py +275 -0
- keble_data_infra_api/runtime/video_enrichment_redis.py +202 -0
- keble_data_infra_api/runtime/video_enrichment_store.py +507 -0
- keble_data_infra_api-0.1.0.dist-info/METADATA +152 -0
- keble_data_infra_api-0.1.0.dist-info/RECORD +20 -0
- keble_data_infra_api-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Central HTTP mappings for typed contract and provider errors."""
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
from keble_data_infra_contract.errors import (
|
|
10
|
+
OperationNotSupportedError,
|
|
11
|
+
ProviderAuthenticationError,
|
|
12
|
+
ProviderNotFoundError,
|
|
13
|
+
ProviderRateLimitError,
|
|
14
|
+
ProviderRequestValidationError,
|
|
15
|
+
ProviderTimeoutError,
|
|
16
|
+
ProviderUnavailableError,
|
|
17
|
+
)
|
|
18
|
+
from keble_tiktok.commerce_provider import SourceNotConfiguredError
|
|
19
|
+
from keble_tiktok.providers.echotik.errors import (
|
|
20
|
+
EchoTikAuthError,
|
|
21
|
+
EchoTikBusinessError,
|
|
22
|
+
EchoTikInvalidRequest,
|
|
23
|
+
EchoTikInvalidResponse,
|
|
24
|
+
EchoTikRateLimited,
|
|
25
|
+
EchoTikUnavailable,
|
|
26
|
+
)
|
|
27
|
+
from keble_tiktok.providers.fastmoss.errors import (
|
|
28
|
+
FastMossAuthError,
|
|
29
|
+
FastMossError,
|
|
30
|
+
FastMossInvalidResponse,
|
|
31
|
+
FastMossRateLimited,
|
|
32
|
+
FastMossUnavailable,
|
|
33
|
+
)
|
|
34
|
+
from keble_keepa import KeepaApiException
|
|
35
|
+
|
|
36
|
+
from keble_data_infra_api.integrations.video_enrichment.service import (
|
|
37
|
+
VideoEnrichmentSourceError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _unprocessable(_: Request, exc: Exception) -> JSONResponse:
|
|
42
|
+
"""Map unsupported or invalid provider operations to HTTP 422."""
|
|
43
|
+
|
|
44
|
+
return JSONResponse(status_code=422, content={"detail": str(exc)})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _not_found(_: Request, exc: Exception) -> JSONResponse:
|
|
48
|
+
"""Map unknown provider identities to HTTP 404."""
|
|
49
|
+
|
|
50
|
+
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _auth(_: Request, __: Exception) -> JSONResponse:
|
|
54
|
+
"""Hide upstream credential material while reporting operator failure."""
|
|
55
|
+
|
|
56
|
+
return JSONResponse(
|
|
57
|
+
status_code=503,
|
|
58
|
+
content={"detail": "Upstream authentication failure. Check server credentials."},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _rate_limited(_: Request, exc: Exception) -> JSONResponse:
|
|
63
|
+
"""Preserve upstream throttling semantics for callers."""
|
|
64
|
+
|
|
65
|
+
return JSONResponse(status_code=429, content={"detail": str(exc)})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _unavailable(_: Request, exc: Exception) -> JSONResponse:
|
|
69
|
+
"""Map provider timeouts and unavailability to HTTP 503."""
|
|
70
|
+
|
|
71
|
+
return JSONResponse(status_code=503, content={"detail": str(exc)})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _bad_gateway(_: Request, exc: Exception) -> JSONResponse:
|
|
75
|
+
"""Map invalid or unsuccessful upstream payloads to HTTP 502."""
|
|
76
|
+
|
|
77
|
+
return JSONResponse(status_code=502, content={"detail": str(exc)})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _video_source(_: Request, exc: Exception) -> JSONResponse:
|
|
81
|
+
"""Preserve retryability when the authoritative enrichment read fails."""
|
|
82
|
+
|
|
83
|
+
source_error = cast(VideoEnrichmentSourceError, exc)
|
|
84
|
+
return JSONResponse(
|
|
85
|
+
status_code=503 if source_error.retryable else 502,
|
|
86
|
+
content={"detail": {"code": source_error.code}},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def register_error_handlers(app: FastAPI) -> None:
|
|
91
|
+
"""Register all expected exception mappings once on the sole raw app.
|
|
92
|
+
|
|
93
|
+
Side effects if changes:
|
|
94
|
+
- every generated provider route;
|
|
95
|
+
- Sentry grouping for expected versus unexpected failures.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
for error_type in (
|
|
99
|
+
SourceNotConfiguredError,
|
|
100
|
+
EchoTikInvalidRequest,
|
|
101
|
+
OperationNotSupportedError,
|
|
102
|
+
ProviderRequestValidationError,
|
|
103
|
+
):
|
|
104
|
+
app.add_exception_handler(error_type, _unprocessable)
|
|
105
|
+
app.add_exception_handler(ProviderNotFoundError, _not_found)
|
|
106
|
+
for error_type in (EchoTikAuthError, FastMossAuthError, ProviderAuthenticationError):
|
|
107
|
+
app.add_exception_handler(error_type, _auth)
|
|
108
|
+
for error_type in (EchoTikRateLimited, FastMossRateLimited, ProviderRateLimitError):
|
|
109
|
+
app.add_exception_handler(error_type, _rate_limited)
|
|
110
|
+
for error_type in (
|
|
111
|
+
EchoTikUnavailable,
|
|
112
|
+
FastMossUnavailable,
|
|
113
|
+
ProviderTimeoutError,
|
|
114
|
+
ProviderUnavailableError,
|
|
115
|
+
aiohttp.ClientError,
|
|
116
|
+
):
|
|
117
|
+
app.add_exception_handler(error_type, _unavailable)
|
|
118
|
+
for error_type in (
|
|
119
|
+
EchoTikBusinessError,
|
|
120
|
+
EchoTikInvalidResponse,
|
|
121
|
+
FastMossError,
|
|
122
|
+
FastMossInvalidResponse,
|
|
123
|
+
KeepaApiException,
|
|
124
|
+
):
|
|
125
|
+
app.add_exception_handler(error_type, _bad_gateway)
|
|
126
|
+
app.add_exception_handler(VideoEnrichmentSourceError, _video_source)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Universal route construction from provider-owned operation manifests."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter
|
|
7
|
+
import sentry_sdk
|
|
8
|
+
|
|
9
|
+
from keble_data_infra_contract.manifests import ServiceOperation
|
|
10
|
+
from keble_data_infra_contract.models.base import ContractModel
|
|
11
|
+
from keble_data_infra_contract.models.creator import CommerceCreator
|
|
12
|
+
from keble_data_infra_contract.models.creator_link import CreatorProductLink
|
|
13
|
+
from keble_data_infra_contract.models.identity import Channel, ProviderId
|
|
14
|
+
from keble_data_infra_contract.models.listing import CommerceListing
|
|
15
|
+
from keble_data_infra_contract.models.product import CommerceProduct
|
|
16
|
+
from keble_data_infra_contract.models.store import CommerceStore
|
|
17
|
+
from keble_data_infra_contract.models.video import ShortVideo
|
|
18
|
+
from keble_data_infra_contract.operations import CommerceAction, CommerceResource
|
|
19
|
+
from keble_data_infra_contract.schemas.query import (
|
|
20
|
+
CreatorProductsQuery,
|
|
21
|
+
CreatorRankingQuery,
|
|
22
|
+
ProductCreatorsQuery,
|
|
23
|
+
ProductLookupQuery,
|
|
24
|
+
ProductRankingQuery,
|
|
25
|
+
ProductSearchQuery,
|
|
26
|
+
ProductVideosQuery,
|
|
27
|
+
SellerRankingQuery,
|
|
28
|
+
)
|
|
29
|
+
from keble_data_infra_contract.schemas.result import Page, RankingSnapshot
|
|
30
|
+
|
|
31
|
+
from ..runtime.provider_registry import ProviderRegistry
|
|
32
|
+
|
|
33
|
+
OperationResponse = (
|
|
34
|
+
Page[CommerceProduct]
|
|
35
|
+
| Page[CommerceListing]
|
|
36
|
+
| Page[CreatorProductLink]
|
|
37
|
+
| Page[ShortVideo]
|
|
38
|
+
| RankingSnapshot[CommerceProduct]
|
|
39
|
+
| RankingSnapshot[CommerceListing]
|
|
40
|
+
| RankingSnapshot[CommerceCreator]
|
|
41
|
+
| RankingSnapshot[CommerceStore]
|
|
42
|
+
)
|
|
43
|
+
OperationEndpoint = Callable[..., Awaitable[OperationResponse]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ProviderOperationDto(ContractModel):
|
|
47
|
+
"""One provider capability returned by discovery."""
|
|
48
|
+
|
|
49
|
+
resource: CommerceResource
|
|
50
|
+
action: CommerceAction
|
|
51
|
+
path: str
|
|
52
|
+
operation_id: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ProviderDiscoveryDto(ContractModel):
|
|
56
|
+
"""One provider and its complete explicit operation set."""
|
|
57
|
+
|
|
58
|
+
channel: Channel
|
|
59
|
+
provider_id: ProviderId
|
|
60
|
+
operations: tuple[ProviderOperationDto, ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _invoke(
|
|
64
|
+
operation: ServiceOperation[ContractModel, ContractModel],
|
|
65
|
+
request: ContractModel,
|
|
66
|
+
) -> OperationResponse:
|
|
67
|
+
"""Invoke one manifest operation and erase only the heterogeneous response union."""
|
|
68
|
+
|
|
69
|
+
with sentry_sdk.start_span(
|
|
70
|
+
op="data_infra.provider_operation",
|
|
71
|
+
name=operation.key.operation_id(),
|
|
72
|
+
) as span:
|
|
73
|
+
span.set_data("channel", operation.key.channel.value)
|
|
74
|
+
span.set_data("provider", operation.key.provider_id.root)
|
|
75
|
+
span.set_data("resource", operation.key.resource.value)
|
|
76
|
+
span.set_data("action", operation.key.action.value)
|
|
77
|
+
return cast(OperationResponse, await operation.invoke(request))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _endpoint_for(
|
|
81
|
+
operation: ServiceOperation[ContractModel, ContractModel],
|
|
82
|
+
) -> OperationEndpoint:
|
|
83
|
+
"""Create a concrete typed FastAPI endpoint for one request model.
|
|
84
|
+
|
|
85
|
+
Side effects if changes:
|
|
86
|
+
- request body schemas for every generated provider route;
|
|
87
|
+
- manifest handler invocation and response validation;
|
|
88
|
+
- OpenAPI route parity tests.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
if operation.request_model is ProductLookupQuery:
|
|
92
|
+
async def product_lookup(request: ProductLookupQuery) -> OperationResponse:
|
|
93
|
+
return await _invoke(operation, request)
|
|
94
|
+
return product_lookup
|
|
95
|
+
if operation.request_model is ProductSearchQuery:
|
|
96
|
+
async def product_search(request: ProductSearchQuery) -> OperationResponse:
|
|
97
|
+
return await _invoke(operation, request)
|
|
98
|
+
return product_search
|
|
99
|
+
if operation.request_model is ProductRankingQuery:
|
|
100
|
+
async def product_ranking(request: ProductRankingQuery) -> OperationResponse:
|
|
101
|
+
return await _invoke(operation, request)
|
|
102
|
+
return product_ranking
|
|
103
|
+
if operation.request_model is CreatorRankingQuery:
|
|
104
|
+
async def creator_ranking(request: CreatorRankingQuery) -> OperationResponse:
|
|
105
|
+
return await _invoke(operation, request)
|
|
106
|
+
return creator_ranking
|
|
107
|
+
if operation.request_model is SellerRankingQuery:
|
|
108
|
+
async def seller_ranking(request: SellerRankingQuery) -> OperationResponse:
|
|
109
|
+
return await _invoke(operation, request)
|
|
110
|
+
return seller_ranking
|
|
111
|
+
if operation.request_model is ProductCreatorsQuery:
|
|
112
|
+
async def product_creators(request: ProductCreatorsQuery) -> OperationResponse:
|
|
113
|
+
return await _invoke(operation, request)
|
|
114
|
+
return product_creators
|
|
115
|
+
if operation.request_model is CreatorProductsQuery:
|
|
116
|
+
async def creator_products(request: CreatorProductsQuery) -> OperationResponse:
|
|
117
|
+
return await _invoke(operation, request)
|
|
118
|
+
return creator_products
|
|
119
|
+
if operation.request_model is ProductVideosQuery:
|
|
120
|
+
async def product_videos(request: ProductVideosQuery) -> OperationResponse:
|
|
121
|
+
return await _invoke(operation, request)
|
|
122
|
+
return product_videos
|
|
123
|
+
raise ValueError(f"request model is not registered for HTTP: {operation.request_model}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def create_router(registry: ProviderRegistry) -> APIRouter:
|
|
127
|
+
"""Build every raw route from the central registry and no provider routers."""
|
|
128
|
+
|
|
129
|
+
router = APIRouter()
|
|
130
|
+
|
|
131
|
+
@router.get("/health", operation_id="health")
|
|
132
|
+
async def health() -> dict[str, str]:
|
|
133
|
+
"""Report process liveness without contacting providers."""
|
|
134
|
+
|
|
135
|
+
return {"status": "ok"}
|
|
136
|
+
|
|
137
|
+
@router.get(
|
|
138
|
+
"/v1/{channel}/providers",
|
|
139
|
+
response_model=list[ProviderDiscoveryDto],
|
|
140
|
+
operation_id="list_channel_providers",
|
|
141
|
+
)
|
|
142
|
+
async def list_providers(channel: Channel) -> list[ProviderDiscoveryDto]:
|
|
143
|
+
"""List only capabilities explicitly registered for one channel."""
|
|
144
|
+
|
|
145
|
+
with sentry_sdk.start_span(
|
|
146
|
+
op="data_infra.registry.discovery",
|
|
147
|
+
name=f"providers:{channel.value}",
|
|
148
|
+
) as span:
|
|
149
|
+
manifests = registry.providers_for(channel)
|
|
150
|
+
span.set_data("provider_count", len(manifests))
|
|
151
|
+
return [
|
|
152
|
+
ProviderDiscoveryDto(
|
|
153
|
+
channel=manifest.channel,
|
|
154
|
+
provider_id=manifest.provider_id,
|
|
155
|
+
operations=tuple(
|
|
156
|
+
ProviderOperationDto(
|
|
157
|
+
resource=operation.key.resource,
|
|
158
|
+
action=operation.key.action,
|
|
159
|
+
path=operation.key.http_path(),
|
|
160
|
+
operation_id=operation.key.operation_id(),
|
|
161
|
+
)
|
|
162
|
+
for operation in manifest.operations
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
for manifest in manifests
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
for operation in registry.operations:
|
|
169
|
+
router.add_api_route(
|
|
170
|
+
operation.key.http_path(),
|
|
171
|
+
_endpoint_for(operation),
|
|
172
|
+
methods=["POST"],
|
|
173
|
+
response_model=operation.response_model,
|
|
174
|
+
operation_id=operation.key.operation_id(),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return router
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Internal-only query and recovery routes for local enrichment projections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query, status
|
|
8
|
+
|
|
9
|
+
from keble_data_infra_api.integrations.video_enrichment.service import IngestionOutcome
|
|
10
|
+
from keble_data_infra_contract.models.video_enrichment import (
|
|
11
|
+
VideoEnrichmentJob,
|
|
12
|
+
VideoEnrichmentIngestionRecord,
|
|
13
|
+
VideoEnrichmentProjection,
|
|
14
|
+
VideoEnrichmentSubmitRequest,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VideoEnrichmentRouterServiceProtocol(Protocol):
|
|
19
|
+
"""Typed application surface consumed by internal enrichment routes."""
|
|
20
|
+
|
|
21
|
+
async def get_projection(
|
|
22
|
+
self,
|
|
23
|
+
result_id: str,
|
|
24
|
+
) -> VideoEnrichmentProjection | None:
|
|
25
|
+
"""Read one normalized projection by result identity."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
async def submit(
|
|
29
|
+
self,
|
|
30
|
+
request: VideoEnrichmentSubmitRequest,
|
|
31
|
+
) -> VideoEnrichmentJob:
|
|
32
|
+
"""Submit one idempotent independent enrichment job."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
async def get_job(self, job_id: str) -> VideoEnrichmentJob | None:
|
|
36
|
+
"""Read one submitted job by stable identity."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
async def get_projection_by_job_id(
|
|
40
|
+
self,
|
|
41
|
+
job_id: str,
|
|
42
|
+
) -> VideoEnrichmentProjection | None:
|
|
43
|
+
"""Read the newest normalized projection by submitted job identity."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
async def list_failed_ingestions(
|
|
47
|
+
self,
|
|
48
|
+
limit: int,
|
|
49
|
+
) -> list[VideoEnrichmentIngestionRecord]:
|
|
50
|
+
"""List bounded durable ingestion failures."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
async def retry_failed_ingestion(
|
|
54
|
+
self,
|
|
55
|
+
event_id: str,
|
|
56
|
+
) -> IngestionOutcome | None:
|
|
57
|
+
"""Retry one durable failed completion event."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
async def replay_result(self, result_id: str) -> IngestionOutcome:
|
|
61
|
+
"""Re-ingest one authoritative result through the canonical state machine."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_video_enrichment_router(
|
|
66
|
+
service: VideoEnrichmentRouterServiceProtocol,
|
|
67
|
+
) -> APIRouter:
|
|
68
|
+
"""Build internal operations without adding fields to public commerce APIs."""
|
|
69
|
+
|
|
70
|
+
router = APIRouter(prefix="/internal", tags=["video-enrichment-internal"])
|
|
71
|
+
|
|
72
|
+
@router.post(
|
|
73
|
+
"/video-enrichments:submit",
|
|
74
|
+
response_model=VideoEnrichmentJob,
|
|
75
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
76
|
+
)
|
|
77
|
+
async def submit_video_enrichment(
|
|
78
|
+
request: VideoEnrichmentSubmitRequest,
|
|
79
|
+
) -> VideoEnrichmentJob:
|
|
80
|
+
"""Submit video analysis without exposing the independent service to consumers.
|
|
81
|
+
|
|
82
|
+
Side effects if changes:
|
|
83
|
+
- Stand Out ENRICH workers call only this centralized data-infra API;
|
|
84
|
+
- deterministic upstream idempotency makes retrying the same command safe.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
return await service.submit(request)
|
|
88
|
+
|
|
89
|
+
@router.get(
|
|
90
|
+
"/video-enrichment-jobs/{job_id}",
|
|
91
|
+
response_model=VideoEnrichmentJob,
|
|
92
|
+
responses={404: {"description": "Enrichment job not found"}},
|
|
93
|
+
)
|
|
94
|
+
async def get_video_enrichment_job(job_id: str) -> VideoEnrichmentJob:
|
|
95
|
+
"""Read one submitted job without leaking upstream persistence fields."""
|
|
96
|
+
|
|
97
|
+
job = await service.get_job(job_id)
|
|
98
|
+
if job is None:
|
|
99
|
+
raise HTTPException(
|
|
100
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
101
|
+
detail={"code": "VIDEO_ENRICHMENT_JOB_NOT_FOUND"},
|
|
102
|
+
)
|
|
103
|
+
return job
|
|
104
|
+
|
|
105
|
+
@router.get(
|
|
106
|
+
"/video-enrichments/{result_id}",
|
|
107
|
+
response_model=VideoEnrichmentProjection,
|
|
108
|
+
responses={404: {"description": "Projection not found"}},
|
|
109
|
+
)
|
|
110
|
+
async def get_video_enrichment(result_id: str) -> VideoEnrichmentProjection:
|
|
111
|
+
projection = await service.get_projection(result_id)
|
|
112
|
+
if projection is None:
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
115
|
+
detail={"code": "VIDEO_ENRICHMENT_NOT_FOUND"},
|
|
116
|
+
)
|
|
117
|
+
return projection
|
|
118
|
+
|
|
119
|
+
@router.get(
|
|
120
|
+
"/video-enrichment-jobs/{job_id}/projection",
|
|
121
|
+
response_model=VideoEnrichmentProjection,
|
|
122
|
+
responses={404: {"description": "Completed projection not found"}},
|
|
123
|
+
)
|
|
124
|
+
async def get_video_enrichment_by_job(
|
|
125
|
+
job_id: str,
|
|
126
|
+
) -> VideoEnrichmentProjection:
|
|
127
|
+
"""Resolve one submitted job to its newest normalized local projection.
|
|
128
|
+
|
|
129
|
+
Side effects if changes:
|
|
130
|
+
- Stand Out ENRICH workers depend on this API-owned correlation route;
|
|
131
|
+
- a 404 means completion ingestion is not yet durably available and is retryable.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
projection = await service.get_projection_by_job_id(job_id)
|
|
135
|
+
if projection is None:
|
|
136
|
+
raise HTTPException(
|
|
137
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
138
|
+
detail={"code": "VIDEO_ENRICHMENT_JOB_PROJECTION_NOT_FOUND"},
|
|
139
|
+
)
|
|
140
|
+
return projection
|
|
141
|
+
|
|
142
|
+
@router.get(
|
|
143
|
+
"/video-enrichment-ingestions/failed",
|
|
144
|
+
response_model=list[VideoEnrichmentIngestionRecord],
|
|
145
|
+
)
|
|
146
|
+
async def list_failed_video_enrichment_ingestions(
|
|
147
|
+
limit: int = Query(default=100, ge=1, le=500),
|
|
148
|
+
) -> list[VideoEnrichmentIngestionRecord]:
|
|
149
|
+
return await service.list_failed_ingestions(limit=limit)
|
|
150
|
+
|
|
151
|
+
@router.post(
|
|
152
|
+
"/video-enrichment-ingestions/{event_id}/retry",
|
|
153
|
+
responses={404: {"description": "Failed ingestion not found"}},
|
|
154
|
+
)
|
|
155
|
+
async def retry_video_enrichment_ingestion(event_id: str) -> dict[str, str | None]:
|
|
156
|
+
outcome = await service.retry_failed_ingestion(event_id)
|
|
157
|
+
if outcome is None:
|
|
158
|
+
raise HTTPException(
|
|
159
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
160
|
+
detail={"code": "FAILED_INGESTION_NOT_FOUND"},
|
|
161
|
+
)
|
|
162
|
+
return {"disposition": outcome.disposition.value, "reason": outcome.reason}
|
|
163
|
+
|
|
164
|
+
@router.post("/video-enrichments/{result_id}/replay")
|
|
165
|
+
async def replay_video_enrichment_result(result_id: str) -> dict[str, str | None]:
|
|
166
|
+
outcome = await service.replay_result(result_id)
|
|
167
|
+
return {"disposition": outcome.disposition.value, "reason": outcome.reason}
|
|
168
|
+
|
|
169
|
+
return router
|