121 lines
7.4 KiB
Python
121 lines
7.4 KiB
Python
"""Question lifecycle sharing the operational task transaction and notification bus."""
|
||
from datetime import datetime
|
||
from typing import TYPE_CHECKING
|
||
from uuid import UUID, uuid4
|
||
|
||
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
|
||
from app.domain.domain_errors import require
|
||
from app.domain.schemas.operational_task import OperationalTaskDetail, OperationalTaskSummary
|
||
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
|
||
|
||
if TYPE_CHECKING:
|
||
from app.services.operational_tasks import OperationalTaskService
|
||
|
||
|
||
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
|
||
# Legacy test repositories do not implement question persistence.
|
||
reader = getattr(service.repo, "list_questions", None)
|
||
return await reader(str(task_id)) if reader else []
|
||
|
||
|
||
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
|
||
for raw in await list_questions(service, task_id):
|
||
question = TaskQuestionRequest.model_validate(raw)
|
||
if question.status != "pending":
|
||
continue
|
||
question = question.model_copy(update={"status": "cancelled", "version": question.version + 1})
|
||
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||
await service._resolve_notifications(task_id, kinds=["human_input_required"],
|
||
subject_type="question", subject_id=question.question_request_id,
|
||
now=now, reason="question_superseded")
|
||
|
||
|
||
async def create_question(service: "OperationalTaskService", task_id: UUID,
|
||
command: TaskQuestionCreate) -> TaskQuestionRequest:
|
||
require(OPERATIONAL_TASK_EXECUTE in service.permissions, "PERMISSION_DENIED",
|
||
"Current user cannot publish task questions", status=403)
|
||
body = command.model_dump(mode="json")
|
||
scope = f"task:{task_id}:question:create"
|
||
async with service.repo.transaction():
|
||
await service.repo.lock(f"task:{task_id}")
|
||
replay = await service._request_replay(scope, command.request_id, body)
|
||
if replay:
|
||
return TaskQuestionRequest.model_validate(await service.repo.get_question(
|
||
str(task_id), replay["question_request_id"]))
|
||
task = await service._task(task_id)
|
||
service._require_actionable_task(task)
|
||
require(task["task_type"] == "event_investigation", "INVALID_TASK_TYPE",
|
||
"Questions require an investigation task", status=409)
|
||
await service.repo.lock(f"run:{command.run_id}")
|
||
run = await service._run(task_id, command.run_id)
|
||
require(run["status"] in {"claimed", "running"}
|
||
and run["generation"] == task["execution_generation"]
|
||
and run.get("agent_session_id") == command.source_session_id,
|
||
"SESSION_MISMATCH", "Question must belong to the active investigation run", status=409)
|
||
now = service._now()
|
||
service._require_active_run_lease(run, now)
|
||
await service._require_no_pending_calculations(task_id, run)
|
||
require(not any(q["status"] == "pending" for q in await list_questions(service, task_id)),
|
||
"QUESTION_PENDING", "Answer the existing questions first", status=409)
|
||
question = TaskQuestionRequest(question_request_id=uuid4(), task_id=task_id,
|
||
run_id=command.run_id, execution_generation=run["generation"],
|
||
source_session_id=command.source_session_id, questions=command.questions,
|
||
status="pending", version=1, created_at=now)
|
||
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||
task.update(revision=task["revision"] + 1, updated_at=now)
|
||
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
|
||
await service._task_event(task, now)
|
||
await service._notify(task_id, "human_input_required", task["title"],
|
||
";".join(q.title for q in question.questions), now,
|
||
required_permission=OPERATIONAL_TASK_WRITE, subject_type="question",
|
||
subject_id=question.question_request_id)
|
||
await service._save_request(scope, command.request_id, body,
|
||
{"question_request_id": str(question.question_request_id)})
|
||
return question
|
||
|
||
|
||
async def answer_question(service: "OperationalTaskService", task_id: UUID, question_id: UUID,
|
||
command: TaskQuestionReply) -> OperationalTaskDetail:
|
||
require(OPERATIONAL_TASK_WRITE in service.permissions, "PERMISSION_DENIED",
|
||
"Current user cannot answer task questions", status=403)
|
||
body = command.model_dump(mode="json")
|
||
scope = f"task:{task_id}:question:{question_id}:answer"
|
||
async with service.repo.transaction():
|
||
await service.repo.lock(f"task:{task_id}")
|
||
if await service._request_replay(scope, command.request_id, body):
|
||
return await service.detail(task_id)
|
||
task = await service._task(task_id)
|
||
service._require_actionable_task(task)
|
||
raw = await service.repo.get_question(str(task_id), str(question_id))
|
||
require(raw is not None, "QUESTION_NOT_FOUND", "Question not found", status=404)
|
||
question = TaskQuestionRequest.model_validate(raw)
|
||
require(question.status == "pending" and question.version == command.expected_version
|
||
and question.execution_generation == task["execution_generation"],
|
||
"VERSION_CONFLICT", "Question is no longer pending in the current generation", status=409)
|
||
require(task["lifecycle_status"] == "waiting_human"
|
||
and await service.repo.get_active_run(str(task_id)) is None,
|
||
"RUN_NOT_SETTLED", "Wait until the investigation has entered waiting_human", status=409)
|
||
answers = {a.question_id: a for a in command.answers}
|
||
known = {q.question_id for q in question.questions}
|
||
require(len(answers) == len(command.answers) and set(answers) <= known,
|
||
"ANSWER_INVALID", "Unknown or duplicate questions", status=422)
|
||
for q in question.questions:
|
||
answer = answers.get(q.question_id)
|
||
selected = answer.selected_option_ids if answer else []
|
||
text = answer.custom_text.strip() if answer else ""
|
||
require(len(set(selected)) == len(selected) and set(selected) <= {o.id for o in q.options}
|
||
and (q.mode == "multiple" or len(selected) <= 1)
|
||
and (q.allow_custom or not text) and (not q.required or bool(selected or text)),
|
||
"ANSWER_INVALID", "Answer does not satisfy the question", status=422)
|
||
now = service._now()
|
||
question = question.model_copy(update={"status": "answered", "version": question.version + 1,
|
||
"answers": command.answers, "answered_at": now, "answered_by": service._actor()})
|
||
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||
await service._resolve_notifications(task_id, kinds=["human_input_required"],
|
||
subject_type="question", subject_id=question_id, now=now, reason="question_answered")
|
||
await service._requeue_task(task, now)
|
||
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
|
||
await service._task_event(task, now)
|
||
await service._save_request(scope, command.request_id, body, {"task_id": str(task_id)})
|
||
return await service.detail(task_id)
|