evd / evd_agent /graph.py
Benedette Otieno
feat: Implement EVD Clinical Screening AI Agent with adaptive questioning and LLM integration
9fe6471
Raw
History Blame Contribute Delete
7.77 kB
from __future__ import annotations
from typing import TypedDict
from langgraph.graph import END, StateGraph
from .context_engine import EpidemiologicalContextEngine
from .llm_client import LLMConfigurationError
from .models import CaseClassification, DecisionOutput, InterviewState, InterviewStatus, LLMInterviewPlan, TurnResult
from .reasoning import ClinicalReasoningAgent
class GraphState(TypedDict):
interview_state: InterviewState
clinician_input: str
llm_plan: LLMInterviewPlan | None
llm_error: str | None
state_updates: dict[str, str]
class AgentGraphRunner:
def __init__(self, context_engine: EpidemiologicalContextEngine) -> None:
self.reasoner = ClinicalReasoningAgent(context_engine)
self.graph = self._build_graph()
def _build_graph(self):
graph = StateGraph(GraphState)
graph.add_node("ingest_input", self._ingest_input)
graph.add_node("llm_reason", self._llm_reason)
graph.add_node("compose_response", self._compose_response)
graph.set_entry_point("ingest_input")
graph.add_edge("ingest_input", "llm_reason")
graph.add_edge("llm_reason", "compose_response")
graph.add_edge("compose_response", END)
return graph.compile()
def run(self, state: InterviewState, clinician_input: str) -> TurnResult:
output = self.graph.invoke(
{
"interview_state": state,
"clinician_input": clinician_input,
"llm_plan": None,
"llm_error": None,
"state_updates": {},
}
)
interview_state = output["interview_state"]
next_key = interview_state.pending_question_key
if interview_state.decision.should_stop_interview:
assistant_message = self._alert_message(interview_state, output.get("llm_plan"))
interview_state.status = InterviewStatus.COMPLETE
elif output["llm_error"]:
assistant_message = f"LLM configuration error: {output['llm_error']}"
else:
# Surface the LLM's own reasoning summary before the next question
reasoning_prefix = ""
llm_plan: LLMInterviewPlan | None = output.get("llm_plan")
if llm_plan and llm_plan.summary_known:
reasoning_prefix = f"{llm_plan.summary_known}\n\n"
next_q = interview_state.pending_question_text or "Please provide the most important missing clinical detail."
assistant_message = f"{reasoning_prefix}{next_q}"
return TurnResult(
assistant_message=assistant_message,
decision=interview_state.decision,
risk_profile=interview_state.risk_profile,
next_question_key=next_key,
llm_summary=interview_state.llm_summary,
state_updates=output["state_updates"],
)
def _ingest_input(self, graph_state: GraphState) -> GraphState:
return graph_state
def _llm_reason(self, graph_state: GraphState) -> GraphState:
interview_state = graph_state["interview_state"]
clinician_input = graph_state["clinician_input"]
try:
plan = self.reasoner.reason(interview_state, clinician_input)
except LLMConfigurationError as error:
graph_state["llm_error"] = str(error)
interview_state.llm_summary = str(error)
interview_state.pending_question_key = None
interview_state.pending_question_text = None
return graph_state
graph_state["llm_plan"] = plan
interview_state.llm_summary = plan.summary_known
interview_state.missing_evidence = list(plan.missing_evidence)
interview_state.rationale_log.append(plan.reasoning)
interview_state.rationale_log.extend(plan.evidence_statements)
updates = plan.fact_updates.model_dump(exclude_none=True)
for key, value in updates.items():
setattr(interview_state.facts, key, value)
graph_state["state_updates"][key] = str(value)
interview_state.pending_question_key = None
interview_state.decision = self._decision_from_llm_plan(plan)
if plan.should_stop_interview:
interview_state.pending_question_text = None
return graph_state
# Enforce MOH 3-follow-up question hard limit
MAX_FOLLOWUP_QUESTIONS = 3
if plan.next_question and interview_state.followup_question_count < MAX_FOLLOWUP_QUESTIONS:
interview_state.pending_question_text = plan.next_question
interview_state.asked_questions.add(plan.next_question)
interview_state.followup_question_count += 1
elif plan.next_question and interview_state.followup_question_count >= MAX_FOLLOWUP_QUESTIONS:
# Hard stop: max follow-ups reached without meeting a case definition
interview_state.pending_question_text = None
interview_state.decision.should_stop_interview = True
interview_state.decision.classification = CaseClassification.NOT_SUSPECTED
interview_state.decision.triggered_rule = "Maximum of 3 follow-up questions reached with insufficient evidence."
interview_state.decision.recommended_action = (
"Available information does not meet current MOH Ebola case definitions. "
"Escalate for clinician review if concern persists."
)
else:
interview_state.pending_question_text = None
return graph_state
def _compose_response(self, graph_state: GraphState) -> GraphState:
return graph_state
@staticmethod
def _decision_from_llm_plan(plan: LLMInterviewPlan) -> DecisionOutput:
classification = plan.classification or CaseClassification.NOT_SUSPECTED
evidence = plan.criteria_matched or list(plan.evidence_statements)
triggered_rule = plan.triggered_rule or "No MOH case definition met yet."
recommended_action = plan.recommended_action or "Continue focused evidence collection."
return DecisionOutput(
classification=classification,
triggered_rule=triggered_rule,
evidence=evidence,
recommended_action=recommended_action,
confidence=plan.confidence,
should_stop_interview=plan.should_stop_interview,
)
@staticmethod
def _alert_message(interview_state: InterviewState, llm_plan: LLMInterviewPlan | None = None) -> str:
decision = interview_state.decision
classification = decision.classification.value
llm_reasoning = ""
if llm_plan and llm_plan.reasoning:
llm_reasoning = f"**Clinical Reasoning:**\n{llm_plan.reasoning}\n\n"
if decision.classification == CaseClassification.NOT_SUSPECTED:
missing = "\n".join(f"- {item}" for item in interview_state.missing_evidence) or "- None identified"
return (
f"**Classification: No Case**\n\n"
f"{llm_reasoning}"
f"The available information does not meet the current Kenya MOH Ebola case definitions.\n\n"
f"**Reasoning:** {decision.triggered_rule}\n\n"
f"**Missing or unclear information:**\n{missing}\n\n"
f"**Recommended Action:** {decision.recommended_action}"
)
evidence = "\n".join(f"- {item}" for item in decision.evidence)
return (
f"🚨 {classification.upper()} ALERT\n\n"
f"{llm_reasoning}"
f"**Criteria Matched:**\n{evidence}\n\n"
f"**Reasoning:**\n{decision.triggered_rule}\n\n"
f"**Recommended Action:**\n{decision.recommended_action}"
)