fix(tests): merge approved procedure suggestions instead of overwriting
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

Approving a suggestion was replacing the template's whole procedure
field with just that submission's extracted text, silently dropping
whatever an earlier approved round had already added. Now it appends
only the genuinely new lines, so templates accumulate commands across
rounds instead of losing them.
This commit is contained in:
kitos
2026-07-14 16:25:24 +02:00
parent 4692a8b93e
commit 8fcd733d4a
2 changed files with 87 additions and 5 deletions
@@ -5,7 +5,9 @@ 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.
is written to the template until a lead reviews and approves it, and
approving one only ever appends new commands, never overwrites or drops
whatever earlier approved rounds already contributed.
"""
from __future__ import annotations
@@ -30,6 +32,43 @@ _TEMPLATE_FIELD = {"red": "attack_procedure", "blue": "detect_suggested_procedur
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
def _new_lines(current: str | None, suggested: str) -> list[str]:
"""Lines in *suggested* not already present verbatim in *current*.
Line-level, not whole-text: a later round's extraction is usually a
different subset/superset of commands than an earlier one, so an exact
string comparison would treat almost every resubmission as "novel" and
then, on approval, blow away whatever an earlier approved round already
contributed.
"""
existing = {line.strip() for line in (current or "").splitlines() if line.strip()}
seen: set[str] = set()
new: list[str] = []
for line in suggested.splitlines():
stripped = line.strip()
if not stripped or stripped in existing or stripped in seen:
continue
new.append(stripped)
seen.add(stripped)
return new
def _merge_procedure_text(current: str | None, suggested: str) -> str:
"""Append genuinely new lines from *suggested* onto *current*.
Approving a suggestion must only ever grow a template's procedure,
never replace it — otherwise a narrower extraction from a later round
would silently erase commands an earlier approved round already added.
"""
current_stripped = (current or "").strip()
new = _new_lines(current, suggested)
if not new:
return current_stripped
if not current_stripped:
return "\n".join(new)
return current_stripped + "\n" + "\n".join(new)
def create_suggestion_from_submission(db: Session, test: Test, team: str) -> ProcedureSuggestion | None:
"""Propose a template procedure improvement from *test*'s just-submitted round.
@@ -51,8 +90,8 @@ def create_suggestion_from_submission(db: Session, test: Test, team: str) -> Pro
if template is None:
return None
current = (getattr(template, _TEMPLATE_FIELD[team]) or "").strip()
if extracted == current:
current = getattr(template, _TEMPLATE_FIELD[team])
if not _new_lines(current, extracted):
return None
duplicate = (
@@ -116,7 +155,8 @@ def approve_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> Pro
if template is None:
raise EntityNotFoundError("TestTemplate", str(suggestion.template_id))
setattr(template, _TEMPLATE_FIELD[suggestion.team], suggestion.suggested_text)
current_text = getattr(template, _TEMPLATE_FIELD[suggestion.team])
setattr(template, _TEMPLATE_FIELD[suggestion.team], _merge_procedure_text(current_text, suggestion.suggested_text))
suggestion.status = "approved"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()