feat(ui): add chat-driven dataset review flow
Move dataset review clarification into the assistant workspace and rework the review page into a chat-centric layout with execution rails. Add session-scoped assistant actions for mappings, semantic fields, and SQL preview generation. Introduce optimistic locking for dataset review mutations, propagate session versions through API responses, and mask imported filter values before assistant exposure. Refresh tests, i18n, and spec artifacts to match the new workflow. BREAKING CHANGE: dataset review mutation endpoints now require the X-Session-Version header, and clarification is no longer handled through ClarificationDialog-based flows
This commit is contained in:
@@ -59,6 +59,8 @@ class ClarificationQuestionPayload:
|
||||
priority: int
|
||||
state: QuestionState
|
||||
options: list[dict[str, object]] = field(default_factory=list)
|
||||
|
||||
|
||||
# [/DEF:ClarificationQuestionPayload:Class]
|
||||
|
||||
|
||||
@@ -71,6 +73,8 @@ class ClarificationStateResult:
|
||||
current_question: Optional[ClarificationQuestionPayload]
|
||||
session: DatasetReviewSession
|
||||
changed_findings: List[ValidationFinding] = field(default_factory=list)
|
||||
|
||||
|
||||
# [/DEF:ClarificationStateResult:Class]
|
||||
|
||||
|
||||
@@ -84,6 +88,8 @@ class ClarificationAnswerCommand:
|
||||
answer_kind: AnswerKind
|
||||
answer_value: Optional[str]
|
||||
user: User
|
||||
|
||||
|
||||
# [/DEF:ClarificationAnswerCommand:Class]
|
||||
|
||||
|
||||
@@ -102,6 +108,7 @@ class ClarificationEngine:
|
||||
# @PURPOSE: Bind repository dependency for clarification persistence operations.
|
||||
def __init__(self, repository: DatasetReviewSessionRepository) -> None:
|
||||
self.repository = repository
|
||||
|
||||
# [/DEF:ClarificationEngine_init:Function]
|
||||
|
||||
# [DEF:build_question_payload:Function]
|
||||
@@ -127,10 +134,17 @@ class ClarificationEngine:
|
||||
return None
|
||||
|
||||
active_questions = [
|
||||
question for question in clarification_session.questions
|
||||
question
|
||||
for question in clarification_session.questions
|
||||
if question.state == QuestionState.OPEN
|
||||
]
|
||||
active_questions.sort(key=lambda item: (-int(item.priority), item.created_at, item.question_id))
|
||||
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
|
||||
@@ -184,7 +198,11 @@ class ClarificationEngine:
|
||||
}
|
||||
for option in sorted(
|
||||
selected_question.options,
|
||||
key=lambda item: (item.display_order, item.label, item.option_id),
|
||||
key=lambda item: (
|
||||
item.display_order,
|
||||
item.label,
|
||||
item.option_id,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -197,6 +215,7 @@ class ClarificationEngine:
|
||||
},
|
||||
)
|
||||
return payload
|
||||
|
||||
# [/DEF:build_question_payload:Function]
|
||||
|
||||
# [DEF:record_answer:Function]
|
||||
@@ -208,7 +227,9 @@ class ClarificationEngine:
|
||||
# @POST: Answer row is persisted before current-question pointer advances; skipped/expert-review items remain unresolved and visible.
|
||||
# @SIDE_EFFECT: Inserts answer row, mutates question/session states, updates clarification findings, and commits.
|
||||
# @DATA_CONTRACT: Input[ClarificationAnswerCommand] -> Output[ClarificationStateResult]
|
||||
def record_answer(self, command: ClarificationAnswerCommand) -> ClarificationStateResult:
|
||||
def record_answer(
|
||||
self, command: ClarificationAnswerCommand
|
||||
) -> ClarificationStateResult:
|
||||
with belief_scope("ClarificationEngine.record_answer"):
|
||||
session = command.session
|
||||
clarification_session = self._get_latest_clarification_session(session)
|
||||
@@ -223,18 +244,27 @@ class ClarificationEngine:
|
||||
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},
|
||||
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},
|
||||
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:
|
||||
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={
|
||||
@@ -243,9 +273,13 @@ class ClarificationEngine:
|
||||
"current_question_id": clarification_session.current_question_id,
|
||||
},
|
||||
)
|
||||
raise ValueError("Only the active clarification question can be answered")
|
||||
raise ValueError(
|
||||
"Only the active clarification question can be answered"
|
||||
)
|
||||
|
||||
normalized_answer_value = self._normalize_answer_value(command.answer_kind, command.answer_value, question)
|
||||
normalized_answer_value = self._normalize_answer_value(
|
||||
command.answer_kind, command.answer_value, question
|
||||
)
|
||||
|
||||
logger.reason(
|
||||
"Persisting clarification answer before state advancement",
|
||||
@@ -260,7 +294,9 @@ class ClarificationEngine:
|
||||
answer_kind=command.answer_kind,
|
||||
answer_value=normalized_answer_value,
|
||||
answered_by_user_id=command.user.id,
|
||||
impact_summary=self._build_impact_summary(question, command.answer_kind, normalized_answer_value),
|
||||
impact_summary=self._build_impact_summary(
|
||||
question, command.answer_kind, normalized_answer_value
|
||||
),
|
||||
)
|
||||
self.repository.db.add(persisted_answer)
|
||||
self.repository.db.flush()
|
||||
@@ -284,15 +320,25 @@ class ClarificationEngine:
|
||||
question.updated_at = datetime.utcnow()
|
||||
self.repository.db.flush()
|
||||
|
||||
clarification_session.resolved_count = self._count_resolved_questions(clarification_session)
|
||||
clarification_session.remaining_count = self._count_remaining_questions(clarification_session)
|
||||
clarification_session.summary_delta = self.summarize_progress(clarification_session)
|
||||
clarification_session.resolved_count = self._count_resolved_questions(
|
||||
clarification_session
|
||||
)
|
||||
clarification_session.remaining_count = self._count_remaining_questions(
|
||||
clarification_session
|
||||
)
|
||||
clarification_session.summary_delta = self.summarize_progress(
|
||||
clarification_session
|
||||
)
|
||||
clarification_session.updated_at = datetime.utcnow()
|
||||
|
||||
next_question = self._select_next_open_question(clarification_session)
|
||||
clarification_session.current_question_id = next_question.question_id if next_question else None
|
||||
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
|
||||
ClarificationStatus.ACTIVE
|
||||
if next_question
|
||||
else ClarificationStatus.COMPLETED
|
||||
)
|
||||
if clarification_session.status == ClarificationStatus.COMPLETED:
|
||||
clarification_session.completed_at = datetime.utcnow()
|
||||
@@ -304,7 +350,7 @@ class ClarificationEngine:
|
||||
if clarification_session.current_question_id
|
||||
else SessionPhase.REVIEW
|
||||
)
|
||||
session.last_activity_at = datetime.utcnow()
|
||||
self.repository.bump_session_version(session)
|
||||
|
||||
self.repository.db.commit()
|
||||
self.repository.db.refresh(session)
|
||||
@@ -326,6 +372,7 @@ class ClarificationEngine:
|
||||
session=session,
|
||||
changed_findings=[changed_finding] if changed_finding else [],
|
||||
)
|
||||
|
||||
# [/DEF:record_answer:Function]
|
||||
|
||||
# [DEF:summarize_progress:Function]
|
||||
@@ -336,6 +383,7 @@ class ClarificationEngine:
|
||||
resolved = self._count_resolved_questions(clarification_session)
|
||||
remaining = self._count_remaining_questions(clarification_session)
|
||||
return f"{resolved} resolved, {remaining} unresolved"
|
||||
|
||||
# [/DEF:summarize_progress:Function]
|
||||
|
||||
# [DEF:_get_latest_clarification_session:Function]
|
||||
@@ -353,6 +401,7 @@ class ClarificationEngine:
|
||||
reverse=True,
|
||||
)
|
||||
return ordered_sessions[0]
|
||||
|
||||
# [/DEF:_get_latest_clarification_session:Function]
|
||||
|
||||
# [DEF:_find_question:Function]
|
||||
@@ -367,6 +416,7 @@ class ClarificationEngine:
|
||||
if question.question_id == question_id:
|
||||
return question
|
||||
return None
|
||||
|
||||
# [/DEF:_find_question:Function]
|
||||
|
||||
# [DEF:_select_next_open_question:Function]
|
||||
@@ -377,35 +427,46 @@ class ClarificationEngine:
|
||||
clarification_session: ClarificationSession,
|
||||
) -> Optional[ClarificationQuestion]:
|
||||
open_questions = [
|
||||
question for question in clarification_session.questions
|
||||
question
|
||||
for question in clarification_session.questions
|
||||
if question.state == QuestionState.OPEN
|
||||
]
|
||||
if not open_questions:
|
||||
return None
|
||||
open_questions.sort(key=lambda item: (-int(item.priority), item.created_at, item.question_id))
|
||||
open_questions.sort(
|
||||
key=lambda item: (-int(item.priority), item.created_at, item.question_id)
|
||||
)
|
||||
return open_questions[0]
|
||||
|
||||
# [/DEF:_select_next_open_question:Function]
|
||||
|
||||
# [DEF:_count_resolved_questions:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Count questions whose answers fully resolved the ambiguity.
|
||||
def _count_resolved_questions(self, clarification_session: ClarificationSession) -> int:
|
||||
def _count_resolved_questions(
|
||||
self, clarification_session: ClarificationSession
|
||||
) -> int:
|
||||
return sum(
|
||||
1
|
||||
for question in clarification_session.questions
|
||||
if question.state == QuestionState.ANSWERED
|
||||
)
|
||||
|
||||
# [/DEF:_count_resolved_questions:Function]
|
||||
|
||||
# [DEF:_count_remaining_questions:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Count questions still unresolved or deferred after clarification interaction.
|
||||
def _count_remaining_questions(self, clarification_session: ClarificationSession) -> int:
|
||||
def _count_remaining_questions(
|
||||
self, clarification_session: ClarificationSession
|
||||
) -> int:
|
||||
return sum(
|
||||
1
|
||||
for question in clarification_session.questions
|
||||
if question.state in {QuestionState.OPEN, QuestionState.SKIPPED, QuestionState.EXPERT_REVIEW}
|
||||
if question.state
|
||||
in {QuestionState.OPEN, QuestionState.SKIPPED, QuestionState.EXPERT_REVIEW}
|
||||
)
|
||||
|
||||
# [/DEF:_count_remaining_questions:Function]
|
||||
|
||||
# [DEF:_normalize_answer_value:Function]
|
||||
@@ -417,18 +478,28 @@ class ClarificationEngine:
|
||||
answer_value: Optional[str],
|
||||
question: ClarificationQuestion,
|
||||
) -> Optional[str]:
|
||||
normalized_answer_value = str(answer_value).strip() if answer_value is not None else None
|
||||
if answer_kind in {AnswerKind.SELECTED, AnswerKind.CUSTOM} and not normalized_answer_value:
|
||||
raise ValueError("answer_value is required for selected or custom clarification answers")
|
||||
normalized_answer_value = (
|
||||
str(answer_value).strip() if answer_value is not None else None
|
||||
)
|
||||
if (
|
||||
answer_kind in {AnswerKind.SELECTED, AnswerKind.CUSTOM}
|
||||
and not normalized_answer_value
|
||||
):
|
||||
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_answer_value not in allowed_values:
|
||||
raise ValueError("answer_value must match one of the current clarification options")
|
||||
raise ValueError(
|
||||
"answer_value must match one of the current clarification options"
|
||||
)
|
||||
if answer_kind == AnswerKind.SKIPPED:
|
||||
return normalized_answer_value or "skipped"
|
||||
if answer_kind == AnswerKind.EXPERT_REVIEW:
|
||||
return normalized_answer_value or "expert_review"
|
||||
return normalized_answer_value
|
||||
|
||||
# [/DEF:_normalize_answer_value:Function]
|
||||
|
||||
# [DEF:_build_impact_summary:Function]
|
||||
@@ -445,6 +516,7 @@ class ClarificationEngine:
|
||||
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}'."
|
||||
|
||||
# [/DEF:_build_impact_summary:Function]
|
||||
|
||||
# [DEF:_upsert_clarification_finding:Function]
|
||||
@@ -461,8 +533,10 @@ class ClarificationEngine:
|
||||
caused_by_ref = f"clarification:{question.question_id}"
|
||||
existing = next(
|
||||
(
|
||||
finding for finding in session.findings
|
||||
if finding.area == FindingArea.CLARIFICATION and finding.caused_by_ref == caused_by_ref
|
||||
finding
|
||||
for finding in session.findings
|
||||
if finding.area == FindingArea.CLARIFICATION
|
||||
and finding.caused_by_ref == caused_by_ref
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -478,7 +552,9 @@ class ClarificationEngine:
|
||||
else:
|
||||
resolution_state = ResolutionState.EXPERT_REVIEW
|
||||
resolved_at = None
|
||||
message = f"Clarification for '{question.topic_ref}' requires expert review."
|
||||
message = (
|
||||
f"Clarification for '{question.topic_ref}' requires expert review."
|
||||
)
|
||||
|
||||
if existing is None:
|
||||
existing = ValidationFinding(
|
||||
@@ -513,6 +589,7 @@ class ClarificationEngine:
|
||||
existing.title = "Clarification requires expert review"
|
||||
|
||||
return existing
|
||||
|
||||
# [/DEF:_upsert_clarification_finding:Function]
|
||||
|
||||
# [DEF:_derive_readiness_state:Function]
|
||||
@@ -532,12 +609,15 @@ class ClarificationEngine:
|
||||
return ReadinessState.CLARIFICATION_NEEDED
|
||||
|
||||
return ReadinessState.REVIEW_READY
|
||||
|
||||
# [/DEF:_derive_readiness_state:Function]
|
||||
|
||||
# [DEF:_derive_recommended_action:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Recompute next-action guidance after clarification mutations.
|
||||
def _derive_recommended_action(self, session: DatasetReviewSession) -> RecommendedAction:
|
||||
def _derive_recommended_action(
|
||||
self, session: DatasetReviewSession
|
||||
) -> RecommendedAction:
|
||||
clarification_session = self._get_latest_clarification_session(session)
|
||||
if clarification_session is None:
|
||||
return session.recommended_action
|
||||
@@ -546,7 +626,10 @@ class ClarificationEngine:
|
||||
if clarification_session.remaining_count > 0:
|
||||
return RecommendedAction.START_CLARIFICATION
|
||||
return RecommendedAction.REVIEW_DOCUMENTATION
|
||||
|
||||
# [/DEF:_derive_recommended_action:Function]
|
||||
|
||||
|
||||
# [/DEF:ClarificationEngine:Class]
|
||||
|
||||
# [/DEF:ClarificationEngine:Module]
|
||||
# [/DEF:ClarificationEngine:Module]
|
||||
|
||||
Reference in New Issue
Block a user