Files
ss-tools/backend/src/services/dataset_review/clarification_pkg/_helpers.py
2026-05-26 09:30:41 +03:00

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