Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 48 additions & 19 deletions src/agents/compliance_tagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import os
import re
from dataclasses import dataclass
from typing import Literal
from typing import Any, Literal

try:
import anthropic
Expand Down Expand Up @@ -199,27 +199,54 @@ def _tag_fintech(text: str, pattern: str = "", severity_priority: str = "medium"
# ---------------------------------------------------------------------------

_SYSTEM_PROMPT_MEDTECH = """You are a medical device software compliance specialist (IEC 62304).
Analyse this bug report and return JSON with these keys:
Analyse this bug report and return these fields:
- iec_62304_class: "A" | "B" | "C" (A=no injury, B=non-serious injury, C=serious injury/death)
- soup_impact: bool (does this affect a Software Of Unknown Provenance component?)
- traceability_tag: str (requirement ID or "SRS-GEN-UNTRACED")
- change_control_required: bool
- rationale: str (one sentence, max 200 chars)

Return ONLY the JSON object."""
- soup_impact: does this affect a Software Of Unknown Provenance component?
- traceability_tag: requirement ID or "SRS-GEN-UNTRACED"
- change_control_required
- rationale: one sentence, max 200 chars"""

_SYSTEM_PROMPT_FINTECH = """You are a PSD2/DORA compliance specialist.
Analyse this bug report and return JSON with these keys:
- psd2_article: str | null (most relevant PSD2 article, e.g. "Art.97 — Strong Customer Authentication")
Analyse this bug report and return these fields:
- psd2_article: most relevant PSD2 article (e.g. "Art.97 — Strong Customer Authentication"),
or null when no article applies
- dora_risk_level: "low" | "medium" | "high"
- aml_flag: bool (potential Anti-Money Laundering relevance)
- incident_reporting_required: bool
- rationale: str (one sentence, max 200 chars)
- aml_flag: potential Anti-Money Laundering relevance
- incident_reporting_required
- rationale: one sentence, max 200 chars"""


def _strict_object(properties: dict[str, Any]) -> dict[str, Any]:
return {
"type": "object",
"properties": properties,
"required": list(properties),
"additionalProperties": False,
}


Return ONLY the JSON object."""
_SCHEMA_MEDTECH = _strict_object(
{
"iec_62304_class": {"type": "string", "enum": ["A", "B", "C"]},
"soup_impact": {"type": "boolean"},
"traceability_tag": {"type": "string"},
"change_control_required": {"type": "boolean"},
"rationale": {"type": "string"},
}
)

_SCHEMA_FINTECH = _strict_object(
{
"psd2_article": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"dora_risk_level": {"type": "string", "enum": ["low", "medium", "high"]},
"aml_flag": {"type": "boolean"},
"incident_reporting_required": {"type": "boolean"},
"rationale": {"type": "string"},
}
)


def _claude_tag(system: str, text: str, api_key: str) -> dict:
def _claude_tag(system: str, schema: dict[str, Any], text: str, api_key: str) -> dict:
if not _ANTHROPIC_AVAILABLE:
raise RuntimeError("anthropic package not installed")
client = anthropic.Anthropic(api_key=api_key)
Expand All @@ -228,10 +255,12 @@ def _claude_tag(system: str, text: str, api_key: str) -> dict:
max_tokens=512,
system=system,
messages=[{"role": "user", "content": text}],
output_config={"format": {"type": "json_schema", "schema": schema}},
)
if msg.stop_reason != "end_turn":
raise RuntimeError(f"LLM stopped early (stop_reason={msg.stop_reason})")
raw = "".join(b.text for b in msg.content if hasattr(b, "text"))
clean = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip(), flags=re.MULTILINE)
return json.loads(clean)
return json.loads(raw)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -286,7 +315,7 @@ def _claude_tag_dispatch(
) -> ComplianceResult:
assert self._api_key
if sector == "medtech":
data = _claude_tag(_SYSTEM_PROMPT_MEDTECH, text, self._api_key)
data = _claude_tag(_SYSTEM_PROMPT_MEDTECH, _SCHEMA_MEDTECH, text, self._api_key)
mt = MedtechTag(
iec_62304_class=data.get("iec_62304_class", "A"),
soup_impact=bool(data.get("soup_impact", False)),
Expand All @@ -296,7 +325,7 @@ def _claude_tag_dispatch(
)
return ComplianceResult(sector="medtech", medtech=mt, tagged_by="claude-sonnet-4-6")

data = _claude_tag(_SYSTEM_PROMPT_FINTECH, text, self._api_key)
data = _claude_tag(_SYSTEM_PROMPT_FINTECH, _SCHEMA_FINTECH, text, self._api_key)
ft = FintechTag(
psd2_article=data.get("psd2_article"),
dora_risk_level=data.get("dora_risk_level", "low"),
Expand Down
61 changes: 47 additions & 14 deletions src/agents/report_enricher.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,48 @@ def enrich_rule_based(
# ---------------------------------------------------------------------------

_SYSTEM_PROMPT = """You are a senior QA engineer. Given a raw bug report, produce a
structured enrichment as valid JSON with exactly these keys:
- title: str (concise, max 120 chars)
- summary: str (professional one-liner, max 300 chars)
- reproduction_steps: list[str] (3-5 steps in Given/When/Then format)
- environment: dict[str,str] (inferred os, browser, version, environment)
- expected_result: str (what should happen, max 300 chars)
- actual_result: str (what actually happened, max 300 chars)
- confidence_score: float (0.0-1.0, your confidence in the enrichment)

Return ONLY the JSON object. No markdown, no explanation."""
structured enrichment with these fields:
- title: concise, max 120 chars
- summary: professional one-liner, max 300 chars
- reproduction_steps: 3-5 steps in Given/When/Then format
- environment: key/value pairs for what the report lets you infer (os, browser,
version, environment); leave out what it does not mention
- expected_result: what should happen, max 300 chars
- actual_result: what actually happened, max 300 chars
- confidence_score: 0.0-1.0, your confidence in the enrichment"""

# Structured outputs reject free-form maps (additionalProperties must be
# false): environment arrives as a list of key/value pairs, rebuilt as a dict.
_RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string"},
"summary": {"type": "string"},
"reproduction_steps": {"type": "array", "items": {"type": "string"}},
"environment": {
"type": "array",
"items": {
"type": "object",
"properties": {"key": {"type": "string"}, "value": {"type": "string"}},
"required": ["key", "value"],
"additionalProperties": False,
},
},
"expected_result": {"type": "string"},
"actual_result": {"type": "string"},
"confidence_score": {"type": "number"},
},
"required": [
"title",
"summary",
"reproduction_steps",
"environment",
"expected_result",
"actual_result",
"confidence_score",
],
"additionalProperties": False,
}


def enrich_with_claude(
Expand All @@ -188,22 +220,23 @@ def enrich_with_claude(
max_tokens=1024,
system=_SYSTEM_PROMPT,
messages=[{"role": "user", "content": context}],
output_config={"format": {"type": "json_schema", "schema": _RESPONSE_SCHEMA}},
)
if message.stop_reason != "end_turn":
raise RuntimeError(f"LLM stopped early (stop_reason={message.stop_reason})")

raw_text = ""
for block in message.content:
if hasattr(block, "text"):
raw_text += block.text

# Strip possible markdown fences
clean = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw_text.strip(), flags=re.MULTILINE)
data = json.loads(clean)
data = json.loads(raw_text)

return EnrichmentResult(
title=str(data.get("title", title or ""))[:200],
summary=str(data.get("summary", ""))[:400],
reproduction_steps=list(data.get("reproduction_steps", [])),
environment=dict(data.get("environment", {})),
environment={p["key"]: p["value"] for p in data.get("environment", [])},
expected_result=str(data.get("expected_result", ""))[:400],
actual_result=str(data.get("actual_result", ""))[:400],
enriched_by="claude-sonnet-4-6",
Expand Down
Loading