You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

177 lines
5.7 KiB
Python

"""Review queue: CRUD operations for ReviewItem."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from libs.common.logging import get_logger
from libs.common.time_utils import utc_now
logger = get_logger(__name__)
_PRIORITY_ORDER = {"P0": 0, "P1": 1, "P2": 2}
async def create_review_item(
session: AsyncSession,
entity_type: str,
entity_id: str,
priority: str,
reason_codes: list[str],
snapshot_refs: dict[str, Any],
suggested_overrides: dict[str, Any] | None = None,
) -> Any:
"""Create a ReviewItem, or update an existing open one (deduplicated).
Args:
session: Async DB session.
entity_type: "parser_event" | "feature_record" | "label_record".
entity_id: ID of the entity to review.
priority: "P0" | "P1" | "P2".
reason_codes: List of reason code strings.
snapshot_refs: Dict of snapshot references.
suggested_overrides: Optional suggested field overrides.
Returns:
ReviewItem ORM instance.
"""
from libs.db.models import ReviewItem
# Check for existing open item
result = await session.execute(
select(ReviewItem).where(
ReviewItem.entity_type == entity_type,
ReviewItem.entity_id == entity_id,
ReviewItem.status == "open",
)
)
existing = result.scalar_one_or_none()
if existing is not None:
# Update existing: escalate priority if needed, merge reason_codes
existing_priority_rank = _PRIORITY_ORDER.get(existing.priority, 99)
new_priority_rank = _PRIORITY_ORDER.get(priority, 99)
if new_priority_rank < existing_priority_rank:
existing.priority = priority # type: ignore[assignment]
existing_codes: list[str] = list(existing.reason_codes or [])
for code in reason_codes:
if code not in existing_codes:
existing_codes.append(code)
existing.reason_codes = existing_codes # type: ignore[assignment]
if suggested_overrides:
existing.suggested_overrides = suggested_overrides # type: ignore[assignment]
existing.updated_at_utc = utc_now() # type: ignore[assignment]
await session.flush()
logger.info("review_item_updated", entity_id=entity_id, entity_type=entity_type)
return existing
item = ReviewItem(
review_id=uuid.uuid4(),
entity_type=entity_type,
entity_id=entity_id,
priority=priority,
reason_codes=reason_codes,
status="open",
snapshot_refs=snapshot_refs,
suggested_overrides=suggested_overrides,
created_at_utc=utc_now(),
updated_at_utc=utc_now(),
)
session.add(item)
await session.flush()
logger.info("review_item_created", entity_id=entity_id, priority=priority, reasons=reason_codes)
return item
async def resolve_review_item(
session: AsyncSession,
review_id: str | uuid.UUID,
reviewer_id: str,
resolution_type: str,
root_cause: str,
notes: str,
field_overrides: dict[str, Any] | None = None,
) -> Any:
"""Resolve a ReviewItem.
Args:
session: Async DB session.
review_id: UUID of the ReviewItem to resolve.
reviewer_id: ID/name of the reviewer.
resolution_type: E.g. "accepted", "corrected", "wont_fix".
root_cause: Short description of root cause.
notes: Free-text reviewer notes.
field_overrides: Optional field overrides applied.
Returns:
Resolved ReviewItem.
Raises:
ValueError: If item not found or already resolved.
"""
from libs.db.models import ReviewItem
if isinstance(review_id, str):
review_id = uuid.UUID(review_id)
result = await session.execute(
select(ReviewItem).where(ReviewItem.review_id == review_id)
)
item = result.scalar_one_or_none()
if item is None:
raise ValueError(f"ReviewItem {review_id} not found")
if item.status != "open": # type: ignore[comparison-overlap]
raise ValueError(f"ReviewItem {review_id} is already {item.status}")
item.status = "resolved" # type: ignore[assignment]
item.reviewer_id = reviewer_id # type: ignore[assignment]
item.resolution_type = resolution_type # type: ignore[assignment]
item.root_cause = root_cause # type: ignore[assignment]
item.notes = notes # type: ignore[assignment]
item.field_overrides = field_overrides # type: ignore[assignment]
item.resolved_at = utc_now() # type: ignore[assignment]
item.updated_at_utc = utc_now() # type: ignore[assignment]
await session.flush()
logger.info("review_item_resolved", review_id=str(review_id), resolution_type=resolution_type)
return item
async def list_review_items(
session: AsyncSession,
status: str | None = None,
priority: str | None = None,
entity_type: str | None = None,
) -> list[Any]:
"""List ReviewItems with optional filters.
Args:
session: Async DB session.
status: Filter by status ("open", "resolved", etc.).
priority: Filter by priority ("P0", "P1", "P2").
entity_type: Filter by entity_type.
Returns:
List of ReviewItem ORM instances.
"""
from libs.db.models import ReviewItem
stmt = select(ReviewItem)
if status is not None:
stmt = stmt.where(ReviewItem.status == status)
if priority is not None:
stmt = stmt.where(ReviewItem.priority == priority)
if entity_type is not None:
stmt = stmt.where(ReviewItem.entity_type == entity_type)
stmt = stmt.order_by(ReviewItem.created_at_utc.desc())
result = await session.execute(stmt)
return list(result.scalars().all())