Files
ss-tools/backend/src/api/routes/dataset_review_pkg/_routes.py
2026-04-24 17:10:02 +03:00

924 lines
39 KiB
Python

# [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]