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
35 changes: 33 additions & 2 deletions qa_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ def run(self) -> TestSession:

self.session.end_time = datetime.now()

# Post-run LLM summary (opt-in)
if self.config.generate_summary:
self._generate_summary()

# Generate reports
self._generate_reports()

Expand All @@ -186,6 +190,9 @@ def _generate_test_plan(self):
from .llm_client import LLMError
from .plan_cache import PlanCache

assert self.session is not None
assert self.config.instructions is not None

cache = PlanCache() if self.config.use_plan_cache else None
cache_key = PlanCache.make_key(self.config.instructions, self.config.urls) if cache else None

Expand Down Expand Up @@ -232,6 +239,7 @@ def _generate_test_plan(self):

def _apply_test_plan(self):
"""Print the test plan summary and enqueue any suggested URLs."""
assert self.test_plan is not None
self.console.print_progress(f"Test plan: {self.test_plan.summary}")
if self.test_plan.focus_areas:
self.console.print_progress(
Expand Down Expand Up @@ -259,6 +267,25 @@ def _apply_test_plan(self):
if self.test_plan.notes:
self.console.print_progress(f"Notes: {self.test_plan.notes}")

def _generate_summary(self):
"""Call the LLM post-run to produce a narrative summary of findings."""
from .summarizer import generate_summary

assert self.session is not None
self.console.print_progress("Generating results summary with AI...")
result = generate_summary(
session=self.session,
provider=self.config.llm_provider,
model=self.config.ai_model,
)
if result:
self.session.summary = result
self.console.print_progress("AI summary complete.")
else:
self.console.print_progress(
"Warning: AI summary generation failed — continuing without it."
)

def _factory(self):
"""Return the playwright context-manager factory (real or injected mock)."""
return self._playwright_factory if self._playwright_factory is not None else sync_playwright
Expand Down Expand Up @@ -426,6 +453,7 @@ def _run_explore_mode(self):

# Discover new links
if current_depth < self.config.max_depth:
assert self.page is not None
new_links = self._discover_links(self.page, url)
for link in new_links:
new_url = link['href']
Expand All @@ -444,6 +472,7 @@ def _run_concurrent(self):
is performed once on a bootstrap context and exported as a
``storage_state`` dict that seeds every worker context.
"""
assert self.session is not None
storage_state = self._bootstrap_auth()

if self.config.mode == TestMode.FOCUSED:
Expand Down Expand Up @@ -852,13 +881,14 @@ def _take_screenshot(self, page: Page, name: str) -> str | None:

def _cleanup(self):
"""Clean up browser resources."""
if self.config.recording.enabled and self.context:
if self.config.recording.enabled and self.context and self.page:
# Get video path
try:
video = self.page.video
if video:
video_path = video.path()
self.session.recording_path = video_path
assert self.session is not None
self.session.recording_path = str(video_path)
except Exception:
pass

Expand All @@ -869,6 +899,7 @@ def _cleanup(self):

def _generate_reports(self):
"""Generate all configured reports."""
assert self.session is not None
for reporter in self.reporters:
if isinstance(reporter, ConsoleReporter):
reporter.generate(self.session)
Expand Down
14 changes: 12 additions & 2 deletions qa_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def parse_auth_config(auth_str: str | None, auth_file: str | None) -> AuthConfig
"""Parse authentication configuration from string or file."""
if auth_file:
try:
with open(auth_file) as f:
with open(auth_file, encoding="utf-8") as f:
auth_data = json.load(f)
return AuthConfig(**auth_data)
except Exception as e:
Expand Down Expand Up @@ -309,6 +309,15 @@ def main():
action="store_true",
help="Bypass the test plan cache and always call the AI. Only valid with --instructions or --instructions-file.",
)
parser.add_argument(
"--summarize",
action="store_true",
help=(
"After the run, call the LLM once to produce a narrative summary: "
"executive summary, prioritised recommendations, root-cause clusters, "
"and likely false positives. Appended to Markdown and JSON reports."
),
)
args = parser.parse_args()

# Validate: --no-cache requires instructions
Expand Down Expand Up @@ -354,7 +363,7 @@ def main():
# Handle cookies file
if args.cookies:
try:
with open(args.cookies) as f:
with open(args.cookies, encoding="utf-8") as f:
cookies = json.load(f)
if auth_config:
auth_config.cookies = cookies
Expand Down Expand Up @@ -422,6 +431,7 @@ def main():
llm_provider=LLMProvider(args.llm_provider),
ai_model=args.ai_model or None,
use_plan_cache=not args.no_cache,
generate_summary=args.summarize,
workers=args.workers,
rate_limit=args.rate_limit,
invocation_context="cli",
Expand Down
5 changes: 4 additions & 1 deletion qa_agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class AuthConfig:
username_selector: str | None = None # Selector for username field
password_selector: str | None = None # Selector for password field
submit_selector: str | None = None # Selector for submit button
cookies: dict | None = None # Pre-set cookies for authentication
cookies: dict | list[dict] | None = None # Pre-set cookies for authentication
headers: dict | None = None # Custom headers (e.g., Bearer token)


Expand Down Expand Up @@ -101,6 +101,9 @@ class TestConfig:
ai_model: str | None = None # None → use the provider's default model
use_plan_cache: bool = True # Cache generated test plans to avoid redundant API calls

# Post-run LLM summary: narrative analysis of findings after the run completes
generate_summary: bool = False

# Invocation context — used to tailor diagnostic hints
invocation_context: Literal["cli", "web"] | None = None

Expand Down
36 changes: 36 additions & 0 deletions qa_agent/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,40 @@ def _normalize_url(url: str) -> str:
return normalised


@dataclass
class RootCauseCluster:
"""A cluster of related findings sharing a common root cause."""
label: str
finding_titles: list[str]
root_cause: str
suggested_fix: str


@dataclass
class SummaryResult:
"""LLM-generated narrative analysis produced after a test run."""
executive_summary: str
priority_recommendations: list[str] = field(default_factory=list)
root_cause_clusters: list[RootCauseCluster] = field(default_factory=list)
false_positive_candidates: list[str] = field(default_factory=list)

def to_dict(self) -> dict:
return {
"executive_summary": self.executive_summary,
"priority_recommendations": self.priority_recommendations,
"root_cause_clusters": [
{
"label": c.label,
"finding_titles": c.finding_titles,
"root_cause": c.root_cause,
"suggested_fix": c.suggested_fix,
}
for c in self.root_cause_clusters
],
"false_positive_candidates": self.false_positive_candidates,
}


@dataclass
class TestSession:
"""Complete test session results."""
Expand All @@ -190,6 +224,7 @@ class TestSession:
findings_by_severity: dict[str, int] = field(default_factory=dict)
findings_by_category: dict[str, int] = field(default_factory=dict)
recording_path: str | None = None
summary: "SummaryResult | None" = None

def add_page_analysis(self, page: PageAnalysis):
"""Add page analysis and update totals."""
Expand Down Expand Up @@ -272,4 +307,5 @@ def to_dict(self) -> dict:
"recording_path": self.recording_path,
"status": self.status,
"findings": [f.to_dict() for f in self.get_deduplicated_findings()],
"summary": self.summary.to_dict() if self.summary else None,
}
1 change: 1 addition & 0 deletions qa_agent/reporters/json_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def _build_report(self, session: "TestSession") -> dict:
for page in session.pages_tested
],
"findings": [finding.to_dict() for finding in session.get_deduplicated_findings()],
"ai_summary": session.summary.to_dict() if session.summary else None,
}

def get_json_string(self, session: "TestSession") -> str:
Expand Down
55 changes: 54 additions & 1 deletion qa_agent/reporters/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .base import BaseReporter

if TYPE_CHECKING:
from ..models import Finding, TestSession
from ..models import Finding, SummaryResult, TestSession


class MarkdownReporter(BaseReporter):
Expand Down Expand Up @@ -80,6 +80,10 @@ def _build_report(self, session: "TestSession") -> str:
lines.append(f"| {emoji} {cat_name} | {count} |")
lines.append("")

# AI summary section
if session.summary:
lines.extend(self._format_summary(session.summary))

# Test reliability warnings
plan_warnings = session.config_summary.get("plan_warnings", [])
if plan_warnings:
Expand Down Expand Up @@ -171,6 +175,55 @@ def _build_report(self, session: "TestSession") -> str:

return "\n".join(lines)

def _format_summary(self, summary: "SummaryResult") -> list[str]:
"""Format the AI summary block."""
lines: list[str] = []

lines.append("## AI Analysis")
lines.append("")
lines.append("> *Generated by LLM after the test run. Treat as advisory — verify before acting.*")
lines.append("")

lines.append("### Executive Summary")
lines.append("")
lines.append(summary.executive_summary)
lines.append("")

if summary.priority_recommendations:
lines.append("### Priority Recommendations")
lines.append("")
for rec in summary.priority_recommendations:
lines.append(f"1. {rec}")
lines.append("")

if summary.root_cause_clusters:
lines.append("### Root Cause Clusters")
lines.append("")
for cluster in summary.root_cause_clusters:
lines.append(f"#### {cluster.label}")
lines.append("")
lines.append(f"**Root cause:** {cluster.root_cause}")
lines.append("")
lines.append(f"**Suggested fix:** {cluster.suggested_fix}")
lines.append("")
lines.append("**Related findings:**")
for title in cluster.finding_titles:
lines.append(f"- {title}")
lines.append("")

if summary.false_positive_candidates:
lines.append("### Possible False Positives")
lines.append("")
lines.append(
"> These findings may be test artifacts rather than real bugs — review before filing."
)
lines.append("")
for candidate in summary.false_positive_candidates:
lines.append(f"- {candidate}")
lines.append("")

return lines

def _escape_html_tags(self, text: str) -> str:
"""Wrap HTML tags in backticks so they render correctly in markdown previews.

Expand Down
Loading
Loading