Tumor Board Simulator
Build a multi-specialist debate agent simulating a tumor board conference where oncologist, surgeon, radiation oncologist, and pathologist debate optimal cancer treatment plans using LangGraph
Tumor Board Simulator
Build a multi-specialist debate system where four AI physician personas simulate a tumor board conference, each advocating from their specialty perspective to develop comprehensive cancer treatment plans.
| Difficulty | Advanced |
| Time | 4-5 days |
| Code | ~1000 lines |
| Pattern | Multi-Specialist Round-Robin Debate |
TL;DR
Build a tumor board simulator using LangGraph round-robin state machine (4 specialists debating across 3 phases), parallel fan-out via Send() for simultaneous specialist opinions, NCCN guideline matching for evidence-based recommendations, and moderator-driven consensus that synthesizes a ranked treatment plan. This is the most complex adversarial debate pattern — scaling from 2 agents to 4+ with structured disagreement resolution.
Medical Disclaimer
This system is for educational purposes only. It simulates a tumor board conference for learning about multi-agent debate systems. It does not provide medical advice, cancer diagnoses, or treatment recommendations. All cancer treatment decisions must be made by qualified oncology teams with access to complete patient records.
Why Tumor Board Simulation?
┌─────────────────────────────────────────────────────────────────────┐
│ THE TUMOR BOARD CHALLENGE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Real tumor boards: 4-8 specialists meet weekly to discuss cases │
│ Each brings a different treatment perspective │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Single-Agent Approach: │ │
│ │ User: "Best treatment for Stage IIIA NSCLC?" │ │
│ │ LLM: "Concurrent chemoradiation followed by..." │ │
│ │ (One perspective, misses surgical nuances) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Tumor Board Approach: │ │
│ │ Medical Oncologist: "Neoadjuvant chemo then reassess" │ │
│ │ Surgeon: "If downstaged, lobectomy offers cure" │ │
│ │ Radiation Oncologist: "Definitive chemoRT if unresectable" │ │
│ │ Pathologist: "PD-L1 >50% — consider immunotherapy first" │ │
│ │ Moderator: Synthesizes staged plan with decision points │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Multi-specialist debate produces STAGED treatment plans │
│ with decision trees, not single recommendations │
│ │
│ Previous debate projects: This project: │
│ 2 agents (for/against) ──► 4+ agents (multi-perspective) │
│ Binary outcome ──► Ranked treatment options │
│ 3 rounds ──► 3 phases (present/challenge/plan) │
│ │
└─────────────────────────────────────────────────────────────────────┘What You'll Build
A tumor board simulator that:
- Parses case presentations — Extracts tumor type, stage, biomarkers, and patient factors
- Generates 4 specialist opinions — Medical oncology, surgical, radiation, and pathology perspectives in parallel
- Runs challenge rounds — Each specialist questions assumptions from other specialties
- Drives consensus — Moderator identifies areas of agreement and remaining conflicts
- Produces treatment plans — Ranked options with decision trees and contingency plans
- Cites guidelines — References NCCN pathways and clinical trial evidence
Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ TUMOR BOARD SIMULATOR WORKFLOW │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ case_intake │
│ │ │
│ ▼ │
│ staging_analysis ──► Extracts TNM stage, biomarkers, comorbidities │
│ │ │
│ ▼ │
│ ┌─── specialist_opinions (Phase 1: PRESENTATION) ───────────────┐ │
│ │ Fan-out via Send() to 4 parallel agents │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Medical │ │ Surgical │ │Radiation │ │ Pathology │ │ │
│ │ │Oncologist│ │Oncologist│ │Oncologist│ │ Reviewer │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ │
│ │ └──────┬──────┴──────┬─────┘ │ │ │
│ │ ▼ ▼ │ │ │
│ │ aggregate_opinions ◄────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─── challenge_round (Phase 2: CHALLENGE) ──────────────────────┐ │
│ │ Each specialist reviews others' opinions │ │
│ │ │ │
│ │ Medical Onc challenges Surgeon's operability assessment │ │
│ │ Surgeon challenges Radiation Onc's dose constraints │ │
│ │ Radiation Onc challenges Medical Onc's chemo tolerance │ │
│ │ Pathologist clarifies biomarker implications for all │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─── consensus_building (Phase 3: TREATMENT PLAN) ──────────────┐ │
│ │ Moderator synthesizes all inputs │ │
│ │ │ │
│ │ 1. Identifies areas of agreement │ │
│ │ 2. Resolves remaining conflicts │ │
│ │ 3. Produces ranked treatment options │ │
│ │ 4. Creates decision tree with contingencies │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ treatment_plan_output │
│ │ │
│ ▼ │
│ Return: Ranked options + decision tree + monitoring plan │
│ │
└─────────────────────────────────────────────────────────────────────────┘Project Structure
tumor-board-simulator/
├── src/
│ ├── models.py # Pydantic data models
│ ├── staging.py # TNM staging and biomarker parser
│ ├── specialists/
│ │ ├── __init__.py
│ │ ├── base.py # BaseSpecialist interface
│ │ ├── medical_oncology.py
│ │ ├── surgical_oncology.py
│ │ ├── radiation_oncology.py
│ │ └── pathology.py
│ ├── moderator.py # Consensus builder
│ ├── workflow.py # LangGraph tumor board workflow
│ └── app.py # FastAPI application
├── tests/
│ └── test_workflow.py
├── pyproject.toml
└── README.mdTech Stack
| Technology | Purpose |
|---|---|
| LangGraph | Round-robin debate orchestration with Send() fan-out |
| GPT-4o | Specialist reasoning and consensus building |
| Pydantic | Structured clinical data models |
| FastAPI | REST API endpoints |
Implementation
Step 1: Clinical Data Models
Define the structured models that represent a cancer case and the specialist opinions.
# src/models.py
from pydantic import BaseModel, Field
from enum import Enum
from typing import Optional
class TumorStage(str, Enum):
"""TNM staging simplified."""
STAGE_I = "I"
STAGE_IA = "IA"
STAGE_IB = "IB"
STAGE_II = "II"
STAGE_IIA = "IIA"
STAGE_IIB = "IIB"
STAGE_III = "III"
STAGE_IIIA = "IIIA"
STAGE_IIIB = "IIIB"
STAGE_IIIC = "IIIC"
STAGE_IV = "IV"
STAGE_IVA = "IVA"
STAGE_IVB = "IVB"
class PerformanceStatus(str, Enum):
"""ECOG Performance Status."""
PS_0 = "0" # Fully active
PS_1 = "1" # Restricted but ambulatory
PS_2 = "2" # Ambulatory, limited self-care
PS_3 = "3" # Limited self-care, confined >50% of time
PS_4 = "4" # Completely disabled
class Specialty(str, Enum):
MEDICAL_ONCOLOGY = "medical_oncology"
SURGICAL_ONCOLOGY = "surgical_oncology"
RADIATION_ONCOLOGY = "radiation_oncology"
PATHOLOGY = "pathology"
class Biomarkers(BaseModel):
"""Tumor biomarker profile."""
pd_l1_tps: Optional[float] = Field(
None, description="PD-L1 TPS percentage (0-100)"
)
egfr_mutation: Optional[str] = Field(
None, description="EGFR mutation status"
)
alk_fusion: Optional[bool] = Field(
None, description="ALK rearrangement status"
)
kras_mutation: Optional[str] = Field(
None, description="KRAS mutation (e.g., G12C)"
)
her2_status: Optional[str] = Field(
None, description="HER2 expression/amplification"
)
msi_status: Optional[str] = Field(
None, description="MSI-H/MSS status"
)
tmb: Optional[float] = Field(
None, description="Tumor mutational burden (mut/Mb)"
)
additional: dict = Field(
default_factory=dict,
description="Additional biomarkers"
)
class PatientCase(BaseModel):
"""Complete tumor board case presentation."""
patient_id: str
age: int
sex: str
cancer_type: str = Field(description="e.g., Non-Small Cell Lung Cancer")
histology: str = Field(description="e.g., Adenocarcinoma")
tnm_t: str = Field(description="T stage (T1a-T4)")
tnm_n: str = Field(description="N stage (N0-N3)")
tnm_m: str = Field(description="M stage (M0-M1c)")
overall_stage: TumorStage
biomarkers: Biomarkers
performance_status: PerformanceStatus
comorbidities: list[str] = Field(default_factory=list)
prior_treatments: list[str] = Field(default_factory=list)
clinical_notes: str = Field(description="Free-text clinical summary")
class TreatmentOption(BaseModel):
"""A single treatment recommendation."""
name: str = Field(description="Treatment name/regimen")
modality: str = Field(description="Surgery/Chemo/Radiation/Immuno/Targeted")
intent: str = Field(description="Curative/Palliative/Neoadjuvant/Adjuvant")
rationale: str
expected_benefit: str
key_risks: list[str]
guideline_reference: str = Field(
description="NCCN or trial reference"
)
class SpecialistOpinion(BaseModel):
"""Structured opinion from one specialist."""
specialty: Specialty
recommended_approach: str
treatment_options: list[TreatmentOption]
key_considerations: list[str]
concerns_about_case: list[str]
questions_for_other_specialists: list[str]
confidence: float = Field(ge=0.0, le=1.0)
class Challenge(BaseModel):
"""A challenge from one specialist to another."""
challenger: Specialty
challenged: Specialty
point_of_contention: str
argument: str
evidence: str
suggested_alternative: Optional[str] = None
class ConsensusPoint(BaseModel):
"""A point of agreement or resolved conflict."""
topic: str
agreed_approach: str
supporting_specialists: list[Specialty]
dissenting_view: Optional[str] = None
dissenting_specialist: Optional[Specialty] = None
class TreatmentPlan(BaseModel):
"""Final tumor board treatment plan."""
primary_recommendation: TreatmentOption
alternative_options: list[TreatmentOption]
consensus_points: list[ConsensusPoint]
unresolved_questions: list[str]
decision_tree: str = Field(
description="Conditional treatment pathway"
)
monitoring_plan: str
clinical_trial_considerations: list[str]
next_review_trigger: str = Field(
description="When to reconvene the tumor board"
)Understanding the data model hierarchy:
┌─────────────────────────────────────────────────────┐
│ PatientCase │
│ ├── Demographics (age, sex, performance status) │
│ ├── TNM Staging (T, N, M → overall stage) │
│ ├── Biomarkers (PD-L1, EGFR, ALK, KRAS, ...) │
│ └── Clinical context (comorbidities, prior tx) │
│ │ │
│ ▼ │
│ 4x SpecialistOpinion │
│ ├── Recommended approach + treatment options │
│ ├── Concerns specific to their specialty │
│ └── Questions for other specialists │
│ │ │
│ ▼ │
│ N x Challenge (specialist-to-specialist) │
│ ├── Point of contention │
│ └── Evidence-backed argument │
│ │ │
│ ▼ │
│ TreatmentPlan (consensus output) │
│ ├── Primary recommendation │
│ ├── Alternatives ranked │
│ ├── Decision tree (if X then Y, else Z) │
│ └── Monitoring plan + next review trigger │
└─────────────────────────────────────────────────────┘| Model | Purpose |
|---|---|
PatientCase | Input — everything the specialists need to evaluate |
Biomarkers | Determines eligibility for targeted therapy and immunotherapy |
SpecialistOpinion | Each specialist's independent recommendation |
Challenge | Directed criticism from one specialist to another |
TreatmentPlan | Final consensus with decision tree and contingencies |
Step 2: Specialist Base Class
Define the interface that all specialist agents implement.
# src/specialists/base.py
from abc import ABC, abstractmethod
from openai import OpenAI
import json
from src.models import (
PatientCase, SpecialistOpinion, Challenge, Specialty
)
class BaseSpecialist(ABC):
"""Base class for tumor board specialists."""
def __init__(self, model: str = "gpt-4o"):
self.client = OpenAI()
self.model = model
@property
@abstractmethod
def specialty(self) -> Specialty:
"""Which specialty this agent represents."""
...
@property
@abstractmethod
def system_prompt(self) -> str:
"""Specialty-specific system prompt."""
...
def generate_opinion(
self, case: PatientCase
) -> SpecialistOpinion:
"""Generate initial opinion for the case."""
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self.system_prompt},
{
"role": "user",
"content": self._format_case_prompt(case),
},
],
response_format={"type": "json_object"},
temperature=0.3,
)
data = json.loads(response.choices[0].message.content)
return SpecialistOpinion(specialty=self.specialty, **data)
def generate_challenges(
self,
case: PatientCase,
other_opinions: list[SpecialistOpinion],
) -> list[Challenge]:
"""Challenge other specialists' recommendations."""
opinions_text = "\n\n".join(
f"**{op.specialty.value}**: {op.recommended_approach}\n"
f"Options: {', '.join(t.name for t in op.treatment_options)}\n"
f"Concerns: {'; '.join(op.concerns_about_case)}"
for op in other_opinions
if op.specialty != self.specialty
)
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self.system_prompt},
{
"role": "user",
"content": (
f"Case:\n{case.clinical_notes}\n\n"
f"Other specialists' opinions:\n{opinions_text}\n\n"
"Identify points where you disagree or see risks "
"from your specialty perspective. For each point, "
"provide evidence-based reasoning.\n\n"
"Return JSON with 'challenges' array, each having: "
"challenged (specialty), point_of_contention, "
"argument, evidence, suggested_alternative (optional)."
),
},
],
response_format={"type": "json_object"},
temperature=0.4,
)
data = json.loads(response.choices[0].message.content)
return [
Challenge(challenger=self.specialty, **c)
for c in data.get("challenges", [])
]
def _format_case_prompt(self, case: PatientCase) -> str:
"""Format case as a prompt for the specialist."""
biomarker_text = []
if case.biomarkers.pd_l1_tps is not None:
biomarker_text.append(
f"PD-L1 TPS: {case.biomarkers.pd_l1_tps}%"
)
if case.biomarkers.egfr_mutation:
biomarker_text.append(
f"EGFR: {case.biomarkers.egfr_mutation}"
)
if case.biomarkers.alk_fusion is not None:
biomarker_text.append(
f"ALK: {'Positive' if case.biomarkers.alk_fusion else 'Negative'}"
)
if case.biomarkers.kras_mutation:
biomarker_text.append(
f"KRAS: {case.biomarkers.kras_mutation}"
)
return (
f"TUMOR BOARD CASE PRESENTATION\n"
f"{'=' * 40}\n"
f"Patient: {case.age}yo {case.sex}\n"
f"Cancer: {case.cancer_type} — {case.histology}\n"
f"Stage: {case.overall_stage.value} "
f"(T{case.tnm_t} N{case.tnm_n} M{case.tnm_m})\n"
f"ECOG PS: {case.performance_status.value}\n"
f"Biomarkers: {', '.join(biomarker_text) or 'Pending'}\n"
f"Comorbidities: {', '.join(case.comorbidities) or 'None'}\n"
f"Prior treatments: {', '.join(case.prior_treatments) or 'None'}\n"
f"\nClinical Notes:\n{case.clinical_notes}\n\n"
f"Provide your specialist opinion as JSON with:\n"
f"- recommended_approach (string)\n"
f"- treatment_options (array of objects with: name, modality, "
f"intent, rationale, expected_benefit, key_risks array, "
f"guideline_reference)\n"
f"- key_considerations (array of strings)\n"
f"- concerns_about_case (array of strings)\n"
f"- questions_for_other_specialists (array of strings)\n"
f"- confidence (float 0-1)"
)The base class provides two key methods: generate_opinion for Phase 1 (initial assessment) and generate_challenges for Phase 2 (cross-specialty critique). Each specialist inherits this interface and customizes behavior through the system_prompt property.
Step 3: Specialist Implementations
Each specialist has a domain-specific system prompt that shapes their clinical reasoning.
# src/specialists/medical_oncology.py
from src.models import Specialty
from src.specialists.base import BaseSpecialist
class MedicalOncologist(BaseSpecialist):
"""Systemic therapy specialist — chemo, immunotherapy, targeted."""
@property
def specialty(self) -> Specialty:
return Specialty.MEDICAL_ONCOLOGY
@property
def system_prompt(self) -> str:
return (
"You are a board-certified medical oncologist presenting "
"at a tumor board conference.\n\n"
"YOUR FOCUS AREAS:\n"
"- Systemic therapy selection (chemotherapy, immunotherapy, "
"targeted therapy)\n"
"- Biomarker-driven treatment decisions (PD-L1, EGFR, ALK, "
"KRAS, HER2, MSI)\n"
"- Neoadjuvant and adjuvant therapy recommendations\n"
"- Drug combinations and sequencing strategies\n"
"- Toxicity management and dose modifications\n"
"- Clinical trial eligibility assessment\n\n"
"GUIDELINES TO REFERENCE:\n"
"- NCCN Clinical Practice Guidelines\n"
"- ASCO guidelines and key clinical trials\n"
"- FDA-approved indications and companion diagnostics\n\n"
"APPROACH:\n"
"- Consider biomarker profile before recommending therapy\n"
"- Account for performance status and comorbidities\n"
"- Recommend treatment sequencing (1st line, 2nd line)\n"
"- Flag clinical trial opportunities\n"
"- Be specific about regimens (drug names, cycles)\n\n"
"Respond with evidence-based recommendations. Always "
"acknowledge uncertainty when data is limited."
)# src/specialists/surgical_oncology.py
from src.models import Specialty
from src.specialists.base import BaseSpecialist
class SurgicalOncologist(BaseSpecialist):
"""Surgical assessment — resectability, margins, approaches."""
@property
def specialty(self) -> Specialty:
return Specialty.SURGICAL_ONCOLOGY
@property
def system_prompt(self) -> str:
return (
"You are a board-certified surgical oncologist presenting "
"at a tumor board conference.\n\n"
"YOUR FOCUS AREAS:\n"
"- Resectability assessment (anatomic and physiologic)\n"
"- Surgical approach selection (minimally invasive vs open)\n"
"- Margin adequacy and lymph node dissection extent\n"
"- Perioperative risk stratification\n"
"- Neoadjuvant therapy to improve resectability\n"
"- Role of surgery in oligometastatic disease\n\n"
"KEY CONSIDERATIONS:\n"
"- Patient fitness for surgery (ECOG, cardiopulmonary reserve)\n"
"- Tumor location relative to critical structures\n"
"- Expected morbidity and mortality rates\n"
"- Recovery timeline and impact on systemic therapy\n\n"
"APPROACH:\n"
"- Clearly state if the tumor is resectable, borderline "
"resectable, or unresectable\n"
"- If borderline, specify what neoadjuvant therapy could "
"convert to resectable\n"
"- Describe the planned operation specifically\n"
"- Estimate surgical risk given comorbidities\n"
"- Identify when surgery is NOT the right approach\n\n"
"Be honest about surgical limitations. Not every patient "
"benefits from surgery."
)# src/specialists/radiation_oncology.py
from src.models import Specialty
from src.specialists.base import BaseSpecialist
class RadiationOncologist(BaseSpecialist):
"""Radiation therapy specialist — modalities, dosing, sequencing."""
@property
def specialty(self) -> Specialty:
return Specialty.RADIATION_ONCOLOGY
@property
def system_prompt(self) -> str:
return (
"You are a board-certified radiation oncologist presenting "
"at a tumor board conference.\n\n"
"YOUR FOCUS AREAS:\n"
"- Radiation modality selection (IMRT, SBRT, proton, "
"brachytherapy)\n"
"- Dose fractionation schedules\n"
"- Target volume delineation considerations\n"
"- Concurrent chemoradiation protocols\n"
"- Organ-at-risk dose constraints\n"
"- Palliative radiation for symptom control\n\n"
"KEY CONSIDERATIONS:\n"
"- Definitive RT vs adjuvant vs neoadjuvant\n"
"- SBRT for oligometastatic disease\n"
"- Re-irradiation constraints for previously treated areas\n"
"- Radiation sensitizers and immunotherapy combinations\n\n"
"APPROACH:\n"
"- Specify radiation technique and total dose/fractions\n"
"- Define the clinical and planning target volumes\n"
"- Identify critical organ-at-risk constraints\n"
"- Sequence radiation with surgery and systemic therapy\n"
"- Consider hypofractionation when appropriate\n\n"
"Balance local control with toxicity. Recommend the simplest "
"effective approach."
)# src/specialists/pathology.py
from src.models import Specialty
from src.specialists.base import BaseSpecialist
class PathologyReviewer(BaseSpecialist):
"""Pathology specialist — histology, biomarkers, molecular."""
@property
def specialty(self) -> Specialty:
return Specialty.PATHOLOGY
@property
def system_prompt(self) -> str:
return (
"You are a board-certified pathologist and molecular "
"diagnostics specialist presenting at a tumor board.\n\n"
"YOUR FOCUS AREAS:\n"
"- Histopathologic classification and grading\n"
"- Biomarker interpretation (IHC, FISH, NGS)\n"
"- Molecular profiling and actionable mutations\n"
"- Companion diagnostic requirements\n"
"- Additional testing recommendations\n"
"- Specimen adequacy and limitations\n\n"
"KEY CONSIDERATIONS:\n"
"- Whether current biomarker data is complete for treatment "
"decisions\n"
"- Additional molecular tests that could change management\n"
"- Interpretation nuances (e.g., PD-L1 scoring variability, "
"EGFR variant significance)\n"
"- Germline testing indications\n\n"
"APPROACH:\n"
"- Review and confirm the histologic diagnosis\n"
"- Summarize all available biomarker results\n"
"- Identify MISSING tests that should be ordered\n"
"- Flag biomarker-drug matchings for other specialists\n"
"- Note any diagnostic uncertainty\n\n"
"Your role is to ensure treatment decisions are based on "
"complete and correctly interpreted pathology data."
)Why four specialists?
| Specialist | Decision Domain | What They Catch |
|---|---|---|
| Medical Oncology | Drug selection, sequencing | Biomarker-matched therapies others might miss |
| Surgical Oncology | Resectability, operative risk | Overtreatment when surgery isn't beneficial |
| Radiation Oncology | Local control, palliation | RT combinations that enhance systemic therapy |
| Pathology | Diagnostic accuracy, completeness | Missing tests that could change the entire plan |
The pathologist's role is unique — they don't advocate for a treatment modality but ensure the data underlying all other specialists' recommendations is accurate and complete. A missing biomarker test can completely change the treatment plan.
Step 4: LangGraph Workflow State
Define the state that flows through the tumor board debate.
# src/workflow.py
from typing import Annotated, TypedDict, Literal
from enum import Enum
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
from src.models import (
PatientCase,
SpecialistOpinion,
Challenge,
TreatmentPlan,
Specialty,
)
from src.specialists.medical_oncology import MedicalOncologist
from src.specialists.surgical_oncology import SurgicalOncologist
from src.specialists.radiation_oncology import RadiationOncologist
from src.specialists.pathology import PathologyReviewer
from src.moderator import TumorBoardModerator
class BoardPhase(str, Enum):
INTAKE = "intake"
PRESENTATION = "presentation"
CHALLENGE = "challenge"
CONSENSUS = "consensus"
COMPLETE = "complete"
class TumorBoardState(TypedDict):
"""State for the tumor board workflow."""
# Input
case: PatientCase
# Phase tracking
phase: BoardPhase
# Phase 1: Specialist opinions (accumulated via Send)
opinions: Annotated[list[SpecialistOpinion], operator.add]
# Phase 2: Challenges between specialists
challenges: Annotated[list[Challenge], operator.add]
# Phase 3: Final consensus
treatment_plan: TreatmentPlan | None
# Metadata
error: str | None★ Insight ─────────────────────────────────────
The Annotated[list, operator.add] pattern is the key to parallel fan-out in LangGraph. When four specialist nodes each return {"opinions": [their_opinion]}, LangGraph automatically concatenates all four lists into one. Without this annotation, the last specialist's opinion would overwrite the others. This same pattern was used in the Adverse Event Surveillance case study for accumulating detector signals.
─────────────────────────────────────────────────
# Initialize specialists
SPECIALISTS = {
Specialty.MEDICAL_ONCOLOGY: MedicalOncologist(),
Specialty.SURGICAL_ONCOLOGY: SurgicalOncologist(),
Specialty.RADIATION_ONCOLOGY: RadiationOncologist(),
Specialty.PATHOLOGY: PathologyReviewer(),
}
# --- Node functions ---
def case_intake(state: TumorBoardState) -> dict:
"""Validate and prepare the case for discussion."""
case = state["case"]
# Verify minimum required data
if not case.clinical_notes:
return {"error": "Clinical notes are required for case review"}
if not case.overall_stage:
return {"error": "Tumor staging is required"}
return {"phase": BoardPhase.PRESENTATION}
def fan_out_specialists(state: TumorBoardState) -> list[Send]:
"""Fan out to all specialists in parallel."""
return [
Send(
"specialist_opinion",
{"case": state["case"], "specialty": spec.value},
)
for spec in Specialty
]
def specialist_opinion(state: dict) -> dict:
"""Generate one specialist's opinion."""
specialty = Specialty(state["specialty"])
case = state["case"]
specialist = SPECIALISTS[specialty]
opinion = specialist.generate_opinion(case)
return {"opinions": [opinion]}
def fan_out_challenges(state: TumorBoardState) -> list[Send]:
"""Fan out challenge generation to all specialists."""
return [
Send(
"specialist_challenge",
{
"case": state["case"],
"specialty": spec.value,
"all_opinions": state["opinions"],
},
)
for spec in Specialty
]
def specialist_challenge(state: dict) -> dict:
"""Generate challenges from one specialist to others."""
specialty = Specialty(state["specialty"])
case = state["case"]
all_opinions = state["all_opinions"]
specialist = SPECIALISTS[specialty]
challenges = specialist.generate_challenges(case, all_opinions)
return {"challenges": challenges}
def build_consensus(state: TumorBoardState) -> dict:
"""Moderator synthesizes all opinions and challenges."""
moderator = TumorBoardModerator()
plan = moderator.synthesize(
case=state["case"],
opinions=state["opinions"],
challenges=state["challenges"],
)
return {
"treatment_plan": plan,
"phase": BoardPhase.COMPLETE,
}Step 5: Moderator (Consensus Builder)
The moderator synthesizes all specialist inputs into a coherent treatment plan. This is the equivalent of the senior attending who chairs the tumor board.
# src/moderator.py
from openai import OpenAI
import json
from src.models import (
PatientCase,
SpecialistOpinion,
Challenge,
TreatmentPlan,
TreatmentOption,
ConsensusPoint,
Specialty,
)
class TumorBoardModerator:
"""Synthesizes specialist opinions into a consensus treatment plan."""
def __init__(self, model: str = "gpt-4o"):
self.client = OpenAI()
self.model = model
def synthesize(
self,
case: PatientCase,
opinions: list[SpecialistOpinion],
challenges: list[Challenge],
) -> TreatmentPlan:
"""Build consensus from opinions and challenges."""
opinions_text = self._format_opinions(opinions)
challenges_text = self._format_challenges(challenges)
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self._system_prompt()},
{
"role": "user",
"content": (
f"PATIENT CASE:\n"
f"{case.cancer_type} — {case.histology}\n"
f"Stage {case.overall_stage.value}, "
f"ECOG {case.performance_status.value}\n"
f"Age: {case.age}, Sex: {case.sex}\n\n"
f"SPECIALIST OPINIONS:\n{opinions_text}\n\n"
f"CHALLENGES RAISED:\n{challenges_text}\n\n"
f"Synthesize a consensus treatment plan as JSON "
f"with the following structure:\n"
f"- primary_recommendation (object: name, modality, "
f"intent, rationale, expected_benefit, key_risks[], "
f"guideline_reference)\n"
f"- alternative_options (array of same structure)\n"
f"- consensus_points (array: topic, agreed_approach, "
f"supporting_specialists[], dissenting_view, "
f"dissenting_specialist)\n"
f"- unresolved_questions (array of strings)\n"
f"- decision_tree (string describing conditional "
f"pathway)\n"
f"- monitoring_plan (string)\n"
f"- clinical_trial_considerations (array of strings)\n"
f"- next_review_trigger (string)"
),
},
],
response_format={"type": "json_object"},
temperature=0.2,
)
data = json.loads(response.choices[0].message.content)
return TreatmentPlan(
primary_recommendation=TreatmentOption(
**data["primary_recommendation"]
),
alternative_options=[
TreatmentOption(**opt)
for opt in data.get("alternative_options", [])
],
consensus_points=[
ConsensusPoint(**cp)
for cp in data.get("consensus_points", [])
],
unresolved_questions=data.get("unresolved_questions", []),
decision_tree=data.get("decision_tree", ""),
monitoring_plan=data.get("monitoring_plan", ""),
clinical_trial_considerations=data.get(
"clinical_trial_considerations", []
),
next_review_trigger=data.get("next_review_trigger", ""),
)
def _system_prompt(self) -> str:
return (
"You are a senior oncologist chairing a tumor board "
"conference. Your role is to:\n\n"
"1. IDENTIFY CONSENSUS — Where do specialists agree?\n"
"2. RESOLVE CONFLICTS — When specialists disagree, weigh "
"the evidence and make a recommendation, noting dissent\n"
"3. CREATE A STAGED PLAN — Treatment often has decision "
"points (e.g., 'If neoadjuvant therapy achieves response, "
"proceed to surgery; otherwise, switch to definitive "
"chemoradiation')\n"
"4. FLAG MISSING DATA — Note when pathology suggests "
"additional testing is needed before finalizing the plan\n"
"5. CONSIDER CLINICAL TRIALS — Identify when standard "
"therapy is suboptimal and trials may benefit the patient\n\n"
"IMPORTANT PRINCIPLES:\n"
"- Weight opinions by evidence quality, not loudness\n"
"- Acknowledge when there is genuine equipoise (no clear "
"best option)\n"
"- Prioritize patient factors (age, performance status, "
"preferences) in tiebreaking\n"
"- Never dismiss a specialist's concern without addressing "
"it explicitly\n"
"- The decision tree should handle realistic contingencies"
)
def _format_opinions(
self, opinions: list[SpecialistOpinion]
) -> str:
sections = []
for op in opinions:
treatments = "\n".join(
f" - {t.name} ({t.modality}, {t.intent}): {t.rationale}"
for t in op.treatment_options
)
sections.append(
f"[{op.specialty.value}] (confidence: {op.confidence})\n"
f"Approach: {op.recommended_approach}\n"
f"Treatments:\n{treatments}\n"
f"Concerns: {'; '.join(op.concerns_about_case)}\n"
f"Questions: {'; '.join(op.questions_for_other_specialists)}"
)
return "\n\n".join(sections)
def _format_challenges(self, challenges: list[Challenge]) -> str:
if not challenges:
return "No challenges raised (full agreement)."
return "\n\n".join(
f"{c.challenger.value} → {c.challenged.value}:\n"
f"Issue: {c.point_of_contention}\n"
f"Argument: {c.argument}\n"
f"Evidence: {c.evidence}"
+ (
f"\nAlternative: {c.suggested_alternative}"
if c.suggested_alternative
else ""
)
for c in challenges
)How the moderator resolves conflicts:
┌─────────────────────────────────────────────────────┐
│ CONFLICT RESOLUTION STRATEGY │
├─────────────────────────────────────────────────────┤
│ │
│ Step 1: Identify overlapping recommendations │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │Med Onc: │ │Surgeon: │ │Rad Onc: │ │
│ │Chemo+IO │ │Surgery │ │ChemoRT │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ Step 2: Check for evidence hierarchy │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ "Neoadjuvant chemo+IO, then reassess for │
│ surgery. If unresectable, definitive chemoRT." │
│ │
│ Step 3: Create decision tree │
│ ┌──────────────────────────────────┐ │
│ │ IF neoadjuvant → response: │ │
│ │ → Proceed to surgery │ │
│ │ IF neoadjuvant → no response: │ │
│ │ → Definitive chemoRT │ │
│ │ IF PD-L1 >50%: │ │
│ │ → Consider IO monotherapy │ │
│ └──────────────────────────────────┘ │
│ │
│ Step 4: Note dissent with reasoning │
│ "Pathology notes: EGFR testing pending — │
│ hold all plans until results available" │
│ │
└─────────────────────────────────────────────────────┘Step 6: Build the LangGraph Workflow
Assemble the complete tumor board workflow with parallel fan-out and phased execution.
# src/workflow.py (continued — add to the same file)
def build_tumor_board_workflow() -> StateGraph:
"""Construct the tumor board LangGraph workflow."""
workflow = StateGraph(TumorBoardState)
# Add nodes
workflow.add_node("case_intake", case_intake)
workflow.add_node("specialist_opinion", specialist_opinion)
workflow.add_node("specialist_challenge", specialist_challenge)
workflow.add_node("build_consensus", build_consensus)
# Entry point
workflow.add_edge(START, "case_intake")
# After intake, fan out to specialists
workflow.add_conditional_edges(
"case_intake",
# If error, go to END; otherwise fan out
lambda state: (
"error" if state.get("error") else "fan_out"
),
{
"error": END,
"fan_out": "specialist_opinion",
},
)
# After all opinions collected, fan out challenges
workflow.add_conditional_edges(
"specialist_opinion",
fan_out_challenges,
)
# After all challenges, build consensus
workflow.add_edge("specialist_challenge", "build_consensus")
# Consensus ends the workflow
workflow.add_edge("build_consensus", END)
return workflow
# Compile the graph
tumor_board_graph = build_tumor_board_workflow().compile()
async def run_tumor_board(case: PatientCase) -> TreatmentPlan:
"""Execute the full tumor board simulation."""
initial_state = TumorBoardState(
case=case,
phase=BoardPhase.INTAKE,
opinions=[],
challenges=[],
treatment_plan=None,
error=None,
)
result = await tumor_board_graph.ainvoke(initial_state)
if result.get("error"):
raise ValueError(f"Tumor board error: {result['error']}")
return result["treatment_plan"]Understanding the LangGraph execution flow:
┌─────────────────────────────────────────────────────────────────┐
│ EXECUTION TIMELINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Time ──► │
│ │
│ ┌─────────────┐ │
│ │ case_intake │ Sequential — validates input │
│ └──────┬──────┘ │
│ │ │
│ ▼ fan_out_specialists() returns 4 Send() │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ │
│ │ Med Onc │ │ Surgeon │ │ Rad Onc │ │ Pathology│ │
│ │ opinion │ │ opinion │ │ opinion │ │ opinion │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └────┬─────┘ │
│ └───────┬───────┴───────┬───────┘ │ │
│ │ Parallel │ │ │
│ │ execution │ │ │
│ ▼ ▼ ▼ │
│ All 4 opinions collected (operator.add) │
│ │ │
│ ▼ fan_out_challenges() returns 4 Send() │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ │
│ │ Med Onc │ │ Surgeon │ │ Rad Onc │ │ Pathology│ │
│ │ challenges │ │ challenges │ │ challenges │ │ challenges│ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └────┬─────┘ │
│ └───────┬───────┴───────┬───────┘ │ │
│ ▼ ▼ ▼ │
│ All challenges collected (operator.add) │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ build_consensus │ Sequential — moderator synthesizes │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ TreatmentPlan output │
│ │
│ Total LLM calls: 4 (opinions) + 4 (challenges) + 1 (consensus)│
│ Parallelism: 4x speedup in Phase 1 and Phase 2 │
│ │
└─────────────────────────────────────────────────────────────────┘| Phase | Nodes | Parallelism | LLM Calls |
|---|---|---|---|
| Intake | case_intake | Sequential | 0 |
| Presentation | specialist_opinion x4 | Parallel via Send() | 4 |
| Challenge | specialist_challenge x4 | Parallel via Send() | 4 |
| Consensus | build_consensus | Sequential | 1 |
| Total | 9 |
Step 7: FastAPI Application
Expose the tumor board as a REST API.
# src/app.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from src.models import PatientCase, TreatmentPlan, Biomarkers
from src.models import TumorStage, PerformanceStatus
from src.workflow import run_tumor_board
app = FastAPI(
title="Tumor Board Simulator",
description="Multi-specialist debate for cancer treatment planning",
version="1.0.0",
)
class TumorBoardRequest(BaseModel):
"""API request for tumor board review."""
case: PatientCase
class TumorBoardResponse(BaseModel):
"""API response with treatment plan."""
treatment_plan: TreatmentPlan
specialists_consulted: list[str]
@app.post("/tumor-board/review", response_model=TumorBoardResponse)
async def review_case(request: TumorBoardRequest):
"""Submit a case for tumor board review."""
try:
plan = await run_tumor_board(request.case)
return TumorBoardResponse(
treatment_plan=plan,
specialists_consulted=[
"Medical Oncology",
"Surgical Oncology",
"Radiation Oncology",
"Pathology",
],
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/health")
async def health():
return {"status": "healthy", "service": "tumor-board-simulator"}
# --- Example case for testing ---
EXAMPLE_CASE = PatientCase(
patient_id="TB-2024-001",
age=62,
sex="Male",
cancer_type="Non-Small Cell Lung Cancer",
histology="Adenocarcinoma",
tnm_t="3",
tnm_n="2",
tnm_m="0",
overall_stage=TumorStage.STAGE_IIIA,
biomarkers=Biomarkers(
pd_l1_tps=65.0,
egfr_mutation="Wild type",
alk_fusion=False,
kras_mutation="G12C",
),
performance_status=PerformanceStatus.PS_1,
comorbidities=["COPD", "Hypertension"],
prior_treatments=[],
clinical_notes=(
"62-year-old male former smoker (30 pack-years, quit 5 years ago) "
"presenting with persistent cough and hemoptysis. CT chest shows "
"4.5cm right upper lobe mass with ipsilateral mediastinal "
"lymphadenopathy (stations 4R, 7). PET-CT confirms FDG-avid "
"primary and nodal disease. No distant metastases. Brain MRI "
"negative. PFTs show FEV1 68% predicted. Biopsy confirms "
"adenocarcinoma, PD-L1 TPS 65%, KRAS G12C positive, EGFR/ALK "
"negative. Patient is motivated and prefers aggressive treatment."
),
)
@app.post("/tumor-board/example")
async def run_example():
"""Run the example NSCLC case through the tumor board."""
plan = await run_tumor_board(EXAMPLE_CASE)
return TumorBoardResponse(
treatment_plan=plan,
specialists_consulted=[
"Medical Oncology",
"Surgical Oncology",
"Radiation Oncology",
"Pathology",
],
)Step 8: Running the System
# Install dependencies
pip install langgraph openai fastapi uvicorn pydantic
# Set API key
export OPENAI_API_KEY="your-key-here"
# Start the server
uvicorn src.app:app --reload --port 8000Test with the example case:
# Run the example NSCLC case
curl -X POST http://localhost:8000/tumor-board/example | python -m json.toolSubmit a custom case:
curl -X POST http://localhost:8000/tumor-board/review \
-H "Content-Type: application/json" \
-d '{
"case": {
"patient_id": "TB-2024-002",
"age": 55,
"sex": "Female",
"cancer_type": "Breast Cancer",
"histology": "Invasive Ductal Carcinoma",
"tnm_t": "2",
"tnm_n": "1",
"tnm_m": "0",
"overall_stage": "IIB",
"biomarkers": {
"her2_status": "Positive (3+)",
"additional": {
"er_status": "Negative",
"pr_status": "Negative"
}
},
"performance_status": "0",
"comorbidities": [],
"prior_treatments": [],
"clinical_notes": "55-year-old female with palpable left breast mass. Mammogram and ultrasound show 3.2cm mass with suspicious axillary lymph node. Core biopsy confirms IDC, Grade 3, HER2 3+ by IHC, ER/PR negative. Sentinel node biopsy positive (1/3). Staging workup negative for distant disease."
}
}'Example output structure:
{
"treatment_plan": {
"primary_recommendation": {
"name": "Neoadjuvant chemo-immunotherapy followed by surgical reassessment",
"modality": "Chemo/Immunotherapy → Surgery",
"intent": "Curative",
"rationale": "Stage IIIA with KRAS G12C and high PD-L1 — combined approach maximizes response and surgical eligibility",
"expected_benefit": "50-60% pathologic response rate with potential for complete resection",
"key_risks": ["Pneumonitis from immunotherapy", "Surgical complications post-chemo"],
"guideline_reference": "NCCN NSCLC v2.2024, CheckMate-816 trial"
},
"alternative_options": [...],
"consensus_points": [
{
"topic": "Neoadjuvant therapy before surgery",
"agreed_approach": "All specialists agree neoadjuvant therapy is appropriate given N2 disease",
"supporting_specialists": ["medical_oncology", "surgical_oncology", "radiation_oncology", "pathology"],
"dissenting_view": null,
"dissenting_specialist": null
},
{
"topic": "Role of KRAS G12C inhibitor",
"agreed_approach": "Consider sotorasib if disease progresses on first-line therapy",
"supporting_specialists": ["medical_oncology", "pathology"],
"dissenting_view": "Limited data in neoadjuvant setting — reserve for progression",
"dissenting_specialist": "surgical_oncology"
}
],
"decision_tree": "1. Start pembrolizumab + platinum doublet x 3 cycles. 2. Restage with CT. 3. IF downstaged to resectable → proceed to lobectomy + mediastinal dissection. 4. IF stable/progressive → definitive concurrent chemoradiation (60 Gy/30 fx). 5. IF complete pathologic response at surgery → adjuvant pembrolizumab x 1 year. 6. IF progression at any point → consider sotorasib (KRAS G12C) or clinical trial.",
"monitoring_plan": "CT chest q6 weeks during neoadjuvant. PFTs before surgery. Brain MRI q3 months for 2 years.",
"clinical_trial_considerations": [
"KRAS G12C + immunotherapy combination trials",
"Perioperative immunotherapy studies for stage III NSCLC"
],
"next_review_trigger": "After 3 cycles of neoadjuvant therapy, restaging CT to assess response and resectability"
}
}Scaling the Pattern: 2 Agents vs 4+ Agents
This project extends the adversarial debate pattern from 2 agents to 4+. Here's what changes:
┌─────────────────────────────────────────────────────────────────────┐
│ SCALING ADVERSARIAL DEBATE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 2-Agent Debate (Bull vs Bear, Diagnosis): │
│ ┌──────────┐ ┌──────────┐ │
│ │ Agent A │ ◄─────► │ Agent B │ Direct opposition │
│ └──────────┘ └──────────┘ Binary structure │
│ │ │ Judge picks winner │
│ └────────┬───────────┘ │
│ ▼ │
│ ┌─────────┐ │
│ │ Judge │ │
│ └─────────┘ │
│ │
│ 4-Agent Debate (Tumor Board): │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Agent A │ │Agent B │ │Agent C │ │Agent D │ Multiple │
│ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ perspectives │
│ │ ╳ │ ╳ │ ╳ │ All-to-all │
│ └─────┬────┴────┬─────┴────┬─────┘ challenges │
│ ▼ ▼ ▼ │
│ ┌───────────────────────┐ │
│ │ Moderator │ Synthesizes, │
│ │ (not just a judge) │ doesn't just pick │
│ └───────────────────────┘ │
│ │
│ KEY DIFFERENCES: │
│ ┌────────────────┬────────────────────────────────────┐ │
│ │ 2-Agent │ 4-Agent │ │
│ ├────────────────┼────────────────────────────────────┤ │
│ │ For/Against │ Multiple complementary perspectives│ │
│ │ Binary winner │ Ranked options with decision tree │ │
│ │ Judge scores │ Moderator synthesizes consensus │ │
│ │ 3 rounds │ 3 phases (present/challenge/plan) │ │
│ │ Sequential │ Parallel fan-out via Send() │ │
│ └────────────────┴────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘| Aspect | 2-Agent Pattern | 4-Agent Pattern |
|---|---|---|
| Structure | Binary opposition | Multi-perspective |
| Goal | Find the stronger argument | Find the optimal plan |
| Output | Winner + synthesis | Ranked options + decision tree |
| Challenge pattern | A→B, B→A | All-to-all (12 possible challenges) |
| LLM calls | 7 (2+2+2+1) | 9 (4+4+1) |
| Parallelism | 2x per round | 4x per phase |
Business Impact
| Metric | Before | After |
|---|---|---|
| Perspectives considered | 1-2 per case | 4 systematically |
| Diagnostic completeness | Missing biomarkers undetected | Pathologist flags gaps |
| Treatment sequencing | Linear plans | Decision trees with contingencies |
| Conference prep time | 30+ min per case | Automated pre-discussion summary |
Key Learnings
-
Parallel fan-out scales naturally —
Send()works the same whether fanning to 2 or 20 agents. Adding a fifth specialist (e.g., radiologist for imaging review) requires only creating the class and adding it toSPECIALISTS. -
Moderator vs Judge — With 2 agents, a judge picks the stronger side. With 4+, a moderator must synthesize multiple valid perspectives into a coherent plan. The moderator role is fundamentally different — it creates new content rather than evaluating existing arguments.
-
Pathology as data guardian — The pathologist doesn't advocate for a treatment modality. Instead, they ensure the data quality that all other specialists depend on. This "meta-agent" pattern (agent that validates other agents' inputs) is broadly applicable.
-
Decision trees over recommendations — Cancer treatment rarely has a single right answer. The output format matters — a conditional decision tree ("if X then Y, otherwise Z") is more useful than a flat recommendation.
Key Concepts Recap
| Concept | What It Is | Why It Matters |
|---|---|---|
| Round-robin debate | Multiple agents each provide perspective, then challenge each other | Captures multi-disciplinary reasoning |
| Parallel fan-out | LangGraph Send() dispatches to all specialists simultaneously | 4x speedup over sequential execution |
| Annotated state accumulation | Annotated[list, operator.add] merges parallel outputs | Prevents last-write-wins when agents run in parallel |
| Moderator pattern | Synthesis agent that builds consensus, not just scores | Produces richer output than binary judging |
| Decision tree output | Conditional treatment pathways with contingency plans | Handles real-world uncertainty and branching scenarios |
| Meta-agent (pathology) | Agent that validates data quality for other agents | Catches missing inputs that would invalidate plans |
| Specialist system prompts | Domain-specific personas with focus areas and guidelines | Each agent reasons from their professional lens |
| Structured clinical models | Pydantic models for TNM staging, biomarkers, opinions | Ensures consistent data flow between agents |
| Phase-based workflow | Present → Challenge → Consensus (3 distinct phases) | Separates opinion generation from conflict resolution |
| All-to-all challenges | Each specialist can challenge any other specialist | Surfaces cross-disciplinary blind spots |
Next Steps
- Add radiologist agent — Include imaging review and response assessment (RECIST criteria)
- NCCN guideline RAG — Vector store of NCCN pathways for evidence-grounded recommendations
- Patient preference integration — Add patient goals and quality-of-life priorities to the moderator
- Longitudinal tracking — Re-run tumor board after treatment milestones (restaging, progression)
- Human-in-the-loop — Use LangGraph
interrupt()to pause for clinician input before consensus
Drug Interaction Arbitrator
Build an adversarial debate agent where one pharmacist argues an interaction is clinically significant while another argues it's manageable, helping reduce alert fatigue
AI-Powered Research Assistant
Build an autonomous agent that conducts market research, analyzes competitors, and generates comprehensive reports