Files
Conflict_Checker/backend/agents/runner.py
T
John Wilganowski 1c1d2ff21b
Docker Release / build-and-push (push) Successful in 1m10s
Docker Release / release (push) Skipped
Add required human review gate to the Agent pipeline.
Agent web jobs now stop after Brain consolidation and enter needs_review
with a persisted review queue (blocking: high-severity, low-confidence,
sensitive-category findings; audit sample of clean clusters). Humans
decide confirm/reject/unsure/needs_clarification via new review API and
frontend queue; a finalizer applies decisions (rejections suppressed with
reason codes), performs bounded targeted reruns for clarifications,
drafts RFIs only for kept issues, and only then marks the job done and
sends the final email. Two-phase email (review-required, then final
report), per-decision feedback labels with redacted aggregate metrics,
restart recovery from job artifacts, and CLI --no-review bypass.
Classic pipeline unchanged. 65 non-LLM tests.
2026-07-28 19:23:57 +00:00

380 lines
14 KiB
Python

"""Public entry point for the scoped Agent-mode pipeline."""
import json
import os
from typing import Callable, Dict, Optional
from backend import config
from backend.agents.base import AgentScope, AgentUsage
from backend.agents.brain import BrainAgent
from backend.agents.code_agent import CodeAgent, build_code_scopes
from backend.agents.completeness import CompletenessAgent, build_sheet_summaries
from backend.agents.conflict_critic import ConflictCriticAgent
from backend.agents.construct_agent import ConstructabilityAgent, build_construct_scopes
from backend.agents.extractors import (
JurisdictionAgent,
SheetExtractorAgent,
SheetIndexAgent,
)
from backend.agents.linker import LinkerAgent, build_link_scopes, build_object_graph
from backend.agents.memory import ProjectMemory
from backend.agents.orchestrator import Orchestrator
from backend.agents.rfi_writer import RFIWriterAgent
from backend.pipeline.pdf_processor import convert_pdf_to_images
from backend.pipeline.report import build_report, to_markdown
from backend.pipeline.sheet_index import derive_project_meta_from_cover
from backend.review.gate import build_review_queue
from backend.review.store import ReviewStore
def run_agent_pipeline(
pdf_path: str,
out_dir: Optional[str] = None,
on_stage: Optional[Callable[[str], None]] = None,
project_input: Optional[Dict] = None,
source_name: Optional[str] = None,
require_review: bool = True,
) -> Dict:
"""Run all scoped specialist waves and return a Classic-compatible report."""
if not os.path.isfile(pdf_path):
raise FileNotFoundError(pdf_path)
agent_dir = os.path.join(out_dir, "agent") if out_dir else None
memory = ProjectMemory(artifact_dir=agent_dir)
orchestrator = Orchestrator(memory=memory, on_stage=on_stage)
usage = AgentUsage()
orchestrator.initialize()
orchestrator.stage("Agent ingest: PDF -> images")
pages = convert_pdf_to_images(pdf_path)
page_to_b64 = {page["page_number"]: page["base64"] for page in pages}
orchestrator.stage("Agent wave 1: extract sheets")
extract_scopes = [
AgentScope(
scope_id=f"sheet:{page['page_number']}",
payload={"page": page},
)
for page in pages
]
extract_results = orchestrator.run_scopes(
SheetExtractorAgent(usage), extract_scopes, config.EXTRACT_CONCURRENCY
)
sheets = [
artifact
for result in extract_results
for artifact in result.artifacts
]
sheets.sort(key=lambda sheet: sheet.get("page_number") or 0)
memory.replace("sheets", sheets)
memory.dump("01-extract.json")
cover_meta = derive_project_meta_from_cover(
sheets, source_name or os.path.basename(pdf_path)
)
merged_input = {**cover_meta, **(project_input or {})}
orchestrator.stage("Agent wave 2: sheet index and jurisdiction")
index_results = orchestrator.run_scopes(
SheetIndexAgent(usage),
[AgentScope("sheet-index", {"sheets": sheets})],
1,
)
jurisdiction_results = orchestrator.run_scopes(
JurisdictionAgent(usage),
[AgentScope("jurisdiction", {"project_input": merged_input})],
1,
)
sheet_index = (
index_results[0].artifacts[0]
if index_results and index_results[0].artifacts else {}
)
jurisdiction = (
jurisdiction_results[0].artifacts[0]
if jurisdiction_results and jurisdiction_results[0].artifacts else {}
)
memory.replace("sheet_index", sheet_index)
memory.replace("jurisdiction", jurisdiction)
memory.dump("02-orient.json")
orchestrator.stage("Agent wave 3: scoped semantic linking")
link_results = orchestrator.run_scopes(
LinkerAgent(usage),
build_link_scopes(sheets),
config.AGENT_LINK_CONCURRENCY,
)
clusters = [
artifact
for result in link_results
for artifact in result.artifacts
][:config.CLUSTER_MAX]
object_graph = build_object_graph(clusters)
memory.replace("clusters", clusters)
memory.replace("object_graph", object_graph)
memory.dump("03-link.json")
orchestrator.stage("Agent wave 4: per-cluster conflict critics")
conflict_scopes = [
AgentScope(
scope_id=f"conflict:{cluster.get('key')}",
payload={"cluster": cluster, "page_to_b64": page_to_b64},
)
for cluster in clusters
]
conflict_results = orchestrator.run_scopes(
ConflictCriticAgent(usage),
conflict_scopes,
config.AGENT_CONFLICT_CONCURRENCY,
)
conflict_findings = [
artifact
for result in conflict_results
for artifact in result.artifacts
]
memory.extend("findings", conflict_findings)
orchestrator.stage("Agent wave 5: scoped specialists")
code_results = orchestrator.run_scopes(
CodeAgent(usage),
build_code_scopes(sheets, jurisdiction, sheet_index),
config.AGENT_SPECIALIST_CONCURRENCY,
)
construct_results = orchestrator.run_scopes(
ConstructabilityAgent(usage),
build_construct_scopes(clusters, conflict_findings),
config.AGENT_SPECIALIST_CONCURRENCY,
)
completeness_scope = AgentScope("completeness", {
"sheet_index": sheet_index,
"sheet_summaries": build_sheet_summaries(sheets),
"cluster_summary": {
"count": len(clusters),
"by_kind": _counts(clusters, "kind"),
},
})
completeness_results = orchestrator.run_scopes(
CompletenessAgent(usage), [completeness_scope], 1
)
specialist_findings = [
artifact
for result in code_results + construct_results + completeness_results
for artifact in result.artifacts
]
memory.extend("findings", specialist_findings)
gap_findings = [
{
"issue_id": f"AGENT-GAP-{index + 1:03d}",
"source_stage": "qaqc",
"category": "analysis_gap",
"severity": "low",
"confidence": "high",
"location": failed_scope.split(":", 2)[1] if ":" in failed_scope else "",
"disciplines": [],
"sheets": [],
"description": f"Agent analysis scope did not complete: {failed_scope}",
"evidence": [],
"recommended_resolution": "Review this scope manually or rerun the job.",
"code_reference": None,
"agent": "completeness",
"scope_id": "failed-scopes",
}
for index, failed_scope in enumerate(orchestrator.stats.failed_scopes)
]
memory.extend("findings", gap_findings)
memory.dump("05-specialists.json")
orchestrator.stage("Agent wave 6: Brain merge, judge, prioritize")
all_findings = memory.snapshot()["findings"]
if all_findings:
prioritized, decisions = BrainAgent(usage).run(
all_findings, sheet_index, jurisdiction
)
else:
prioritized, decisions = [], []
memory.extend("decisions", decisions)
orchestrator.stats.merges = sum(
1 for decision in decisions if decision.get("action") == "merged"
)
if require_review:
orchestrator.stage("Agent review gate: build human-review queue")
memory_snapshot = memory.snapshot()
queue = build_review_queue(memory_snapshot, prioritized, decisions,
limit=config.AGENT_REVIEW_AUDIT_SAMPLE)
store = ReviewStore(out_dir)
store.write_queue(queue)
candidate_conflicts = [_finding_as_conflict(item) for item in conflict_findings]
report = build_report(
conflicts=candidate_conflicts,
sheets=sheets,
clusters=clusters,
source=source_name or os.path.basename(pdf_path),
)
report.update({
"project_input": merged_input,
"jurisdiction": jurisdiction,
"sheet_index": sheet_index,
"project_intelligence": object_graph,
"validated_issues": prioritized,
"rfis": [],
"suppressed_issues": [],
})
progress = store.progress(queue)
# Same usage/stats summary block as the wave-7 path (rfis: 0 — they
# are drafted only after human review finalizes the run).
cost = usage.snapshot()
orchestrator.stats.calls = cost["calls"]
stats = orchestrator.stats.as_dict()
report["summary"].update({
"pipeline_mode": "agent",
"agent_status": "needs_review",
"review": progress,
"agent_stats": stats,
"by_stage": {
"conflicts": len(conflict_findings),
"qaqc": sum(
1 for item in specialist_findings
if item.get("source_stage") == "qaqc"
),
"code": sum(
1 for item in specialist_findings
if item.get("source_stage") == "code"
),
"constructability": sum(
1 for item in specialist_findings
if item.get("source_stage") == "constructability"
),
"validated": len(prioritized),
"rfis": 0,
},
"cost_usd": round(cost["usd"], 4),
"llm_calls": cost["calls"],
"cached_calls": cost["cached"],
"cost_by_stage": cost["by_stage"],
"text_backend": "openrouter",
"models_used": cost["models"],
})
if out_dir:
_dump(out_dir, "conflicts.json", report)
_dump(out_dir, "validated_issues.json", prioritized)
# Snapshot for the review finalizer's targeted clarification reruns.
memory.dump("memory.json")
return report
orchestrator.stage("Agent wave 7: per-finding RFI writers")
rfi_scopes = [
AgentScope(
scope_id=f"rfi:{finding.get('issue_id') or index + 1}",
payload={"finding": finding},
)
for index, finding in enumerate(prioritized)
]
rfi_results = orchestrator.run_scopes(
RFIWriterAgent(usage), rfi_scopes, config.AGENT_RFI_CONCURRENCY
)
rfis = [
artifact for result in rfi_results for artifact in result.artifacts
]
memory.extend("rfis", rfis)
memory.dump("memory.json")
orchestrator.stage("Build agent report")
conflicts = [_finding_as_conflict(item) for item in conflict_findings]
report = build_report(
conflicts=conflicts,
sheets=sheets,
clusters=clusters,
source=source_name or os.path.basename(pdf_path),
)
report.update({
"project_input": merged_input,
"jurisdiction": jurisdiction,
"sheet_index": sheet_index,
"project_intelligence": object_graph,
"validated_issues": prioritized,
"rfis": rfis,
})
cost = usage.snapshot()
orchestrator.stats.calls = cost["calls"]
stats = orchestrator.stats.as_dict()
report["summary"].update({
"pipeline_mode": "agent",
"agent_status": "complete",
"agent_stats": stats,
"by_stage": {
"conflicts": len(conflict_findings),
"qaqc": sum(
1 for item in specialist_findings
if item.get("source_stage") == "qaqc"
),
"code": sum(
1 for item in specialist_findings
if item.get("source_stage") == "code"
),
"constructability": sum(
1 for item in specialist_findings
if item.get("source_stage") == "constructability"
),
"validated": len(prioritized),
"rfis": len(rfis),
},
"cost_usd": round(cost["usd"], 4),
"llm_calls": cost["calls"],
"cached_calls": cost["cached"],
"cost_by_stage": cost["by_stage"],
"text_backend": "openrouter",
"models_used": cost["models"],
})
if out_dir:
os.makedirs(out_dir, exist_ok=True)
_dump(out_dir, "assertions.json", sheets)
_dump(out_dir, "clusters.json", [_without_base64(item) for item in clusters])
_dump(out_dir, "sheet_index.json", sheet_index)
_dump(out_dir, "jurisdiction.json", jurisdiction)
_dump(out_dir, "project_intelligence.json", object_graph)
_dump(out_dir, "validated_issues.json", prioritized)
_dump(out_dir, "rfis.json", rfis)
_dump(out_dir, "conflicts.json", report)
with open(os.path.join(out_dir, "report.md"), "w", encoding="utf-8") as f:
f.write(to_markdown(report))
return report
def _dump(out_dir: str, name: str, value) -> None:
with open(os.path.join(out_dir, name), "w", encoding="utf-8") as f:
json.dump(value, f, indent=2)
def _counts(items, key: str) -> Dict[str, int]:
counts: Dict[str, int] = {}
for item in items:
value = str(item.get(key) or "unknown")
counts[value] = counts.get(value, 0) + 1
return counts
def _finding_as_conflict(finding: Dict) -> Dict:
return {
"category": finding.get("category") or "uncategorized",
"severity": finding.get("severity") or "medium",
"disciplines": finding.get("disciplines") or [],
"location": finding.get("location") or "",
"sheets": finding.get("sheets") or [],
"description": finding.get("description") or "",
"evidence": finding.get("evidence") or [],
"recommended_resolution": finding.get("recommended_resolution") or "",
"confidence": finding.get("confidence") or "medium",
"cluster_key": finding.get("scope_id"),
}
def _without_base64(cluster: Dict) -> Dict:
return {
**cluster,
"assertions": [
{key: value for key, value in assertion.items() if key != "base64"}
for assertion in cluster.get("assertions") or []
],
}