FlyingNunchucks's picture
Sync from GitHub via hub-sync
f902adb verified
Raw History Blame Contribute Delete
6.81 kB
"""Bounded specialist agent for evidence-linked analysis."""
from collections.abc import Callable
import json
from .schemas import AnalysisResult, ResearchResult
AnalysisModel = Callable[[str], str]
class AnalysisAgent:
"""Interpret validated research findings without introducing new facts."""
role = "analysis"
def __init__(self, model: AnalysisModel) -> None:
self.model = model
def build_prompt(
self,
mission: str,
research_result: ResearchResult,
) -> str:
"""Build the bounded prompt given to the analysis model."""
research_packet = research_result.model_dump_json(indent=2)
return f"""You are the Analysis Agent in a centralized multi-agent system.
Your job is narrow: interpret the validated research handoff and identify implications relevant to the mission.
MISSION:
{mission}
VALIDATED RESEARCH HANDOFF:
{research_packet}
Rules:
- Use only information contained in the validated research handoff.
- Do not invent new facts, evidence, source IDs, entity relationships, or comparative claims.
- Do not perform new research.
- Every analysis point must cite one or more source_id values already present in the research findings.
- If you cannot cite at least one approved source_id for an idea, do not put it in points; place it in questions_for_verification instead.
- Preserve the epistemic strength of the research. Do not upgrade related evidence into a stronger claim.
- If the research says competitors compete on speed and service, you may say those are competitive dimensions; do not say the competitors dominate, outperform, or are superior unless the research explicitly establishes that.
- If the research says customers show interest in automation, you may say demand exists for automation; do not say that demand aligns with this company's offering unless the research explicitly describes that offering and establishes the fit.
- If a capability is unresolved, describe it as unresolved; do not call it inadequate, insufficient, or a proven weakness.
- When an implication requires interpretation rather than direct evidence, use bounded language such as may, could, or suggests and lower confidence appropriately.
- Distinguish opportunities, risks, constraints, and uncertainties.
- Explain the reasoning that connects the research evidence to each analysis point.
- Record any assumptions explicitly rather than presenting them as facts.
- Put claims that deserve independent checking in questions_for_verification.
- Questions must be neutral and must not embed an unsupported premise. Ask what must be learned, not what must be done to fix a problem that has not been established.
- Do not create point IDs. Application code assigns stable IDs after validating your response.
- Do not make the final recommendation; the Synthesis Agent will own the final answer.
- Return valid JSON only.
Return exactly this shape:
{{
"assessment": "overall interpretation of the research",
"points": [
{{
"kind": "opportunity",
"statement": "analysis point",
"reasoning": "why the research supports this interpretation",
"source_ids": ["source-1"],
"confidence": "high"
}}
],
"assumptions": [],
"questions_for_verification": []
}}
"""
@staticmethod
def _quarantine_unlinked_points(
payload: object,
approved_ids: set[str],
) -> object:
"""Remove non-evidence-linked points while preserving them for audit."""
if not isinstance(payload, dict):
return payload
raw_points = payload.get("points")
if not isinstance(raw_points, list):
return payload
kept_points = []
omitted_points = []
for raw_point in raw_points:
if not isinstance(raw_point, dict):
kept_points.append(raw_point)
continue
raw_source_ids = raw_point.get("source_ids")
cleaned_ids = []
if isinstance(raw_source_ids, list):
for source_id in raw_source_ids:
if isinstance(source_id, str):
cleaned = source_id.strip()
if cleaned and cleaned not in cleaned_ids:
cleaned_ids.append(cleaned)
has_only_approved_ids = bool(cleaned_ids) and all(
source_id in approved_ids
for source_id in cleaned_ids
)
if has_only_approved_ids:
normalized_point = dict(raw_point)
normalized_point["source_ids"] = cleaned_ids
kept_points.append(normalized_point)
continue
statement = raw_point.get("statement")
if isinstance(statement, str) and statement.strip():
omitted_points.append(statement.strip())
else:
omitted_points.append(
"Analysis point omitted because it lacked approved source links."
)
normalized_payload = dict(payload)
normalized_payload["points"] = kept_points
normalized_payload["omitted_points"] = omitted_points
if raw_points and not kept_points:
raise ValueError(
"analysis model returned no evidence-linked points after validation"
)
return normalized_payload
def run(
self,
mission: str,
research_result: ResearchResult,
) -> AnalysisResult:
"""Run the analysis model and validate its handoff contract."""
if not mission.strip():
raise ValueError("mission must not be empty")
prompt = self.build_prompt(mission, research_result)
raw_response = self.model(prompt)
try:
payload = json.loads(raw_response)
except json.JSONDecodeError as exc:
raise ValueError("analysis model returned invalid JSON") from exc
approved_ids = {
source_id
for finding in research_result.findings
for source_id in finding.source_ids
}
payload = self._quarantine_unlinked_points(
payload=payload,
approved_ids=approved_ids,
)
result = AnalysisResult.model_validate(payload)
cited_ids = {
source_id
for point in result.points
for source_id in point.source_ids
}
unknown_ids = cited_ids - approved_ids
if unknown_ids:
raise ValueError(
"analysis model cited unknown source IDs: "
+ ", ".join(sorted(unknown_ids))
)
for index, point in enumerate(result.points, start=1):
point.point_id = f"analysis-{index}"
return result