| """LangGraph orchestrator. |
| |
| Day-0 contract: |
| * Builds a graph that classifies the inbound task type and routes to the |
| matching agent node. |
| * Every branch terminates by calling the agent's `mocks.py` so the full |
| flow demos end-to-end while real agents are still being built. |
| |
| When A / B / C ship their real agents, swap the `agent_*_node` body for the |
| real call. The orchestrator itself does not change. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| from typing import Any |
|
|
| import structlog |
|
|
| from app.agents.appointment.mocks import fake_booking |
| from app.agents.clerical.mocks import fake_referral_letter |
| from app.agents.discharge.mocks import fake_discharge |
| from app.agents.documentation.mocks import fake_soap_draft |
| from app.agents.handover.mocks import fake_handover |
| from app.agents.roster.mocks import fake_roster |
| from app.contracts import RoutingDecision, TaskType |
| from app.llm.router import complete |
| from app.state import CliniqState, new_state |
|
|
| log = structlog.get_logger(__name__) |
|
|
|
|
| |
| |
| |
|
|
|
|
| _ROUTE_PROMPT = """You are CliniqAI's orchestrator. Classify the user's request into one of: |
| documentation, appointment, roster, handover, discharge, clerical, wiki_maintenance. |
| |
| Return JSON: {"task": "<one of above>", "confidence": 0.0-1.0, "rationale": "<one short sentence>", "escalate": false} |
| |
| Request: |
| {utterance} |
| """ |
|
|
|
|
| def _route_with_llm(utterance: str) -> RoutingDecision: |
| try: |
| resp = complete( |
| task="routing", |
| messages=[{"role": "user", "content": _ROUTE_PROMPT.format(utterance=utterance)}], |
| json_mode=True, |
| max_tokens=200, |
| ) |
| data = json.loads(resp.text) |
| return RoutingDecision( |
| task=TaskType(data["task"]), |
| confidence=float(data.get("confidence", 0.5)), |
| rationale=str(data.get("rationale", "")), |
| escalate=bool(data.get("escalate", False)), |
| ) |
| except Exception: |
| log.warning("orchestrator.llm_routing_failed_falling_back", exc_info=True) |
| return _route_with_keywords(utterance) |
|
|
|
|
| def _route_with_keywords(utterance: str) -> RoutingDecision: |
| u = utterance.lower() |
| |
| |
| pairs = [ |
| (("handover", "shift change", "shift handover", "ward round"), TaskType.HANDOVER), |
| (("book", "appointment", "reschedule", "cancel", "slot"), TaskType.APPOINTMENT), |
| (("discharge", "going home", "leaving today"), TaskType.DISCHARGE), |
| (("roster", "sick call", "leave request", "shift swap", "on-call"), TaskType.ROSTER), |
| (("referral", "insurance", "faq", "followup", "follow-up"), TaskType.CLERICAL), |
| (("guideline", "wiki", "update protocol"), TaskType.WIKI_MAINT), |
| ] |
| for keywords, t in pairs: |
| if any(k in u for k in keywords): |
| return RoutingDecision(task=t, confidence=0.6, rationale=f"keyword match: {keywords[0]}") |
| return RoutingDecision(task=TaskType.DOCUMENTATION, confidence=0.4, rationale="default → documentation") |
|
|
|
|
| |
| |
| |
|
|
|
|
| def orchestrator_node(state: CliniqState) -> dict[str, Any]: |
| utt = "" |
| if state.get("messages"): |
| utt = state["messages"][-1].get("content", "") |
| decision = _route_with_llm(utt) if utt else RoutingDecision( |
| task=TaskType.DOCUMENTATION, confidence=0.0, rationale="empty input" |
| ) |
| log.info("orchestrator.route", task=decision.task.value, conf=decision.confidence) |
| return {"routing": decision, "task_type": decision.task, "current_agent": "orchestrator"} |
|
|
|
|
| def doc_node(state: CliniqState) -> dict[str, Any]: |
| |
| draft = fake_soap_draft() |
| return {"soap_draft": draft, "current_agent": "documentation"} |
|
|
|
|
| def appt_node(state: CliniqState) -> dict[str, Any]: |
| utt = "" |
| if state.get("messages"): |
| utt = state["messages"][-1].get("content", "") |
| try: |
| from app.agents.appointment.api import handle_inbound_message |
| draft = handle_inbound_message(channel="web", payload={"from": "orchestrator", "body": utt}) |
| return {"communication": draft, "current_agent": "appointment"} |
| except Exception: |
| log.warning("orchestrator.appt_node_fallback", exc_info=True) |
| booking = fake_booking() |
| return {"booking": booking, "current_agent": "appointment"} |
|
|
|
|
| def roster_node(state: CliniqState) -> dict[str, Any]: |
| |
| return {"roster": fake_roster(), "current_agent": "roster"} |
|
|
|
|
| def handover_node(state: CliniqState) -> dict[str, Any]: |
| |
| return {"handover": fake_handover(), "current_agent": "handover"} |
|
|
|
|
| def discharge_node(state: CliniqState) -> dict[str, Any]: |
| patient_id = state.get("patient_id", "p-001") |
| try: |
| from app.agents.discharge.api import initiate_discharge |
| return {"discharge": initiate_discharge(patient_id), "current_agent": "discharge"} |
| except Exception: |
| log.warning("orchestrator.discharge_node_fallback", exc_info=True) |
| return {"discharge": fake_discharge(), "current_agent": "discharge"} |
|
|
|
|
| def clerical_node(state: CliniqState) -> dict[str, Any]: |
| try: |
| from app.agents.clerical.api import draft_referral |
| draft = draft_referral(soap_id="soap-001", receiving_specialist="General") |
| return {"communication": draft, "current_agent": "clerical"} |
| except Exception: |
| log.warning("orchestrator.clerical_node_fallback", exc_info=True) |
| return {"communication": fake_referral_letter(), "current_agent": "clerical"} |
|
|
|
|
| def wiki_maint_node(state: CliniqState) -> dict[str, Any]: |
| |
| return {"current_agent": "wiki_maintenance"} |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _route_branch(state: CliniqState) -> str: |
| r = state.get("routing") |
| if r is None: |
| return TaskType.DOCUMENTATION.value |
| return r.task.value |
|
|
|
|
| def build_graph(): |
| """Build and compile the LangGraph state graph.""" |
| try: |
| from langgraph.graph import END, START, StateGraph |
| except ImportError as e: |
| raise RuntimeError("langgraph is not installed — run `make install`") from e |
|
|
| g = StateGraph(CliniqState) |
| g.add_node("orchestrator", orchestrator_node) |
| g.add_node(TaskType.DOCUMENTATION.value, doc_node) |
| g.add_node(TaskType.APPOINTMENT.value, appt_node) |
| g.add_node(TaskType.ROSTER.value, roster_node) |
| g.add_node(TaskType.HANDOVER.value, handover_node) |
| g.add_node(TaskType.DISCHARGE.value, discharge_node) |
| g.add_node(TaskType.CLERICAL.value, clerical_node) |
| g.add_node(TaskType.WIKI_MAINT.value, wiki_maint_node) |
|
|
| g.add_edge(START, "orchestrator") |
| g.add_conditional_edges("orchestrator", _route_branch, { |
| TaskType.DOCUMENTATION.value: TaskType.DOCUMENTATION.value, |
| TaskType.APPOINTMENT.value: TaskType.APPOINTMENT.value, |
| TaskType.ROSTER.value: TaskType.ROSTER.value, |
| TaskType.HANDOVER.value: TaskType.HANDOVER.value, |
| TaskType.DISCHARGE.value: TaskType.DISCHARGE.value, |
| TaskType.CLERICAL.value: TaskType.CLERICAL.value, |
| TaskType.WIKI_MAINT.value: TaskType.WIKI_MAINT.value, |
| }) |
| for t in TaskType: |
| g.add_edge(t.value, END) |
|
|
| return g.compile() |
|
|
|
|
| def run(utterance: str, session_id: str = "demo") -> CliniqState: |
| """Synchronous one-shot helper for CLI / tests.""" |
| graph = build_graph() |
| state = new_state(session_id) |
| state["messages"] = [{"role": "user", "content": utterance}] |
| return graph.invoke(state) |
|
|
|
|
| if __name__ == "__main__": |
| logging.basicConfig(level=logging.INFO) |
| import sys |
| out = run(sys.argv[1] if len(sys.argv) > 1 else "Please write a SOAP note for today's BP patient.") |
| print(json.dumps({k: str(v) for k, v in out.items() if v is not None}, indent=2)) |
|
|