Files
Aegis/backend/app/services/procedure_suggestion_service.py
T
kitos 7ec3c5bc8b feat(tests): auto-propose procedure suggestions from submitted rounds, add lead review workflow
When Red or Blue submits a round for a test created from a template,
any extractable command in their procedure text is proposed as an
update to the template's suggested procedure — never written
automatically. Adds GET/approve/reject endpoints scoped so a lead only
reviews their own team's suggestions; approving writes the suggested
text into the template, rejecting leaves it untouched.
2026-07-14 15:22:03 +02:00

135 lines
5.2 KiB
Python

"""Procedure-improvement suggestion workflow.
When an operator submits a round with a filled-in procedure field, the
command(s) in it are extracted heuristically (see
``procedure_extraction_service``) and proposed as an update to the
originating template's suggested-procedure field. A suggestion is only
ever created when there's a real, novel improvement to propose — nothing
is written to the template until a lead reviews and approves it.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy.orm import Session
from app.domain.errors import EntityNotFoundError, InvalidOperationError
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.models.user import User
from app.services.notification_service import notify_role
from app.services.procedure_extraction_service import extract_commands
# Which Test field holds the operator's free-text procedure, and which
# TestTemplate field it's proposed as an improvement to, per team.
_SOURCE_FIELD = {"red": "procedure_text", "blue": "detect_procedure"}
_TEMPLATE_FIELD = {"red": "attack_procedure", "blue": "detect_suggested_procedure"}
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
def create_suggestion_from_submission(db: Session, test: Test, team: str) -> ProcedureSuggestion | None:
"""Propose a template procedure improvement from *test*'s just-submitted round.
Returns the created (unflushed-to-caller-commit) suggestion, or ``None``
when there's nothing worth proposing: the test wasn't created from a
template, no command could be extracted, the extraction matches what
the template already suggests, or an identical suggestion is already
pending review. Does not commit; caller uses UnitOfWork.
"""
if not test.source_template_id:
return None
procedure_text = getattr(test, _SOURCE_FIELD[team])
extracted = extract_commands(procedure_text)
if not extracted:
return None
template = db.query(TestTemplate).filter(TestTemplate.id == test.source_template_id).first()
if template is None:
return None
current = (getattr(template, _TEMPLATE_FIELD[team]) or "").strip()
if extracted == current:
return None
duplicate = (
db.query(ProcedureSuggestion)
.filter(
ProcedureSuggestion.template_id == template.id,
ProcedureSuggestion.team == team,
ProcedureSuggestion.suggested_text == extracted,
ProcedureSuggestion.status == "pending",
)
.first()
)
if duplicate is not None:
return None
submitter_id = test.red_tech_assignee if team == "red" else test.blue_tech_assignee
suggestion = ProcedureSuggestion(
template_id=template.id,
team=team,
suggested_text=extracted,
source_test_id=test.id,
submitted_by=submitter_id,
)
db.add(suggestion)
db.flush()
notify_role(
db,
role=_LEAD_ROLE[team],
type="procedure_suggestion",
title="New procedure suggestion for review",
message=f'A {team} procedure improvement was proposed for template "{template.name}".',
entity_type="procedure_suggestion",
entity_id=suggestion.id,
)
return suggestion
def list_pending_suggestions(db: Session, *, team: str | None = None) -> list[ProcedureSuggestion]:
"""List pending suggestions, optionally filtered to one team."""
query = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.status == "pending")
if team is not None:
query = query.filter(ProcedureSuggestion.team == team)
return query.order_by(ProcedureSuggestion.created_at.asc()).all()
def _get_pending_suggestion_or_raise(db: Session, suggestion_id: uuid.UUID) -> ProcedureSuggestion:
suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first()
if suggestion is None:
raise EntityNotFoundError("ProcedureSuggestion", str(suggestion_id))
if suggestion.status != "pending":
raise InvalidOperationError("This suggestion has already been reviewed.")
return suggestion
def approve_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> ProcedureSuggestion:
"""Approve a suggestion: write it into the template and mark reviewed. Does not commit."""
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first()
if template is None:
raise EntityNotFoundError("TestTemplate", str(suggestion.template_id))
setattr(template, _TEMPLATE_FIELD[suggestion.team], suggestion.suggested_text)
suggestion.status = "approved"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()
db.flush()
return suggestion
def reject_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> ProcedureSuggestion:
"""Reject a suggestion without touching the template. Does not commit."""
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
suggestion.status = "rejected"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()
db.flush()
return suggestion