@cryptotaxi247 / CoPilot / commits / 132ade01

fix(ai-analyst): reject report submissions missing body fields (#902)

* fix(ai-analyst): reject report submissions missing body fields SubmitReportRequest accepted reports with severity_assessment, summary, report_markdown, and recommended_actions omitted or blank, persisting a row with NULL bodies that surfaces as an empty report in CoPilot. This happens in production when a long-lived agent session compacts and the report schema drops out of context, so the agent calls the tool with only job_id/alert_id/customer_code. Add a model_validator that rejects the submission (422) when any body field is missing or whitespace-only. Runs after the existing control-character stripping, so all-control-char values are caught too. Adds tests/ (first tests in backend) covering complete, missing, blank, control-char-only, and identifiers-only submissions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * precommit-fixes --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

taylor_socfortress committed Jun 3, 2026 at 12:48 UTC 132ade010bba3dac628b66a6f261d87d6dbd8fd2
2 files changed +81
backend/app/ai_analyst/schema/ai_analyst.py
+23
@@ -7,6 +7,7 @@ from typing import Optional
7 from pydantic import BaseModel
8 from pydantic import Field
9 from pydantic import field_validator
10 +from pydantic import model_validator
11
12 # --- Enums ---
13
@@ -117,6 +118,28 @@ class SubmitReportRequest(BaseModel):
118 return v
119 return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", v)
120
121 + @model_validator(mode="after")
122 + def require_report_body(self):
123 + """Reject reports missing any human-readable body field.
124 +
125 + The agent populates these from CLAUDE.md context; when a long-lived
126 + session compacts, the field list can drop out and the agent calls this
127 + with only job_id/alert_id/customer_code. Without this guard the row
128 + persists with NULL bodies and surfaces as an empty report in CoPilot.
129 + Runs after strip_control_characters, so all-control-char values that
130 + collapse to "" are caught here too.
131 + """
132 + missing = [
133 + name
134 + for name in ("severity_assessment", "summary", "report_markdown", "recommended_actions")
135 + if not (getattr(self, name) and str(getattr(self, name)).strip())
136 + ]
137 + if missing:
138 + raise ValueError(
139 + f"Report body fields must be non-empty: {', '.join(missing)}. " "A report missing these persists a blank row in CoPilot.",
140 + )
141 + return self
142 +
143
144 class SubmitIocRequest(BaseModel):
145 ioc_value: str = Field(..., max_length=512, description="The IOC value")
backend/tests/test_submit_report_validation.py new
+58
@@ -0,0 +1,58 @@
1 +"""Tests for SubmitReportRequest body-completeness validation.
2 +
3 +A report submitted without its human-readable body fields must be rejected, not
4 +silently persisted as a blank row. These are pure Pydantic-model tests — no DB
5 +or app wiring required.
6 +
7 +Run with: cd backend && pip install pytest && python -m pytest tests/
8 +"""
9 +
10 +import pytest
11 +from pydantic import ValidationError
12 +
13 +from app.ai_analyst.schema.ai_analyst import SubmitReportRequest
14 +
15 +COMPLETE = dict(
16 + job_id="copilot-inv-1-abc",
17 + alert_id=1,
18 + customer_code="acme",
19 + severity_assessment="High",
20 + summary="Short tl;dr.",
21 + report_markdown="# Full report\n\nDetails.",
22 + recommended_actions="Isolate host.",
23 +)
24 +
25 +
26 +def test_complete_report_is_accepted():
27 + req = SubmitReportRequest(**COMPLETE)
28 + assert req.report_markdown == "# Full report\n\nDetails."
29 +
30 +
31 +@pytest.mark.parametrize("field", ["severity_assessment", "summary", "report_markdown", "recommended_actions"])
32 +def test_missing_body_field_is_rejected(field):
33 + payload = {k: v for k, v in COMPLETE.items() if k != field}
34 + with pytest.raises(ValidationError) as excinfo:
35 + SubmitReportRequest(**payload)
36 + assert field in str(excinfo.value)
37 +
38 +
39 +@pytest.mark.parametrize("field", ["summary", "report_markdown", "recommended_actions"])
40 +def test_blank_or_whitespace_body_field_is_rejected(field):
41 + payload = {**COMPLETE, field: " "}
42 + with pytest.raises(ValidationError) as excinfo:
43 + SubmitReportRequest(**payload)
44 + assert field in str(excinfo.value)
45 +
46 +
47 +def test_control_chars_only_body_is_rejected():
48 + # strip_control_characters collapses this to "" before require_report_body runs.
49 + payload = {**COMPLETE, "report_markdown": "\x00\x01\x02"}
50 + with pytest.raises(ValidationError) as excinfo:
51 + SubmitReportRequest(**payload)
52 + assert "report_markdown" in str(excinfo.value)
53 +
54 +
55 +def test_minimal_call_missing_all_bodies_is_rejected():
56 + # The exact failure mode from production: agent sends only the identifiers.
57 + with pytest.raises(ValidationError):
58 + SubmitReportRequest(job_id="j", alert_id=1, customer_code="acme")