This commit is contained in:
2026-04-24 17:10:02 +03:00
parent b7a9ef71b2
commit 0b227a307c
34 changed files with 5220 additions and 5446 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,900 @@
# [DEF:DatasetReviewDependencies:Module]
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @LAYER: API
# @RATIONALE: Extracted from 2484-line monolith to satisfy INV_7 (400-line module limit).
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Union, cast
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from src.core.database import get_db
from src.core.logger import belief_scope, logger
from src.dependencies import (
get_config_manager,
get_current_user,
get_task_manager,
has_permission,
)
from src.models.auth import User
from src.models.dataset_review import (
AnswerKind,
ApprovalState,
ArtifactFormat,
CandidateStatus,
ClarificationSession,
DatasetReviewSession,
ExecutionMapping,
FieldProvenance,
MappingMethod,
PreviewStatus,
QuestionState,
ReadinessState,
RecommendedAction,
SemanticCandidate,
SemanticFieldEntry,
SessionStatus,
)
from src.schemas.dataset_review import (
ClarificationAnswerDto,
ClarificationQuestionDto,
ClarificationSessionDto,
CompiledPreviewDto,
DatasetRunContextDto,
ExecutionMappingDto,
SemanticFieldEntryDto,
SessionDetail,
SessionSummary,
ValidationFindingDto,
)
from src.services.dataset_review.clarification_engine import (
ClarificationAnswerCommand,
ClarificationEngine,
ClarificationQuestionPayload,
ClarificationStateResult,
)
from src.services.dataset_review.orchestrator import (
DatasetReviewOrchestrator,
LaunchDatasetCommand,
PreparePreviewCommand,
StartSessionCommand,
)
from src.services.dataset_review.repositories.session_repository import (
DatasetReviewSessionRepository,
DatasetReviewSessionVersionConflictError,
)
# [DEF:StartSessionRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Request DTO for starting one dataset review session.
class StartSessionRequest(BaseModel):
source_kind: str = Field(..., pattern="^(superset_link|dataset_selection)$")
source_input: str = Field(..., min_length=1)
environment_id: str = Field(..., min_length=1)
# [/DEF:StartSessionRequest:Class]
# [DEF:UpdateSessionRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Request DTO for lifecycle state updates on an existing session.
class UpdateSessionRequest(BaseModel):
status: SessionStatus
note: Optional[str] = None
# [/DEF:UpdateSessionRequest:Class]
# [DEF:SessionCollectionResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Paginated session collection response.
class SessionCollectionResponse(BaseModel):
items: List[SessionSummary]
total: int
page: int
page_size: int
has_next: bool
# [/DEF:SessionCollectionResponse:Class]
# [DEF:ExportArtifactResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Inline export response for documentation or validation outputs.
class ExportArtifactResponse(BaseModel):
artifact_id: str
session_id: str
artifact_type: str
format: str
storage_ref: str
created_by_user_id: str
created_at: Optional[str] = None
content: Dict[str, Any]
# [/DEF:ExportArtifactResponse:Class]
# [DEF:FieldSemanticUpdateRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Request DTO for field-level semantic candidate acceptance or manual override.
class FieldSemanticUpdateRequest(BaseModel):
candidate_id: Optional[str] = None
verbose_name: Optional[str] = None
description: Optional[str] = None
display_format: Optional[str] = None
lock_field: bool = False
resolution_note: Optional[str] = None
# [/DEF:FieldSemanticUpdateRequest:Class]
# [DEF:FeedbackRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Request DTO for thumbs up/down feedback.
class FeedbackRequest(BaseModel):
feedback: str = Field(..., pattern="^(up|down)$")
# [/DEF:FeedbackRequest:Class]
# [DEF:ClarificationAnswerRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Request DTO for submitting one clarification answer.
class ClarificationAnswerRequest(BaseModel):
question_id: str = Field(..., min_length=1)
answer_kind: AnswerKind
answer_value: Optional[str] = None
# [/DEF:ClarificationAnswerRequest:Class]
# [DEF:ClarificationSessionSummaryResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Summary DTO for current clarification session state.
class ClarificationSessionSummaryResponse(BaseModel):
clarification_session_id: str
session_id: str
status: str
current_question_id: Optional[str] = None
resolved_count: int
remaining_count: int
summary_delta: Optional[str] = None
# [/DEF:ClarificationSessionSummaryResponse:Class]
# [DEF:ClarificationStateResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Response DTO for current clarification state and active question payload.
class ClarificationStateResponse(BaseModel):
clarification_session: Optional[ClarificationSessionSummaryResponse] = None
current_question: Optional[ClarificationQuestionDto] = None
# [/DEF:ClarificationStateResponse:Class]
# [DEF:ClarificationAnswerResultResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Response DTO for one clarification answer mutation result.
class ClarificationAnswerResultResponse(BaseModel):
clarification_state: ClarificationStateResponse
session: SessionSummary
changed_findings: List[ValidationFindingDto]
# [/DEF:ClarificationAnswerResultResponse:Class]
# [DEF:FeedbackResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Minimal response DTO for persisted AI feedback actions.
class FeedbackResponse(BaseModel):
target_id: str
feedback: str
# [/DEF:FeedbackResponse:Class]
# [DEF:ApproveMappingRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Optional request DTO for explicit mapping approval audit notes.
class ApproveMappingRequest(BaseModel):
approval_note: Optional[str] = None
# [/DEF:ApproveMappingRequest:Class]
# [DEF:BatchApproveSemanticItemRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Request DTO for one batch semantic-approval item.
class BatchApproveSemanticItemRequest(BaseModel):
field_id: str = Field(..., min_length=1)
candidate_id: str = Field(..., min_length=1)
lock_field: bool = False
# [/DEF:BatchApproveSemanticItemRequest:Class]
# [DEF:BatchApproveSemanticRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Request DTO for explicit batch semantic approvals.
class BatchApproveSemanticRequest(BaseModel):
items: List[BatchApproveSemanticItemRequest] = Field(..., min_length=1)
# [/DEF:BatchApproveSemanticRequest:Class]
# [DEF:BatchApproveMappingRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Request DTO for explicit batch mapping approvals.
class BatchApproveMappingRequest(BaseModel):
mapping_ids: List[str] = Field(..., min_length=1)
approval_note: Optional[str] = None
# [/DEF:BatchApproveMappingRequest:Class]
# [DEF:PreviewEnqueueResultResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Async preview trigger response exposing only enqueue state.
class PreviewEnqueueResultResponse(BaseModel):
session_id: str
session_version: Optional[int] = None
preview_status: str
task_id: Optional[str] = None
# [/DEF:PreviewEnqueueResultResponse:Class]
# [DEF:MappingCollectionResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Wrapper for execution mapping list responses.
class MappingCollectionResponse(BaseModel):
items: List[ExecutionMappingDto]
# [/DEF:MappingCollectionResponse:Class]
# [DEF:UpdateExecutionMappingRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Request DTO for one manual execution-mapping override update.
class UpdateExecutionMappingRequest(BaseModel):
effective_value: Optional[Any] = None
mapping_method: Optional[str] = Field(default=None, pattern="^(manual_override|direct_match|heuristic_match|semantic_match)$")
transformation_note: Optional[str] = None
# [/DEF:UpdateExecutionMappingRequest:Class]
# [DEF:LaunchDatasetResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Launch result exposing audited run context and SQL Lab redirect target.
class LaunchDatasetResponse(BaseModel):
run_context: DatasetRunContextDto
redirect_url: str
# [/DEF:LaunchDatasetResponse:Class]
# --- Dependency Injection ---
# [DEF:_require_auto_review_flag:Function]
# @COMPLEXITY: 2
# @PURPOSE: Guard US1 dataset review endpoints behind the configured feature flag.
def _require_auto_review_flag(config_manager=Depends(get_config_manager)) -> bool:
with belief_scope("dataset_review.require_auto_review_flag"):
if not config_manager.get_config().settings.ff_dataset_auto_review:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dataset auto review feature is disabled")
return True
# [/DEF:_require_auto_review_flag:Function]
# [DEF:_require_clarification_flag:Function]
# @COMPLEXITY: 2
# @PURPOSE: Guard clarification-specific US2 endpoints behind the configured feature flag.
def _require_clarification_flag(config_manager=Depends(get_config_manager)) -> bool:
with belief_scope("dataset_review.require_clarification_flag"):
if not config_manager.get_config().settings.ff_dataset_clarification:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dataset clarification feature is disabled")
return True
# [/DEF:_require_clarification_flag:Function]
# [DEF:_require_execution_flag:Function]
# @COMPLEXITY: 2
# @PURPOSE: Guard US3 execution endpoints behind the configured feature flag.
def _require_execution_flag(config_manager=Depends(get_config_manager)) -> bool:
with belief_scope("dataset_review.require_execution_flag"):
if not config_manager.get_config().settings.ff_dataset_execution:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dataset execution feature is disabled")
return True
# [/DEF:_require_execution_flag:Function]
# [DEF:_get_repository:Function]
# @COMPLEXITY: 1
# @PURPOSE: Build repository dependency.
def _get_repository(db: Session = Depends(get_db)) -> DatasetReviewSessionRepository:
return DatasetReviewSessionRepository(db)
# [/DEF:_get_repository:Function]
# [DEF:_get_orchestrator:Function]
# @COMPLEXITY: 1
# @PURPOSE: Build orchestrator dependency.
def _get_orchestrator(
repository: DatasetReviewSessionRepository = Depends(_get_repository),
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
) -> DatasetReviewOrchestrator:
return DatasetReviewOrchestrator(repository=repository, config_manager=config_manager, task_manager=task_manager)
# [/DEF:_get_orchestrator:Function]
# [DEF:_get_clarification_engine:Function]
# @COMPLEXITY: 1
# @PURPOSE: Build clarification engine dependency.
def _get_clarification_engine(
repository: DatasetReviewSessionRepository = Depends(_get_repository),
) -> ClarificationEngine:
return ClarificationEngine(repository=repository)
# [/DEF:_get_clarification_engine:Function]
# --- Serialization Helpers ---
# [DEF:_serialize_session_summary:Function]
# @COMPLEXITY: 1
# @PURPOSE: Map session aggregate into stable API summary DTO.
def _serialize_session_summary(session: DatasetReviewSession) -> SessionSummary:
summary = SessionSummary.model_validate(session, from_attributes=True)
summary.session_version = summary.version
return summary
# [/DEF:_serialize_session_summary:Function]
# [DEF:_serialize_session_detail:Function]
# @COMPLEXITY: 1
# @PURPOSE: Map session aggregate into stable API detail DTO.
def _serialize_session_detail(session: DatasetReviewSession) -> SessionDetail:
detail = SessionDetail.model_validate(session, from_attributes=True)
detail.session_version = detail.version
return detail
# [/DEF:_serialize_session_detail:Function]
# [DEF:_require_session_version_header:Function]
# @COMPLEXITY: 1
# @PURPOSE: Read the optimistic-lock session version header.
def _require_session_version_header(
session_version: int = Header(..., alias="X-Session-Version", ge=0),
) -> int:
return session_version
# [/DEF:_require_session_version_header:Function]
# [DEF:_build_session_version_conflict_http_exception:Function]
# @COMPLEXITY: 1
# @PURPOSE: Normalize optimistic-lock conflict errors into HTTP 409 responses.
def _build_session_version_conflict_http_exception(exc: DatasetReviewSessionVersionConflictError) -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"error_code": "session_version_conflict", "message": str(exc), "session_id": exc.session_id, "expected_version": exc.expected_version, "actual_version": exc.actual_version},
)
# [/DEF:_build_session_version_conflict_http_exception:Function]
# [DEF:_enforce_session_version:Function]
# @COMPLEXITY: 3
# @PURPOSE: Convert repository optimistic-lock conflicts into deterministic HTTP 409 responses.
def _enforce_session_version(repository, session, expected_version):
with belief_scope("_enforce_session_version"):
try:
repository.require_session_version(session, expected_version)
except DatasetReviewSessionVersionConflictError as exc:
logger.explore("Dataset review optimistic-lock conflict detected", extra={"session_id": exc.session_id, "expected_version": exc.expected_version, "actual_version": exc.actual_version})
raise _build_session_version_conflict_http_exception(exc) from exc
return session
# [/DEF:_enforce_session_version:Function]
# [DEF:_get_owned_session_or_404:Function]
# @COMPLEXITY: 3
# @PURPOSE: Resolve one session for current user or collaborator scope, returning 404 when inaccessible.
def _get_owned_session_or_404(repository, session_id, current_user):
with belief_scope("_get_owned_session_or_404"):
session = repository.load_session_detail(session_id, current_user.id)
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
return session
# [/DEF:_get_owned_session_or_404:Function]
# [DEF:_require_owner_mutation_scope:Function]
# @COMPLEXITY: 2
# @PURPOSE: Enforce owner-only mutation scope.
def _require_owner_mutation_scope(session, current_user):
with belief_scope("_require_owner_mutation_scope"):
if session.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can mutate dataset review state")
return session
# [/DEF:_require_owner_mutation_scope:Function]
# [DEF:_prepare_owned_session_mutation:Function]
# @COMPLEXITY: 3
# @PURPOSE: Resolve owner-scoped mutation session and enforce optimistic-lock version.
def _prepare_owned_session_mutation(repository, session_id, current_user, expected_version):
with belief_scope("_prepare_owned_session_mutation"):
session = _get_owned_session_or_404(repository, session_id, current_user)
_require_owner_mutation_scope(session, current_user)
return _enforce_session_version(repository, session, expected_version)
# [/DEF:_prepare_owned_session_mutation:Function]
# [DEF:_commit_owned_session_mutation:Function]
# @COMPLEXITY: 3
# @PURPOSE: Centralize session version bumping and commit semantics.
def _commit_owned_session_mutation(repository, session, *, refresh_targets=None):
with belief_scope("_commit_owned_session_mutation"):
try:
repository.commit_session_mutation(session, refresh_targets=refresh_targets)
except DatasetReviewSessionVersionConflictError as exc:
raise _build_session_version_conflict_http_exception(exc) from exc
return session
# [/DEF:_commit_owned_session_mutation:Function]
# [DEF:_record_session_event:Function]
# @COMPLEXITY: 1
# @PURPOSE: Persist one explicit audit event for an owned mutation endpoint.
def _record_session_event(repository, session, current_user, *, event_type, event_summary, event_details=None):
repository.event_logger.log_for_session(session, actor_user_id=current_user.id, event_type=event_type, event_summary=event_summary, event_details=event_details or {})
# [/DEF:_record_session_event:Function]
# [DEF:_serialize_semantic_field:Function]
# @COMPLEXITY: 1
# @PURPOSE: Map one semantic field into stable DTO.
def _serialize_semantic_field(field):
payload = SemanticFieldEntryDto.model_validate(field, from_attributes=True)
session_ref = getattr(field, "session", None)
version_value = getattr(session_ref, "version", None)
payload.session_version = int(version_value or 0) if version_value is not None else None
return payload
# [/DEF:_serialize_semantic_field:Function]
# [DEF:_serialize_execution_mapping:Function]
# @COMPLEXITY: 1
# @PURPOSE: Map one execution mapping into stable DTO.
def _serialize_execution_mapping(mapping):
payload = ExecutionMappingDto.model_validate(mapping, from_attributes=True)
session_ref = getattr(mapping, "session", None)
version_value = getattr(session_ref, "version", None)
payload.session_version = int(version_value or 0) if version_value is not None else None
return payload
# [/DEF:_serialize_execution_mapping:Function]
# [DEF:_serialize_preview:Function]
# @COMPLEXITY: 1
# @PURPOSE: Map one preview into stable DTO.
def _serialize_preview(preview, *, session_version_fallback=None):
payload = CompiledPreviewDto.model_validate(preview, from_attributes=True)
session_ref = getattr(preview, "session", None)
version_value = getattr(session_ref, "version", None)
if version_value is None:
version_value = session_version_fallback
payload.session_version = int(version_value or 0) if version_value is not None else None
return payload
# [/DEF:_serialize_preview:Function]
# [DEF:_serialize_run_context:Function]
# @COMPLEXITY: 1
# @PURPOSE: Map one run context into stable DTO.
def _serialize_run_context(run_context):
payload = DatasetRunContextDto.model_validate(run_context, from_attributes=True)
session_ref = getattr(run_context, "session", None)
version_value = getattr(session_ref, "version", None)
payload.session_version = int(version_value or 0) if version_value is not None else None
return payload
# [/DEF:_serialize_run_context:Function]
# [DEF:_serialize_clarification_question_payload:Function]
# @COMPLEXITY: 1
# @PURPOSE: Convert clarification engine payload into API DTO.
def _serialize_clarification_question_payload(payload):
if payload is None:
return None
return ClarificationQuestionDto.model_validate({
"question_id": payload.question_id, "clarification_session_id": payload.clarification_session_id,
"topic_ref": payload.topic_ref, "question_text": payload.question_text,
"why_it_matters": payload.why_it_matters, "current_guess": payload.current_guess,
"priority": payload.priority, "state": payload.state, "options": payload.options,
"answer": None, "created_at": datetime.utcnow(), "updated_at": datetime.utcnow(),
})
# [/DEF:_serialize_clarification_question_payload:Function]
# [DEF:_serialize_clarification_state:Function]
# @COMPLEXITY: 1
# @PURPOSE: Convert clarification engine state into API response.
def _serialize_clarification_state(state):
return ClarificationStateResponse(
clarification_session=ClarificationSessionSummaryResponse(
clarification_session_id=state.clarification_session.clarification_session_id,
session_id=state.clarification_session.session_id, status=state.clarification_session.status.value,
current_question_id=state.clarification_session.current_question_id,
resolved_count=state.clarification_session.resolved_count,
remaining_count=state.clarification_session.remaining_count,
summary_delta=state.clarification_session.summary_delta,
),
current_question=_serialize_clarification_question_payload(state.current_question),
)
# [/DEF:_serialize_clarification_state:Function]
# [DEF:_serialize_empty_clarification_state:Function]
# @COMPLEXITY: 1
# @PURPOSE: Return empty clarification payload.
def _serialize_empty_clarification_state():
return ClarificationStateResponse(clarification_session=None, current_question=None)
# [/DEF:_serialize_empty_clarification_state:Function]
# [DEF:_get_latest_clarification_session_or_404:Function]
# @COMPLEXITY: 1
# @PURPOSE: Resolve the latest clarification aggregate or raise.
def _get_latest_clarification_session_or_404(session):
if not session.clarification_sessions:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Clarification session not found")
return sorted(session.clarification_sessions, key=lambda item: (item.started_at, item.clarification_session_id), reverse=True)[0]
# [/DEF:_get_latest_clarification_session_or_404:Function]
# [DEF:_get_owned_mapping_or_404:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve one execution mapping inside one owned session.
def _get_owned_mapping_or_404(session, mapping_id):
for mapping in session.execution_mappings:
if mapping.mapping_id == mapping_id:
return mapping
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Execution mapping not found")
# [/DEF:_get_owned_mapping_or_404:Function]
# [DEF:_get_owned_field_or_404:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve a semantic field inside one owned session.
def _get_owned_field_or_404(session, field_id):
for field in session.semantic_fields:
if field.field_id == field_id:
return field
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Semantic field not found")
# [/DEF:_get_owned_field_or_404:Function]
# [DEF:_map_candidate_provenance:Function]
# @COMPLEXITY: 1
# @PURPOSE: Translate accepted semantic candidate type into stable field provenance.
def _map_candidate_provenance(candidate):
if str(candidate.match_type.value) == "exact":
return FieldProvenance.DICTIONARY_EXACT
if str(candidate.match_type.value) == "reference":
return FieldProvenance.REFERENCE_IMPORTED
if str(candidate.match_type.value) == "generated":
return FieldProvenance.AI_GENERATED
return FieldProvenance.FUZZY_INFERRED
# [/DEF:_map_candidate_provenance:Function]
# [DEF:_resolve_candidate_source_version:Function]
# @COMPLEXITY: 1
# @PURPOSE: Resolve the semantic source version for one accepted candidate.
def _resolve_candidate_source_version(field, source_id):
if not source_id:
return None
session = getattr(field, "session", None)
if session is None:
return None
for source in getattr(session, "semantic_sources", []) or []:
if source.source_id == source_id:
return source.source_version
return None
# [/DEF:_resolve_candidate_source_version:Function]
# [DEF:_update_semantic_field_state:Function]
# @COMPLEXITY: 3
# @PURPOSE: Apply field-level semantic manual override or candidate acceptance.
# @POST: Manual overrides always set manual provenance plus lock.
def _update_semantic_field_state(field, request, changed_by):
has_manual_override = any(v is not None for v in [request.verbose_name, request.description, request.display_format])
selected_candidate = None
if request.candidate_id:
selected_candidate = next((c for c in field.candidates if c.candidate_id == request.candidate_id), None)
if selected_candidate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Semantic candidate not found")
if has_manual_override:
field.verbose_name = request.verbose_name
field.description = request.description
field.display_format = request.display_format
field.provenance = FieldProvenance.MANUAL_OVERRIDE
field.source_id = None
field.source_version = None
field.confidence_rank = None
field.is_locked = True
field.has_conflict = False
field.needs_review = False
field.last_changed_by = changed_by
for c in field.candidates:
c.status = CandidateStatus.SUPERSEDED
return field
if selected_candidate is not None:
field.verbose_name = selected_candidate.proposed_verbose_name
field.description = selected_candidate.proposed_description
field.display_format = selected_candidate.proposed_display_format
field.provenance = _map_candidate_provenance(selected_candidate)
field.source_id = selected_candidate.source_id
field.source_version = _resolve_candidate_source_version(field, selected_candidate.source_id)
field.confidence_rank = selected_candidate.candidate_rank
field.is_locked = bool(request.lock_field or field.is_locked)
field.has_conflict = len(field.candidates) > 1
field.needs_review = False
field.last_changed_by = changed_by
for c in field.candidates:
c.status = CandidateStatus.ACCEPTED if c.candidate_id == selected_candidate.candidate_id else CandidateStatus.SUPERSEDED
return field
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provide candidate_id or at least one manual override field")
# [/DEF:_update_semantic_field_state:Function]
# [DEF:_build_sql_lab_redirect_url:Function]
# @COMPLEXITY: 1
# @PURPOSE: Build SQL Lab redirect URL.
def _build_sql_lab_redirect_url(environment_url, sql_lab_session_ref):
base_url = str(environment_url or "").rstrip("/")
session_ref = str(sql_lab_session_ref or "").strip()
if not base_url:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Superset environment URL is not configured")
if not session_ref:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="SQL Lab session reference is missing")
return f"{base_url}/superset/sqllab?queryId={session_ref}"
# [/DEF:_build_sql_lab_redirect_url:Function]
# [DEF:_build_documentation_export:Function]
# @COMPLEXITY: 2
# @PURPOSE: Produce session documentation export content.
def _build_documentation_export(session, export_format):
profile = session.profile
findings = sorted(session.findings, key=lambda item: (item.severity.value, item.code))
if export_format == ArtifactFormat.MARKDOWN:
lines = [f"# Dataset Review: {session.dataset_ref}", "", f"- Session ID: {session.session_id}", f"- Environment: {session.environment_id}", f"- Readiness: {session.readiness_state.value}", f"- Recommended action: {session.recommended_action.value}", "", "## Business Summary", profile.business_summary if profile else "No profile summary available.", "", "## Findings"]
if findings:
for f in findings:
lines.append(f"- [{f.severity.value}] {f.title}: {f.message}")
else:
lines.append("- No findings recorded.")
return {"storage_ref": f"inline://dataset-review/{session.session_id}/documentation.md", "content": {"markdown": "\n".join(lines)}}
content = {"session": _serialize_session_summary(session).model_dump(mode="json"), "profile": profile and {"dataset_name": profile.dataset_name, "business_summary": profile.business_summary, "confidence_state": profile.confidence_state.value, "dataset_type": profile.dataset_type}, "findings": [{"code": f.code, "severity": f.severity.value, "title": f.title, "message": f.message, "resolution_state": f.resolution_state.value} for f in findings]}
return {"storage_ref": f"inline://dataset-review/{session.session_id}/documentation.json", "content": content}
# [/DEF:_build_documentation_export:Function]
# [DEF:_build_validation_export:Function]
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content.
def _build_validation_export(session, export_format):
findings = sorted(session.findings, key=lambda item: (item.severity.value, item.code))
if export_format == ArtifactFormat.MARKDOWN:
lines = [f"# Validation Report: {session.dataset_ref}", "", f"- Session ID: {session.session_id}", f"- Readiness: {session.readiness_state.value}", "", "## Findings"]
if findings:
for f in findings:
lines.append(f"- `{f.code}` [{f.severity.value}] {f.message}")
else:
lines.append("- No findings recorded.")
return {"storage_ref": f"inline://dataset-review/{session.session_id}/validation.md", "content": {"markdown": "\n".join(lines)}}
content = {"session_id": session.session_id, "dataset_ref": session.dataset_ref, "readiness_state": session.readiness_state.value, "findings": [{"finding_id": f.finding_id, "area": f.area.value, "severity": f.severity.value, "code": f.code, "title": f.title, "message": f.message, "resolution_state": f.resolution_state.value} for f in findings]}
return {"storage_ref": f"inline://dataset-review/{session.session_id}/validation.json", "content": content}
# [/DEF:_build_validation_export:Function]
# [/DEF:DatasetReviewDependencies:Module]

View File

@@ -0,0 +1,923 @@
# [DEF:DatasetReviewRoutes:Module]
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @LAYER: API
# @RATIONALE: Extracted from 2484-line monolith to satisfy INV_7 (400-line module limit).
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
from __future__ import annotations
from datetime import datetime
from typing import Any, List, Optional, Union, cast
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from src.core.logger import belief_scope, logger
from src.dependencies import get_config_manager, get_current_user, has_permission
from src.models.auth import User
from src.models.dataset_review import (
ApprovalState,
ArtifactFormat,
FieldProvenance,
MappingMethod,
PreviewStatus,
ReadinessState,
RecommendedAction,
SessionStatus,
)
from src.schemas.dataset_review import (
ClarificationAnswerDto,
ClarificationStateResponse as _ClarificationStateResponseSchema,
CompiledPreviewDto,
ExecutionMappingDto,
SemanticFieldEntryDto,
SessionSummary,
ValidationFindingDto,
)
from src.services.dataset_review.clarification_engine import (
ClarificationAnswerCommand,
ClarificationStateResult,
)
from src.services.dataset_review.orchestrator import (
LaunchDatasetCommand,
PreparePreviewCommand,
StartSessionCommand,
)
from src.services.dataset_review.repositories.session_repository import (
DatasetReviewSessionVersionConflictError,
)
from src.api.routes.dataset_review_pkg._dependencies import (
BatchApproveMappingRequest,
BatchApproveSemanticRequest,
ClarificationAnswerRequest,
ClarificationAnswerResultResponse,
ClarificationStateResponse,
ExportArtifactResponse,
FeedbackRequest,
FeedbackResponse,
FieldSemanticUpdateRequest,
LaunchDatasetResponse,
MappingCollectionResponse,
PreviewEnqueueResultResponse,
SessionCollectionResponse,
StartSessionRequest,
UpdateExecutionMappingRequest,
UpdateSessionRequest,
_build_documentation_export,
_build_sql_lab_redirect_url,
_build_validation_export,
_commit_owned_session_mutation,
_get_clarification_engine,
_get_latest_clarification_session_or_404,
_get_owned_field_or_404,
_get_owned_mapping_or_404,
_get_owned_session_or_404,
_get_orchestrator,
_get_repository,
_prepare_owned_session_mutation,
_record_session_event,
_require_auto_review_flag,
_require_clarification_flag,
_require_execution_flag,
_require_session_version_header,
_serialize_clarification_state,
_serialize_empty_clarification_state,
_serialize_execution_mapping,
_serialize_preview,
_serialize_run_context,
_serialize_semantic_field,
_serialize_session_detail,
_serialize_session_summary,
_update_semantic_field_state,
_build_session_version_conflict_http_exception,
)
router = APIRouter(prefix="/api/dataset-orchestration", tags=["Dataset Orchestration"])
# [DEF:list_sessions:Function]
# @COMPLEXITY: 3
# @PURPOSE: List resumable dataset review sessions for the current user.
@router.get(
"/sessions",
response_model=SessionCollectionResponse,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "READ")),
],
)
async def list_sessions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.list_sessions"):
logger.reason(
"Listing dataset review sessions",
extra={"user_id": current_user.id, "page": page, "page_size": page_size},
)
sessions = repository.list_sessions_for_user(current_user.id)
start = (page - 1) * page_size
end = start + page_size
items = [_serialize_session_summary(s) for s in sessions[start:end]]
logger.reflect(
"Session page assembled",
extra={"user_id": current_user.id, "returned": len(items), "total": len(sessions)},
)
return SessionCollectionResponse(
items=items, total=len(sessions), page=page,
page_size=page_size, has_next=end < len(sessions),
)
# [/DEF:list_sessions:Function]
# [DEF:start_session:Function]
# @COMPLEXITY: 4
# @PURPOSE: Start a new dataset review session from a Superset link or dataset selection.
@router.post(
"/sessions",
response_model=SessionSummary,
status_code=status.HTTP_201_CREATED,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def start_session(
request: StartSessionRequest,
orchestrator=Depends(_get_orchestrator),
current_user: User = Depends(get_current_user),
):
with belief_scope("start_session"):
logger.reason(
"Starting dataset review session",
extra={"user_id": current_user.id, "environment_id": request.environment_id},
)
try:
result = orchestrator.start_session(
StartSessionCommand(
user=current_user, environment_id=request.environment_id,
source_kind=request.source_kind, source_input=request.source_input,
)
)
except ValueError as exc:
logger.explore("Session start rejected", extra={"user_id": current_user.id, "error": str(exc)})
detail = str(exc)
sc = status.HTTP_404_NOT_FOUND if detail == "Environment not found" else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=sc, detail=detail) from exc
logger.reflect("Session started", extra={"session_id": result.session.session_id})
return _serialize_session_summary(result.session)
# [/DEF:start_session:Function]
# [DEF:get_session_detail:Function]
# @COMPLEXITY: 3
# @PURPOSE: Return the full accessible dataset review session aggregate.
@router.get(
"/sessions/{session_id}",
response_model=SessionSummary,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "READ")),
],
)
async def get_session_detail(
session_id: str,
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.get_session_detail"):
session = _get_owned_session_or_404(repository, session_id, current_user)
return _serialize_session_detail(session)
# [/DEF:get_session_detail:Function]
# [DEF:update_session:Function]
# @COMPLEXITY: 3
# @PURPOSE: Update resumable lifecycle status for an owned session.
@router.patch(
"/sessions/{session_id}",
response_model=SessionSummary,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def update_session(
session_id: str,
request: UpdateSessionRequest,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("update_session"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
session_record = cast(Any, session)
session_record.status = request.status
if request.status == SessionStatus.PAUSED:
session_record.recommended_action = RecommendedAction.RESUME_SESSION
elif request.status in {SessionStatus.ARCHIVED, SessionStatus.CANCELLED, SessionStatus.COMPLETED}:
session_record.active_task_id = None
_commit_owned_session_mutation(repository, session)
_record_session_event(
repository, session, current_user,
event_type="session_status_updated",
event_summary="Dataset review session lifecycle updated",
event_details={"status": session_record.status.value, "version": session_record.version},
)
return _serialize_session_summary(session)
# [/DEF:update_session:Function]
# [DEF:delete_session:Function]
# @COMPLEXITY: 3
# @PURPOSE: Archive or hard-delete a session owned by the current user.
@router.delete(
"/sessions/{session_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def delete_session(
session_id: str,
hard_delete: bool = Query(False),
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("delete_session"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
if hard_delete:
_record_session_event(repository, session, current_user, event_type="session_deleted", event_summary="Session hard-deleted", event_details={"hard_delete": True})
repository.db.delete(session)
repository.db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
session_record = cast(Any, session)
session_record.status = SessionStatus.ARCHIVED
session_record.active_task_id = None
_commit_owned_session_mutation(repository, session)
_record_session_event(repository, session, current_user, event_type="session_archived", event_summary="Session archived", event_details={"hard_delete": False, "version": session_record.version})
return Response(status_code=status.HTTP_204_NO_CONTENT)
# [/DEF:delete_session:Function]
# [DEF:export_documentation:Function]
# @COMPLEXITY: 3
# @PURPOSE: Export documentation output for the current session.
@router.get(
"/sessions/{session_id}/exports/documentation",
response_model=ExportArtifactResponse,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "READ")),
],
)
async def export_documentation(
session_id: str,
format: ArtifactFormat = Query(ArtifactFormat.JSON),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("export_documentation"):
if format not in {ArtifactFormat.JSON, ArtifactFormat.MARKDOWN}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only json and markdown exports are supported")
session = _get_owned_session_or_404(repository, session_id, current_user)
payload = _build_documentation_export(session, format)
return ExportArtifactResponse(
artifact_id=f"documentation-{session.session_id}-{format.value}",
session_id=session.session_id, artifact_type="documentation",
format=format.value, storage_ref=payload["storage_ref"],
created_by_user_id=current_user.id, content=payload["content"],
)
# [/DEF:export_documentation:Function]
# [DEF:export_validation:Function]
# @COMPLEXITY: 3
# @PURPOSE: Export validation findings for the current session.
@router.get(
"/sessions/{session_id}/exports/validation",
response_model=ExportArtifactResponse,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "READ")),
],
)
async def export_validation(
session_id: str,
format: ArtifactFormat = Query(ArtifactFormat.JSON),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("export_validation"):
if format not in {ArtifactFormat.JSON, ArtifactFormat.MARKDOWN}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only json and markdown exports are supported")
session = _get_owned_session_or_404(repository, session_id, current_user)
payload = _build_validation_export(session, format)
return ExportArtifactResponse(
artifact_id=f"validation-{session.session_id}-{format.value}",
session_id=session.session_id, artifact_type="validation_report",
format=format.value, storage_ref=payload["storage_ref"],
created_by_user_id=current_user.id, content=payload["content"],
)
# [/DEF:export_validation:Function]
# [DEF:get_clarification_state:Function]
# @COMPLEXITY: 3
# @PURPOSE: Return the current clarification session summary and active question payload.
@router.get(
"/sessions/{session_id}/clarification",
response_model=ClarificationStateResponse,
dependencies=[
Depends(_require_auto_review_flag),
Depends(_require_clarification_flag),
Depends(has_permission("dataset:session", "READ")),
],
)
async def get_clarification_state(
session_id: str,
repository=Depends(_get_repository),
clarification_engine=Depends(_get_clarification_engine),
current_user: User = Depends(get_current_user),
):
with belief_scope("get_clarification_state"):
session = _get_owned_session_or_404(repository, session_id, current_user)
if not session.clarification_sessions:
return _serialize_empty_clarification_state()
cs = _get_latest_clarification_session_or_404(session)
question = clarification_engine.build_question_payload(session)
return _serialize_clarification_state(
ClarificationStateResult(clarification_session=cs, current_question=question, session=session, changed_findings=[])
)
# [/DEF:get_clarification_state:Function]
# [DEF:resume_clarification:Function]
# @COMPLEXITY: 3
# @PURPOSE: Resume clarification mode on the highest-priority unresolved question.
@router.post(
"/sessions/{session_id}/clarification/resume",
response_model=ClarificationStateResponse,
dependencies=[
Depends(_require_auto_review_flag),
Depends(_require_clarification_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def resume_clarification(
session_id: str,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
clarification_engine=Depends(_get_clarification_engine),
current_user: User = Depends(get_current_user),
):
with belief_scope("resume_clarification"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
cs = _get_latest_clarification_session_or_404(session)
question = clarification_engine.build_question_payload(session)
return _serialize_clarification_state(
ClarificationStateResult(clarification_session=cs, current_question=question, session=session, changed_findings=[])
)
# [/DEF:resume_clarification:Function]
# [DEF:record_clarification_answer:Function]
# @COMPLEXITY: 3
# @PURPOSE: Persist one clarification answer before advancing the active pointer.
@router.post(
"/sessions/{session_id}/clarification/answers",
response_model=ClarificationAnswerResultResponse,
dependencies=[
Depends(_require_auto_review_flag),
Depends(_require_clarification_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def record_clarification_answer(
session_id: str,
request: ClarificationAnswerRequest,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
clarification_engine=Depends(_get_clarification_engine),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.record_clarification_answer"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
try:
result = clarification_engine.record_answer(
ClarificationAnswerCommand(
session=session, question_id=request.question_id,
answer_kind=request.answer_kind, answer_value=request.answer_value,
user=current_user,
)
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return ClarificationAnswerResultResponse(
clarification_state=_serialize_clarification_state(result),
session=_serialize_session_summary(result.session),
changed_findings=[ValidationFindingDto.model_validate(f, from_attributes=True) for f in result.changed_findings],
)
# [/DEF:record_clarification_answer:Function]
# [DEF:update_field_semantic:Function]
# @COMPLEXITY: 3
# @PURPOSE: Apply one field-level semantic candidate decision or manual override.
@router.patch(
"/sessions/{session_id}/fields/{field_id}/semantic",
response_model=SemanticFieldEntryDto,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def update_field_semantic(
session_id: str, field_id: str, request: FieldSemanticUpdateRequest,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.update_field_semantic"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
field = _get_owned_field_or_404(session, field_id)
_update_semantic_field_state(field, request, changed_by="user")
sr = cast(Any, session)
_commit_owned_session_mutation(repository, session, refresh_targets=[field])
_record_session_event(repository, session, current_user, event_type="semantic_field_updated", event_summary="Semantic field decision persisted", event_details={"field_id": field.field_id, "version": sr.version})
return _serialize_semantic_field(field)
# [/DEF:update_field_semantic:Function]
# [DEF:lock_field_semantic:Function]
# @COMPLEXITY: 3
# @PURPOSE: Lock one semantic field against later automatic overwrite.
@router.post(
"/sessions/{session_id}/fields/{field_id}/lock",
response_model=SemanticFieldEntryDto,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def lock_field_semantic(
session_id: str, field_id: str,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.lock_field_semantic"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
field = _get_owned_field_or_404(session, field_id)
field.is_locked = True
field.last_changed_by = "user"
sr = cast(Any, session)
_commit_owned_session_mutation(repository, session, refresh_targets=[field])
_record_session_event(repository, session, current_user, event_type="semantic_field_locked", event_summary="Semantic field lock persisted", event_details={"field_id": field.field_id, "version": sr.version})
return _serialize_semantic_field(field)
# [/DEF:lock_field_semantic:Function]
# [DEF:unlock_field_semantic:Function]
# @COMPLEXITY: 3
# @PURPOSE: Unlock one semantic field so later automated candidate application may replace it.
@router.post(
"/sessions/{session_id}/fields/{field_id}/unlock",
response_model=SemanticFieldEntryDto,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def unlock_field_semantic(
session_id: str, field_id: str,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.unlock_field_semantic"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
field = _get_owned_field_or_404(session, field_id)
field.is_locked = False
field.last_changed_by = "user"
if field.provenance == FieldProvenance.MANUAL_OVERRIDE:
field.provenance = FieldProvenance.UNRESOLVED
field.needs_review = True
sr = cast(Any, session)
_commit_owned_session_mutation(repository, session, refresh_targets=[field])
_record_session_event(repository, session, current_user, event_type="semantic_field_unlocked", event_summary="Semantic field unlock persisted", event_details={"field_id": field.field_id, "version": sr.version})
return _serialize_semantic_field(field)
# [/DEF:unlock_field_semantic:Function]
# [DEF:approve_batch_semantic_fields:Function]
# @COMPLEXITY: 3
# @PURPOSE: Approve multiple semantic candidate decisions in one batch.
@router.post(
"/sessions/{session_id}/fields/semantic/approve-batch",
response_model=List[SemanticFieldEntryDto],
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def approve_batch_semantic_fields(
session_id: str, request: BatchApproveSemanticRequest,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.approve_batch_semantic_fields"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
updated = []
for item in request.items:
field = _get_owned_field_or_404(session, item.field_id)
_update_semantic_field_state(field, FieldSemanticUpdateRequest(candidate_id=item.candidate_id, lock_field=item.lock_field), changed_by="user")
updated.append(field)
sr = cast(Any, session)
_commit_owned_session_mutation(repository, session, refresh_targets=list(updated))
_record_session_event(repository, session, current_user, event_type="semantic_fields_batch_approved", event_summary="Batch semantic approval persisted", event_details={"count": len(updated), "version": sr.version})
return [_serialize_semantic_field(f) for f in updated]
# [/DEF:approve_batch_semantic_fields:Function]
# [DEF:list_execution_mappings:Function]
# @COMPLEXITY: 3
# @PURPOSE: Return the current mapping-review set for one accessible session.
@router.get(
"/sessions/{session_id}/mappings",
response_model=MappingCollectionResponse,
dependencies=[
Depends(_require_auto_review_flag),
Depends(_require_execution_flag),
Depends(has_permission("dataset:session", "READ")),
],
)
async def list_execution_mappings(
session_id: str,
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.list_execution_mappings"):
session = _get_owned_session_or_404(repository, session_id, current_user)
return MappingCollectionResponse(items=[_serialize_execution_mapping(m) for m in session.execution_mappings])
# [/DEF:list_execution_mappings:Function]
# [DEF:update_execution_mapping:Function]
# @COMPLEXITY: 3
# @PURPOSE: Persist one owner-authorized execution-mapping effective value override.
@router.patch(
"/sessions/{session_id}/mappings/{mapping_id}",
response_model=ExecutionMappingDto,
dependencies=[
Depends(_require_auto_review_flag),
Depends(_require_execution_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def update_execution_mapping(
session_id: str, mapping_id: str, request: UpdateExecutionMappingRequest,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.update_execution_mapping"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
mapping = _get_owned_mapping_or_404(session, mapping_id)
if request.effective_value is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="effective_value is required")
mapping.effective_value = request.effective_value
mapping.mapping_method = MappingMethod(request.mapping_method or MappingMethod.MANUAL_OVERRIDE.value)
mapping.transformation_note = request.transformation_note
mapping.approval_state = ApprovalState.APPROVED
mapping.approved_by_user_id = current_user.id
mapping.approved_at = datetime.utcnow()
session.last_activity_at = datetime.utcnow()
session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW
if session.readiness_state in {ReadinessState.MAPPING_REVIEW_NEEDED, ReadinessState.COMPILED_PREVIEW_READY, ReadinessState.RUN_READY, ReadinessState.RUN_IN_PROGRESS}:
session.readiness_state = ReadinessState.COMPILED_PREVIEW_READY
for preview in session.previews:
if preview.preview_status == PreviewStatus.READY:
preview.preview_status = PreviewStatus.STALE
sr = cast(Any, session)
_commit_owned_session_mutation(repository, session, refresh_targets=[mapping])
_record_session_event(repository, session, current_user, event_type="execution_mapping_updated", event_summary="Mapping override persisted", event_details={"mapping_id": mapping.mapping_id, "version": sr.version})
return _serialize_execution_mapping(mapping)
# [/DEF:update_execution_mapping:Function]
# [DEF:approve_execution_mapping:Function]
# @COMPLEXITY: 3
# @PURPOSE: Explicitly approve a warning-sensitive mapping transformation.
@router.post(
"/sessions/{session_id}/mappings/{mapping_id}/approve",
response_model=ExecutionMappingDto,
dependencies=[
Depends(_require_auto_review_flag),
Depends(_require_execution_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def approve_execution_mapping(
session_id: str, mapping_id: str, request: ApproveMappingRequest,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.approve_execution_mapping"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
mapping = _get_owned_mapping_or_404(session, mapping_id)
mapping.approval_state = ApprovalState.APPROVED
mapping.approved_by_user_id = current_user.id
mapping.approved_at = datetime.utcnow()
if request.approval_note:
mapping.transformation_note = request.approval_note
session.last_activity_at = datetime.utcnow()
if session.readiness_state == ReadinessState.MAPPING_REVIEW_NEEDED:
session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW
sr = cast(Any, session)
_commit_owned_session_mutation(repository, session, refresh_targets=[mapping])
_record_session_event(repository, session, current_user, event_type="execution_mapping_approved", event_summary="Mapping approval persisted", event_details={"mapping_id": mapping.mapping_id, "version": sr.version})
return _serialize_execution_mapping(mapping)
# [/DEF:approve_execution_mapping:Function]
# [DEF:approve_batch_execution_mappings:Function]
# @COMPLEXITY: 3
# @PURPOSE: Approve multiple warning-sensitive execution mappings in one batch.
@router.post(
"/sessions/{session_id}/mappings/approve-batch",
response_model=List[ExecutionMappingDto],
dependencies=[
Depends(_require_auto_review_flag),
Depends(_require_execution_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def approve_batch_execution_mappings(
session_id: str, request: BatchApproveMappingRequest,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.approve_batch_execution_mappings"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
updated = []
for mid in list(dict.fromkeys(request.mapping_ids)):
mapping = _get_owned_mapping_or_404(session, mid)
mapping.approval_state = ApprovalState.APPROVED
mapping.approved_by_user_id = current_user.id
mapping.approved_at = datetime.utcnow()
if request.approval_note:
mapping.transformation_note = request.approval_note
updated.append(mapping)
session.last_activity_at = datetime.utcnow()
if session.readiness_state == ReadinessState.MAPPING_REVIEW_NEEDED:
session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW
sr = cast(Any, session)
_commit_owned_session_mutation(repository, session, refresh_targets=list(updated))
_record_session_event(repository, session, current_user, event_type="execution_mappings_batch_approved", event_summary="Batch mapping approval persisted", event_details={"count": len(updated), "version": sr.version})
return [_serialize_execution_mapping(m) for m in updated]
# [/DEF:approve_batch_execution_mappings:Function]
# [DEF:trigger_preview_generation:Function]
# @COMPLEXITY: 3
# @PURPOSE: Trigger Superset-side preview compilation for the current owned execution context.
@router.post(
"/sessions/{session_id}/preview",
response_model=Union[CompiledPreviewDto, PreviewEnqueueResultResponse],
dependencies=[
Depends(_require_auto_review_flag),
Depends(_require_execution_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def trigger_preview_generation(
session_id: str, response: Response,
orchestrator=Depends(_get_orchestrator),
repository=Depends(_get_repository),
session_version: int = Depends(_require_session_version_header),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.trigger_preview_generation"):
_prepare_owned_session_mutation(repository, session_id, current_user, session_version)
try:
result = orchestrator.prepare_launch_preview(
PreparePreviewCommand(user=current_user, session_id=session_id, expected_version=session_version)
)
except DatasetReviewSessionVersionConflictError as exc:
raise _build_session_version_conflict_http_exception(exc) from exc
except ValueError as exc:
detail = str(exc)
sc = status.HTTP_404_NOT_FOUND if detail in {"Session not found", "Environment not found"} else status.HTTP_409_CONFLICT if detail.startswith("Preview blocked:") else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=sc, detail=detail) from exc
if result.preview.preview_status == PreviewStatus.PENDING:
response.status_code = status.HTTP_202_ACCEPTED
return PreviewEnqueueResultResponse(
session_id=result.session.session_id,
session_version=int(getattr(result.session, "version", 0) or 0),
preview_status=result.preview.preview_status.value, task_id=None,
)
response.status_code = status.HTTP_200_OK
return _serialize_preview(result.preview, session_version_fallback=int(getattr(result.session, "version", 0) or 0))
# [/DEF:trigger_preview_generation:Function]
# [DEF:launch_dataset:Function]
# @COMPLEXITY: 3
# @PURPOSE: Execute the current owned session launch handoff and return audited SQL Lab run context.
@router.post(
"/sessions/{session_id}/launch",
response_model=LaunchDatasetResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[
Depends(_require_auto_review_flag),
Depends(_require_execution_flag),
Depends(has_permission("dataset:execution:launch", "EXECUTE")),
],
)
async def launch_dataset(
session_id: str,
orchestrator=Depends(_get_orchestrator),
repository=Depends(_get_repository),
session_version: int = Depends(_require_session_version_header),
config_manager=Depends(get_config_manager),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.launch_dataset"):
_prepare_owned_session_mutation(repository, session_id, current_user, session_version)
try:
result = orchestrator.launch_dataset(
LaunchDatasetCommand(user=current_user, session_id=session_id, expected_version=session_version)
)
except DatasetReviewSessionVersionConflictError as exc:
raise _build_session_version_conflict_http_exception(exc) from exc
except ValueError as exc:
detail = str(exc)
sc = status.HTTP_404_NOT_FOUND if detail in {"Session not found", "Environment not found"} else status.HTTP_409_CONFLICT if detail.startswith("Launch blocked:") else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=sc, detail=detail) from exc
environment = config_manager.get_environment(result.session.environment_id)
env_url = getattr(environment, "url", "") if environment is not None else ""
return LaunchDatasetResponse(
run_context=_serialize_run_context(result.run_context),
redirect_url=_build_sql_lab_redirect_url(environment_url=env_url, sql_lab_session_ref=result.run_context.sql_lab_session_ref),
)
# [/DEF:launch_dataset:Function]
# [DEF:record_field_feedback:Function]
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for AI-assisted semantic field content.
@router.post(
"/sessions/{session_id}/fields/{field_id}/feedback",
response_model=FeedbackResponse,
dependencies=[
Depends(_require_auto_review_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def record_field_feedback(
session_id: str, field_id: str, request: FeedbackRequest,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.record_field_feedback"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
field = _get_owned_field_or_404(session, field_id)
field.user_feedback = request.feedback
sr = cast(Any, session)
_commit_owned_session_mutation(repository, session)
_record_session_event(repository, session, current_user, event_type="semantic_field_feedback_recorded", event_summary="Feedback persisted", event_details={"field_id": field.field_id, "feedback": request.feedback, "version": sr.version})
return FeedbackResponse(target_id=field.field_id, feedback=request.feedback)
# [/DEF:record_field_feedback:Function]
# [DEF:record_clarification_feedback:Function]
# @COMPLEXITY: 3
# @PURPOSE: Persist thumbs up/down feedback for clarification question/answer content.
@router.post(
"/sessions/{session_id}/clarification/questions/{question_id}/feedback",
response_model=FeedbackResponse,
dependencies=[
Depends(_require_auto_review_flag),
Depends(_require_clarification_flag),
Depends(has_permission("dataset:session", "MANAGE")),
],
)
async def record_clarification_feedback(
session_id: str, question_id: str, request: FeedbackRequest,
session_version: int = Depends(_require_session_version_header),
repository=Depends(_get_repository),
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.record_clarification_feedback"):
session = _prepare_owned_session_mutation(repository, session_id, current_user, session_version)
cs = _get_latest_clarification_session_or_404(session)
question = next((q for q in cs.questions if q.question_id == question_id), None)
if question is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Clarification question not found")
if question.answer is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Clarification answer not found")
question.answer.user_feedback = request.feedback
sr = cast(Any, session)
_commit_owned_session_mutation(repository, session)
_record_session_event(repository, session, current_user, event_type="clarification_feedback_recorded", event_summary="Feedback persisted", event_details={"question_id": question.question_id, "feedback": request.feedback, "version": sr.version})
return FeedbackResponse(target_id=question.question_id, feedback=request.feedback)
# [/DEF:record_clarification_feedback:Function]
# [/DEF:DatasetReviewRoutes:Module]