289 lines
15 KiB
Python
289 lines
15 KiB
Python
# #region ClarificationEngine [C:4] [TYPE Module] [SEMANTICS pydantic, dataset, clarification, finding, resolution]
|
|
# @BRIEF Manage one-question-at-a-time clarification state, deterministic answer persistence, and readiness/finding updates.
|
|
# @LAYER Domain
|
|
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
|
|
# @RELATION DEPENDS_ON -> [ClarificationSession]
|
|
# @RELATION DEPENDS_ON -> [ClarificationQuestion]
|
|
# @RELATION DEPENDS_ON -> [ClarificationAnswer]
|
|
# @RELATION DEPENDS_ON -> [ValidationFinding]
|
|
# @RELATION DISPATCHES -> [ClarificationHelpers]
|
|
# @PRE Target session contains a persisted clarification aggregate in the current ownership scope.
|
|
# @POST Active clarification payload exposes one highest-priority unresolved question, and each recorded answer is persisted before pointer/readiness mutation.
|
|
# @SIDE_EFFECT Persists clarification answers, question/session states, and related readiness/finding changes.
|
|
# @DATA_CONTRACT Input[DatasetReviewSession|ClarificationAnswerCommand] -> Output[ClarificationStateResult]
|
|
# @INVARIANT Only one active clarification question may exist at a time; skipped and expert-review items remain unresolved and visible.
|
|
# @RATIONALE Original 635-line file exceeded INV_7 (400-line module limit). Extracted pure helpers into _helpers sub-module.
|
|
# @REJECTED Keeping all clarification logic in one file because it exceeded the fractal limit.
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
|
|
from src.core.logger import belief_scope, logger
|
|
from src.models.auth import User
|
|
from src.models.dataset_review import (
|
|
AnswerKind,
|
|
ClarificationAnswer,
|
|
ClarificationQuestion,
|
|
ClarificationSession,
|
|
ClarificationStatus,
|
|
DatasetReviewSession,
|
|
QuestionState,
|
|
ReadinessState,
|
|
RecommendedAction,
|
|
SessionPhase,
|
|
ValidationFinding,
|
|
)
|
|
from src.services.dataset_review.clarification_pkg._helpers import (
|
|
build_impact_summary,
|
|
count_remaining_questions,
|
|
count_resolved_questions,
|
|
derive_readiness_state,
|
|
derive_recommended_action,
|
|
normalize_answer_value,
|
|
select_next_open_question,
|
|
upsert_clarification_finding,
|
|
)
|
|
from src.services.dataset_review.repositories.session_repository import (
|
|
DatasetReviewSessionRepository,
|
|
)
|
|
|
|
|
|
# #region ClarificationQuestionPayload [C:2] [TYPE Class]
|
|
# @BRIEF Typed active-question payload returned to the API layer.
|
|
@dataclass
|
|
class ClarificationQuestionPayload:
|
|
question_id: str
|
|
clarification_session_id: str
|
|
topic_ref: str
|
|
question_text: str
|
|
why_it_matters: str
|
|
current_guess: str | None
|
|
priority: int
|
|
state: QuestionState
|
|
options: list[dict[str, object]] = field(default_factory=list)
|
|
|
|
|
|
# #endregion ClarificationQuestionPayload
|
|
|
|
|
|
# #region ClarificationStateResult [C:2] [TYPE Class]
|
|
# @BRIEF Clarification state result carrying the current session, active payload, and changed findings.
|
|
@dataclass
|
|
class ClarificationStateResult:
|
|
clarification_session: ClarificationSession
|
|
current_question: ClarificationQuestionPayload | None
|
|
session: DatasetReviewSession
|
|
changed_findings: list[ValidationFinding] = field(default_factory=list)
|
|
|
|
|
|
# #endregion ClarificationStateResult
|
|
|
|
|
|
# #region ClarificationAnswerCommand [C:2] [TYPE Class]
|
|
# @BRIEF Typed answer command for clarification state mutation.
|
|
@dataclass
|
|
class ClarificationAnswerCommand:
|
|
session: DatasetReviewSession
|
|
question_id: str
|
|
answer_kind: AnswerKind
|
|
answer_value: str | None
|
|
user: User
|
|
|
|
|
|
# #endregion ClarificationAnswerCommand
|
|
|
|
|
|
# #region ClarificationEngine [C:4] [TYPE Class]
|
|
# @BRIEF Provide deterministic one-question-at-a-time clarification selection and answer persistence.
|
|
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
|
|
# @RELATION CALLS -> [ClarificationHelpers]
|
|
# @PRE Repository is bound to the current request transaction scope.
|
|
# @POST Returned clarification state is persistence-backed and aligned with session readiness/recommended action.
|
|
# @SIDE_EFFECT Mutates clarification answers, session flags, and related clarification findings.
|
|
class ClarificationEngine:
|
|
# region ClarificationEngine_init [TYPE Function]
|
|
# @PURPOSE: Bind repository dependency for clarification persistence operations.
|
|
def __init__(self, repository: DatasetReviewSessionRepository) -> None:
|
|
self.repository = repository
|
|
|
|
# endregion ClarificationEngine_init
|
|
|
|
# region build_question_payload [TYPE Function]
|
|
# @PURPOSE: Return the one active highest-priority clarification question payload.
|
|
# @PRE Session contains unresolved clarification state or a resumable clarification session.
|
|
# @POST Returns exactly one active/open question payload or None when no unresolved question remains.
|
|
# @SIDE_EFFECT Normalizes the active-question pointer and clarification status in persistence.
|
|
def build_question_payload(
|
|
self, session: DatasetReviewSession,
|
|
) -> ClarificationQuestionPayload | None:
|
|
with belief_scope("ClarificationEngine.build_question_payload"):
|
|
clarification_session = self._get_latest_clarification_session(session)
|
|
if clarification_session is None:
|
|
logger.reason("No clarification session found", extra={"session_id": session.session_id})
|
|
return None
|
|
|
|
active_questions = [
|
|
q for q in clarification_session.questions if q.state == QuestionState.OPEN
|
|
]
|
|
active_questions.sort(key=lambda item: (-int(item.priority), item.created_at, item.question_id))
|
|
|
|
if not active_questions:
|
|
clarification_session.current_question_id = None
|
|
clarification_session.status = ClarificationStatus.COMPLETED
|
|
session.readiness_state = derive_readiness_state(session, clarification_session)
|
|
session.recommended_action = derive_recommended_action(session, clarification_session)
|
|
if session.current_phase == SessionPhase.CLARIFICATION:
|
|
session.current_phase = SessionPhase.REVIEW
|
|
self.repository.db.commit()
|
|
logger.reflect("No unresolved clarification question remains", extra={"session_id": session.session_id})
|
|
return None
|
|
|
|
selected_question = active_questions[0]
|
|
clarification_session.current_question_id = selected_question.question_id
|
|
clarification_session.status = ClarificationStatus.ACTIVE
|
|
session.readiness_state = ReadinessState.CLARIFICATION_ACTIVE
|
|
session.recommended_action = RecommendedAction.ANSWER_NEXT_QUESTION
|
|
session.current_phase = SessionPhase.CLARIFICATION
|
|
|
|
logger.reason("Selected active clarification question", extra={"session_id": session.session_id, "question_id": selected_question.question_id, "priority": selected_question.priority})
|
|
self.repository.db.commit()
|
|
|
|
payload = ClarificationQuestionPayload(
|
|
question_id=selected_question.question_id,
|
|
clarification_session_id=selected_question.clarification_session_id,
|
|
topic_ref=selected_question.topic_ref,
|
|
question_text=selected_question.question_text,
|
|
why_it_matters=selected_question.why_it_matters,
|
|
current_guess=selected_question.current_guess,
|
|
priority=selected_question.priority,
|
|
state=selected_question.state,
|
|
options=[
|
|
{"option_id": o.option_id, "question_id": o.question_id, "label": o.label, "value": o.value, "is_recommended": o.is_recommended, "display_order": o.display_order}
|
|
for o in sorted(selected_question.options, key=lambda item: (item.display_order, item.label, item.option_id))
|
|
],
|
|
)
|
|
logger.reflect("Clarification payload built", extra={"session_id": session.session_id, "question_id": payload.question_id, "option_count": len(payload.options)})
|
|
return payload
|
|
|
|
# endregion build_question_payload
|
|
|
|
# region record_answer [TYPE Function]
|
|
# @PURPOSE: Persist one clarification answer before any pointer/readiness mutation.
|
|
# @PRE Target question belongs to the session's active clarification session and is still open.
|
|
# @POST Answer row is persisted before current-question pointer advances.
|
|
# @SIDE_EFFECT Inserts answer row, mutates question/session states, updates clarification findings, and commits.
|
|
def record_answer(self, command: ClarificationAnswerCommand) -> ClarificationStateResult:
|
|
with belief_scope("ClarificationEngine.record_answer"):
|
|
session = command.session
|
|
clarification_session = self._get_latest_clarification_session(session)
|
|
if clarification_session is None:
|
|
logger.explore("Cannot record clarification answer because no clarification session exists", extra={"session_id": session.session_id})
|
|
raise ValueError("Clarification session not found")
|
|
|
|
question = self._find_question(clarification_session, command.question_id)
|
|
if question is None:
|
|
logger.explore("Cannot record clarification answer for foreign or missing question", extra={"session_id": session.session_id, "question_id": command.question_id})
|
|
raise ValueError("Clarification question not found")
|
|
|
|
if question.answer is not None:
|
|
logger.explore("Rejected duplicate clarification answer submission", extra={"session_id": session.session_id, "question_id": command.question_id})
|
|
raise ValueError("Clarification question already answered")
|
|
|
|
if clarification_session.current_question_id and clarification_session.current_question_id != question.question_id:
|
|
logger.explore("Rejected answer for non-active clarification question", extra={"session_id": session.session_id, "question_id": question.question_id, "current_question_id": clarification_session.current_question_id})
|
|
raise ValueError("Only the active clarification question can be answered")
|
|
|
|
normalized_answer_value = normalize_answer_value(command.answer_kind, command.answer_value, question)
|
|
|
|
logger.reason("Persisting clarification answer before state advancement", extra={"session_id": session.session_id, "question_id": question.question_id, "answer_kind": command.answer_kind.value})
|
|
persisted_answer = ClarificationAnswer(
|
|
question_id=question.question_id,
|
|
answer_kind=command.answer_kind,
|
|
answer_value=normalized_answer_value,
|
|
answered_by_user_id=command.user.id,
|
|
impact_summary=build_impact_summary(question, command.answer_kind, normalized_answer_value),
|
|
)
|
|
self.repository.db.add(persisted_answer)
|
|
self.repository.db.flush()
|
|
|
|
changed_finding = upsert_clarification_finding(
|
|
session=session, question=question, answer_kind=command.answer_kind,
|
|
answer_value=normalized_answer_value, db_session=self.repository.db,
|
|
)
|
|
|
|
if command.answer_kind == AnswerKind.SELECTED or command.answer_kind == AnswerKind.CUSTOM:
|
|
question.state = QuestionState.ANSWERED
|
|
elif command.answer_kind == AnswerKind.SKIPPED:
|
|
question.state = QuestionState.SKIPPED
|
|
elif command.answer_kind == AnswerKind.EXPERT_REVIEW:
|
|
question.state = QuestionState.EXPERT_REVIEW
|
|
|
|
question.updated_at = datetime.utcnow()
|
|
self.repository.db.flush()
|
|
|
|
clarification_session.resolved_count = count_resolved_questions(clarification_session)
|
|
clarification_session.remaining_count = count_remaining_questions(clarification_session)
|
|
clarification_session.summary_delta = self.summarize_progress(clarification_session)
|
|
clarification_session.updated_at = datetime.utcnow()
|
|
|
|
next_question = select_next_open_question(clarification_session)
|
|
clarification_session.current_question_id = next_question.question_id if next_question else None
|
|
clarification_session.status = ClarificationStatus.ACTIVE if next_question else ClarificationStatus.COMPLETED
|
|
if clarification_session.status == ClarificationStatus.COMPLETED:
|
|
clarification_session.completed_at = datetime.utcnow()
|
|
|
|
session.readiness_state = derive_readiness_state(session, clarification_session)
|
|
session.recommended_action = derive_recommended_action(session, clarification_session)
|
|
session.current_phase = SessionPhase.CLARIFICATION if clarification_session.current_question_id else SessionPhase.REVIEW
|
|
self.repository.bump_session_version(session)
|
|
|
|
self.repository.db.commit()
|
|
self.repository.db.refresh(session)
|
|
|
|
logger.reflect("Clarification answer recorded and session advanced", extra={"session_id": session.session_id, "question_id": question.question_id, "next_question_id": clarification_session.current_question_id, "readiness_state": session.readiness_state.value, "remaining_count": clarification_session.remaining_count})
|
|
|
|
return ClarificationStateResult(
|
|
clarification_session=clarification_session,
|
|
current_question=self.build_question_payload(session),
|
|
session=session,
|
|
changed_findings=[changed_finding] if changed_finding else [],
|
|
)
|
|
|
|
# endregion record_answer
|
|
|
|
# region summarize_progress [TYPE Function]
|
|
# @PURPOSE: Produce a compact progress summary for pause/resume and completion UX.
|
|
def summarize_progress(self, clarification_session: ClarificationSession) -> str:
|
|
resolved = count_resolved_questions(clarification_session)
|
|
remaining = count_remaining_questions(clarification_session)
|
|
return f"{resolved} resolved, {remaining} unresolved"
|
|
|
|
# endregion summarize_progress
|
|
|
|
# region _get_latest_clarification_session [TYPE Function]
|
|
# @PURPOSE: Select the latest clarification session for the current dataset review aggregate.
|
|
def _get_latest_clarification_session(self, session: DatasetReviewSession) -> ClarificationSession | None:
|
|
if not session.clarification_sessions:
|
|
return None
|
|
ordered = sorted(session.clarification_sessions, key=lambda item: (item.started_at, item.clarification_session_id), reverse=True)
|
|
return ordered[0]
|
|
|
|
# endregion _get_latest_clarification_session
|
|
|
|
# region _find_question [TYPE Function]
|
|
# @PURPOSE: Resolve a clarification question from the active clarification aggregate.
|
|
def _find_question(self, clarification_session: ClarificationSession, question_id: str) -> ClarificationQuestion | None:
|
|
for q in clarification_session.questions:
|
|
if q.question_id == question_id:
|
|
return q
|
|
return None
|
|
|
|
# endregion _find_question
|
|
|
|
|
|
# #endregion ClarificationEngine
|
|
|
|
# #endregion ClarificationEngine
|