Skip to content

Commit 680f790

Browse files
CopilotCataldir
andauthored
feat: add 6 truth-layer route modules to CRUD service (#129)
* feat: add 6 truth-layer route modules to CRUD service (#105) Co-authored-by: Cataldir <29005497+Cataldir@users.noreply.github.com> * style: format truth-layer route modules for CI --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Cataldir <29005497+Cataldir@users.noreply.github.com> Co-authored-by: Ricardo Cataldi <rcataldi@microsoft.com>
1 parent 8ad64a1 commit 680f790

File tree

9 files changed

+938
-0
lines changed

9 files changed

+938
-0
lines changed

apps/crud-service/src/crud_service/main.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,21 @@
1616
acp_checkout,
1717
acp_payments,
1818
acp_products,
19+
audit_trail,
1920
auth,
2021
cart,
2122
categories,
2223
checkout,
24+
completeness,
2325
health,
2426
orders,
2527
payments,
2628
products,
29+
proposed_attributes,
2730
reviews,
31+
schemas_registry,
32+
truth_attributes,
33+
ucp_products,
2834
users,
2935
webhooks,
3036
)
@@ -154,6 +160,14 @@ async def global_exception_handler(_request, exc):
154160
app.include_router(returns.router, prefix="/api/staff/returns", tags=["Staff Returns"])
155161
app.include_router(shipments.router, prefix="/api/staff/shipments", tags=["Staff Shipments"])
156162

163+
# Truth-layer routes
164+
app.include_router(truth_attributes.router, prefix="/api", tags=["Truth Attributes"])
165+
app.include_router(proposed_attributes.router, prefix="/api", tags=["Proposed Attributes"])
166+
app.include_router(schemas_registry.router, prefix="/api", tags=["Schemas Registry"])
167+
app.include_router(completeness.router, prefix="/api", tags=["Completeness"])
168+
app.include_router(audit_trail.router, prefix="/api", tags=["Audit Trail"])
169+
app.include_router(ucp_products.router, prefix="/api", tags=["UCP Products"])
170+
157171

158172
@app.get("/")
159173
async def root():

apps/crud-service/src/crud_service/routes/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,28 @@
22

33
from crud_service.routes import (
44
acp_products,
5+
audit_trail,
56
auth,
67
cart,
78
categories,
89
checkout,
10+
completeness,
911
health,
1012
orders,
1113
payments,
1214
products,
15+
proposed_attributes,
1316
reviews,
17+
schemas_registry,
18+
truth_attributes,
19+
ucp_products,
1420
users,
1521
webhooks,
1622
)
1723

1824
__all__ = [
1925
"acp_products",
26+
"audit_trail",
2027
"health",
2128
"auth",
2229
"users",
@@ -27,5 +34,10 @@
2734
"checkout",
2835
"payments",
2936
"reviews",
37+
"completeness",
38+
"proposed_attributes",
39+
"schemas_registry",
40+
"truth_attributes",
41+
"ucp_products",
3042
"webhooks",
3143
]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Audit trail routes — immutable audit log for product truth-layer changes."""
2+
3+
from crud_service.repositories.base import BaseRepository
4+
from fastapi import APIRouter, HTTPException, Query, status
5+
from pydantic import BaseModel
6+
7+
router = APIRouter()
8+
9+
10+
class AuditRepository(BaseRepository):
11+
"""Repository for audit events."""
12+
13+
def __init__(self):
14+
super().__init__(container_name="audit_events")
15+
16+
17+
audit_repo = AuditRepository()
18+
19+
20+
class AuditEventResponse(BaseModel):
21+
"""Response model for a single audit event."""
22+
23+
id: str
24+
entity_id: str
25+
action: str
26+
actor: str | None = None
27+
field_name: str | None = None
28+
old_value: object = None
29+
new_value: object = None
30+
confidence: float | None = None
31+
source_model: str | None = None
32+
timestamp: str | None = None
33+
metadata: dict | None = None
34+
35+
36+
@router.get("/audit/{entity_id}", response_model=list[AuditEventResponse])
37+
async def get_audit_trail(
38+
entity_id: str,
39+
action: str | None = Query(None, description="Filter by action type"),
40+
limit: int = Query(50, le=200, description="Maximum number of events to return"),
41+
):
42+
"""Get the audit trail for a specific product."""
43+
items = await audit_repo.query(
44+
query="SELECT * FROM c WHERE c.entity_id = @entity_id",
45+
parameters=[{"name": "@entity_id", "value": entity_id}],
46+
partition_key=entity_id,
47+
)
48+
if action:
49+
items = [i for i in items if i.get("action") == action]
50+
return [AuditEventResponse(**item) for item in items[:limit]]
51+
52+
53+
@router.get("/audit", response_model=list[AuditEventResponse])
54+
async def query_audit_events(
55+
action: str | None = Query(None, description="Filter by action type (e.g. enrichment)"),
56+
actor: str | None = Query(None, description="Filter by actor (user or system)"),
57+
entity_id: str | None = Query(None, description="Filter by entity ID"),
58+
limit: int = Query(50, le=200, description="Maximum number of events to return"),
59+
):
60+
"""Query audit events across all products with optional filters."""
61+
if entity_id:
62+
raise HTTPException(
63+
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
64+
detail="Use /audit/{entity_id} for entity-scoped queries",
65+
headers={"Location": f"/api/audit/{entity_id}"},
66+
)
67+
items = await audit_repo.query(query="SELECT * FROM c")
68+
if action:
69+
items = [i for i in items if i.get("action") == action]
70+
if actor:
71+
items = [i for i in items if i.get("actor") == actor]
72+
return [AuditEventResponse(**item) for item in items[:limit]]
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Completeness routes — product completeness scoring reports."""
2+
3+
from crud_service.repositories.base import BaseRepository
4+
from fastapi import APIRouter, HTTPException, Query, status
5+
from pydantic import BaseModel
6+
7+
router = APIRouter()
8+
9+
10+
class CompletenessRepository(BaseRepository):
11+
"""Repository for completeness reports."""
12+
13+
def __init__(self):
14+
super().__init__(container_name="completeness_reports")
15+
16+
17+
completeness_repo = CompletenessRepository()
18+
19+
20+
class GapDetail(BaseModel):
21+
"""Details of a single missing or incomplete field."""
22+
23+
field_name: str
24+
required: bool = True
25+
reason: str | None = None
26+
27+
28+
class CompletenessReportResponse(BaseModel):
29+
"""Response model for a product completeness report."""
30+
31+
id: str
32+
entity_id: str
33+
score: float
34+
required_fields: int
35+
completed_fields: int
36+
gaps: list[GapDetail] = []
37+
category_id: str | None = None
38+
schema_version: str | None = None
39+
generated_at: str | None = None
40+
41+
42+
class CompletenessSummaryResponse(BaseModel):
43+
"""Aggregate completeness statistics."""
44+
45+
total_products: int
46+
average_score: float
47+
fully_complete: int
48+
needs_enrichment: int
49+
critical_gaps: int
50+
51+
52+
@router.get("/completeness/summary", response_model=CompletenessSummaryResponse)
53+
async def get_completeness_summary():
54+
"""Aggregate completeness statistics across all products."""
55+
items = await completeness_repo.query(query="SELECT * FROM c")
56+
if not items:
57+
return CompletenessSummaryResponse(
58+
total_products=0,
59+
average_score=0.0,
60+
fully_complete=0,
61+
needs_enrichment=0,
62+
critical_gaps=0,
63+
)
64+
total = len(items)
65+
scores = [i.get("score", 0.0) for i in items]
66+
avg_score = sum(scores) / total
67+
fully_complete = sum(1 for s in scores if s >= 1.0)
68+
needs_enrichment = sum(1 for s in scores if 0.70 <= s < 1.0)
69+
critical_gaps = sum(1 for s in scores if s < 0.70)
70+
return CompletenessSummaryResponse(
71+
total_products=total,
72+
average_score=avg_score,
73+
fully_complete=fully_complete,
74+
needs_enrichment=needs_enrichment,
75+
critical_gaps=critical_gaps,
76+
)
77+
78+
79+
@router.get("/completeness/{entity_id}", response_model=CompletenessReportResponse)
80+
async def get_completeness_report(
81+
entity_id: str,
82+
limit: int = Query(1, le=10, description="Number of recent reports to return"),
83+
):
84+
"""Get the latest completeness report for a product."""
85+
items = await completeness_repo.query(
86+
query="SELECT * FROM c WHERE c.entity_id = @entity_id",
87+
parameters=[{"name": "@entity_id", "value": entity_id}],
88+
partition_key=entity_id,
89+
)
90+
if not items:
91+
raise HTTPException(
92+
status_code=status.HTTP_404_NOT_FOUND,
93+
detail=f"No completeness report found for entity '{entity_id}'",
94+
)
95+
# Return most recent (last inserted)
96+
return CompletenessReportResponse(**items[-1])
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Proposed attributes routes — AI-proposed (pending review) product attributes."""
2+
3+
from crud_service.repositories.base import BaseRepository
4+
from fastapi import APIRouter, HTTPException, Query, status
5+
from pydantic import BaseModel
6+
7+
router = APIRouter()
8+
9+
10+
class ProposedAttributeRepository(BaseRepository):
11+
"""Repository for proposed attributes."""
12+
13+
def __init__(self):
14+
super().__init__(container_name="proposed_attributes")
15+
16+
17+
proposed_attr_repo = ProposedAttributeRepository()
18+
19+
20+
class ProposedAttributeResponse(BaseModel):
21+
"""Response model for a proposed attribute."""
22+
23+
id: str
24+
entity_id: str
25+
field_name: str
26+
proposed_value: object
27+
status: str = "pending"
28+
confidence: float | None = None
29+
source_model: str | None = None
30+
evidence: list[str] | None = None
31+
proposed_at: str | None = None
32+
reviewed_at: str | None = None
33+
reviewed_by: str | None = None
34+
rejection_reason: str | None = None
35+
36+
37+
@router.get(
38+
"/proposed/attributes/{entity_id}",
39+
response_model=list[ProposedAttributeResponse],
40+
)
41+
async def get_proposed_attributes(
42+
entity_id: str,
43+
status_filter: str | None = Query(
44+
None, alias="status", description="Filter by status (pending/approved/rejected)"
45+
),
46+
limit: int = Query(50, le=200, description="Maximum number of results"),
47+
):
48+
"""Get all proposed attributes for a product, with optional status filter."""
49+
items = await proposed_attr_repo.query(
50+
query="SELECT * FROM c WHERE c.entity_id = @entity_id",
51+
parameters=[{"name": "@entity_id", "value": entity_id}],
52+
partition_key=entity_id,
53+
)
54+
if status_filter:
55+
items = [i for i in items if i.get("status") == status_filter]
56+
return [ProposedAttributeResponse(**item) for item in items[:limit]]
57+
58+
59+
@router.get(
60+
"/proposed/attributes/{entity_id}/{attribute_id}",
61+
response_model=ProposedAttributeResponse,
62+
)
63+
async def get_proposed_attribute(entity_id: str, attribute_id: str):
64+
"""Get a single proposed attribute by ID."""
65+
item = await proposed_attr_repo.get_by_id(attribute_id, partition_key=entity_id)
66+
if not item:
67+
raise HTTPException(
68+
status_code=status.HTTP_404_NOT_FOUND,
69+
detail=f"Proposed attribute '{attribute_id}' not found for entity '{entity_id}'",
70+
)
71+
return ProposedAttributeResponse(**item)

0 commit comments

Comments
 (0)