210 lines
7.5 KiB
Python
210 lines
7.5 KiB
Python
# #region ClarificationHelpers [C:3] [TYPE Module] [SEMANTICS dataset, clarification, question, selection, normalization]
|
|
# @BRIEF Pure helper functions for clarification engine — question selection, counting, normalization, finding upsert, and readiness derivation.
|
|
# @LAYER Domain
|
|
# @RELATION DEPENDS_ON -> [DatasetReviewModels]
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
import uuid
|
|
|
|
from src.models.dataset_review import (
|
|
AnswerKind,
|
|
ClarificationQuestion,
|
|
ClarificationSession,
|
|
DatasetReviewSession,
|
|
FindingArea,
|
|
FindingSeverity,
|
|
QuestionState,
|
|
ReadinessState,
|
|
RecommendedAction,
|
|
ResolutionState,
|
|
ValidationFinding,
|
|
)
|
|
|
|
|
|
# #region select_next_open_question [C:2] [TYPE Function]
|
|
# @BRIEF Select the next unresolved question in deterministic priority order.
|
|
def select_next_open_question(
|
|
clarification_session: ClarificationSession,
|
|
) -> ClarificationQuestion | None:
|
|
open_questions = [
|
|
q for q in clarification_session.questions if q.state == QuestionState.OPEN
|
|
]
|
|
if not open_questions:
|
|
return None
|
|
open_questions.sort(key=lambda item: (-int(item.priority), item.created_at, item.question_id))
|
|
return open_questions[0]
|
|
|
|
|
|
# #endregion select_next_open_question
|
|
|
|
|
|
# #region count_resolved_questions [C:1] [TYPE Function]
|
|
# @BRIEF Count questions whose answers fully resolved the ambiguity.
|
|
def count_resolved_questions(clarification_session: ClarificationSession) -> int:
|
|
return sum(1 for q in clarification_session.questions if q.state == QuestionState.ANSWERED)
|
|
|
|
|
|
# #endregion count_resolved_questions
|
|
|
|
|
|
# #region count_remaining_questions [C:1] [TYPE Function]
|
|
# @BRIEF Count questions still unresolved or deferred after clarification interaction.
|
|
def count_remaining_questions(clarification_session: ClarificationSession) -> int:
|
|
return sum(
|
|
1
|
|
for q in clarification_session.questions
|
|
if q.state in {QuestionState.OPEN, QuestionState.SKIPPED, QuestionState.EXPERT_REVIEW}
|
|
)
|
|
|
|
|
|
# #endregion count_remaining_questions
|
|
|
|
|
|
# #region normalize_answer_value [C:2] [TYPE Function]
|
|
# @BRIEF Validate and normalize answer payload based on answer kind and active question options.
|
|
def normalize_answer_value(
|
|
answer_kind: AnswerKind,
|
|
answer_value: str | None,
|
|
question: ClarificationQuestion,
|
|
) -> str | None:
|
|
normalized = str(answer_value).strip() if answer_value is not None else None
|
|
if answer_kind in {AnswerKind.SELECTED, AnswerKind.CUSTOM} and not normalized:
|
|
raise ValueError("answer_value is required for selected or custom clarification answers")
|
|
if answer_kind == AnswerKind.SELECTED:
|
|
allowed_values = {option.value for option in question.options}
|
|
if normalized not in allowed_values:
|
|
raise ValueError("answer_value must match one of the current clarification options")
|
|
if answer_kind == AnswerKind.SKIPPED:
|
|
return normalized or "skipped"
|
|
if answer_kind == AnswerKind.EXPERT_REVIEW:
|
|
return normalized or "expert_review"
|
|
return normalized
|
|
|
|
|
|
# #endregion normalize_answer_value
|
|
|
|
|
|
# #region build_impact_summary [C:1] [TYPE Function]
|
|
# @BRIEF Build a compact audit note describing how the clarification answer affects session state.
|
|
def build_impact_summary(
|
|
question: ClarificationQuestion,
|
|
answer_kind: AnswerKind,
|
|
answer_value: str | None,
|
|
) -> str:
|
|
if answer_kind == AnswerKind.SKIPPED:
|
|
return f"Clarification for {question.topic_ref} was skipped and remains unresolved."
|
|
if answer_kind == AnswerKind.EXPERT_REVIEW:
|
|
return f"Clarification for {question.topic_ref} was deferred for expert review."
|
|
return f"Clarification for {question.topic_ref} recorded as '{answer_value}'."
|
|
|
|
|
|
# #endregion build_impact_summary
|
|
|
|
|
|
# #region upsert_clarification_finding [C:3] [TYPE Function]
|
|
# @BRIEF Keep one finding per clarification topic aligned with answer outcome and unresolved visibility rules.
|
|
# @RELATION DEPENDS_ON -> [ValidationFinding]
|
|
def upsert_clarification_finding(
|
|
session: DatasetReviewSession,
|
|
question: ClarificationQuestion,
|
|
answer_kind: AnswerKind,
|
|
answer_value: str | None,
|
|
db_session,
|
|
) -> ValidationFinding | None:
|
|
caused_by_ref = f"clarification:{question.question_id}"
|
|
existing = next(
|
|
(f for f in session.findings if f.area == FindingArea.CLARIFICATION and f.caused_by_ref == caused_by_ref),
|
|
None,
|
|
)
|
|
|
|
if answer_kind in {AnswerKind.SELECTED, AnswerKind.CUSTOM}:
|
|
resolution_state = ResolutionState.RESOLVED
|
|
resolved_at = datetime.utcnow()
|
|
message = f"Clarified '{question.topic_ref}' with answer '{answer_value}'."
|
|
elif answer_kind == AnswerKind.SKIPPED:
|
|
resolution_state = ResolutionState.SKIPPED
|
|
resolved_at = None
|
|
message = f"Clarification for '{question.topic_ref}' was skipped and still needs review."
|
|
else:
|
|
resolution_state = ResolutionState.EXPERT_REVIEW
|
|
resolved_at = None
|
|
message = f"Clarification for '{question.topic_ref}' requires expert review."
|
|
|
|
if existing is None:
|
|
existing = ValidationFinding(
|
|
finding_id=str(uuid.uuid4()),
|
|
session_id=session.session_id,
|
|
area=FindingArea.CLARIFICATION,
|
|
severity=FindingSeverity.WARNING,
|
|
code="CLARIFICATION_PENDING",
|
|
title="Clarification pending",
|
|
message=message,
|
|
resolution_state=resolution_state,
|
|
resolution_note=None,
|
|
caused_by_ref=caused_by_ref,
|
|
created_at=datetime.utcnow(),
|
|
resolved_at=resolved_at,
|
|
)
|
|
db_session.add(existing)
|
|
session.findings.append(existing)
|
|
else:
|
|
existing.message = message
|
|
existing.resolution_state = resolution_state
|
|
existing.resolved_at = resolved_at
|
|
|
|
if answer_kind in {AnswerKind.SELECTED, AnswerKind.CUSTOM}:
|
|
existing.code = "CLARIFICATION_RESOLVED"
|
|
existing.title = "Clarification resolved"
|
|
elif answer_kind == AnswerKind.SKIPPED:
|
|
existing.code = "CLARIFICATION_SKIPPED"
|
|
existing.title = "Clarification skipped"
|
|
else:
|
|
existing.code = "CLARIFICATION_EXPERT_REVIEW"
|
|
existing.title = "Clarification requires expert review"
|
|
|
|
return existing
|
|
|
|
|
|
# #endregion upsert_clarification_finding
|
|
|
|
|
|
# #region derive_readiness_state [C:2] [TYPE Function]
|
|
# @BRIEF Recompute readiness after clarification mutation while preserving unresolved visibility semantics.
|
|
def derive_readiness_state(
|
|
session: DatasetReviewSession,
|
|
clarification_session: ClarificationSession | None,
|
|
) -> ReadinessState:
|
|
if clarification_session is None:
|
|
return session.readiness_state
|
|
if clarification_session.current_question_id:
|
|
return ReadinessState.CLARIFICATION_ACTIVE
|
|
if clarification_session.remaining_count > 0:
|
|
return ReadinessState.CLARIFICATION_NEEDED
|
|
return ReadinessState.REVIEW_READY
|
|
|
|
|
|
# #endregion derive_readiness_state
|
|
|
|
|
|
# #region derive_recommended_action [C:2] [TYPE Function]
|
|
# @BRIEF Recompute next-action guidance after clarification mutations.
|
|
def derive_recommended_action(
|
|
session: DatasetReviewSession,
|
|
clarification_session: ClarificationSession | None,
|
|
) -> RecommendedAction:
|
|
if clarification_session is None:
|
|
return session.recommended_action
|
|
if clarification_session.current_question_id:
|
|
return RecommendedAction.ANSWER_NEXT_QUESTION
|
|
if clarification_session.remaining_count > 0:
|
|
return RecommendedAction.START_CLARIFICATION
|
|
return RecommendedAction.REVIEW_DOCUMENTATION
|
|
|
|
|
|
# #endregion derive_recommended_action
|
|
|
|
|
|
# #endregion ClarificationHelpers
|