feat: Add CuraFlow AI Scribe and Coder agents
This commit is contained in:
parent
a9303b0f74
commit
6436db475d
|
|
@ -0,0 +1,43 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: curaflow-agent
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: curaflow-agent
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: curaflow-agent
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: curaflow-agent
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: worker
|
||||||
|
image: 192.168.8.250:5000/agents-runtime:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
command: ["python", "-m", "curaflow_agent.worker"]
|
||||||
|
env:
|
||||||
|
- name: PYTHONPATH
|
||||||
|
value: "/app/src"
|
||||||
|
- name: PYTHONUNBUFFERED
|
||||||
|
value: "1"
|
||||||
|
- name: TEMPORAL_URL
|
||||||
|
value: "temporal-frontend.ai-core.svc.cluster.local:7233"
|
||||||
|
- name: TEMPORAL_NAMESPACE
|
||||||
|
value: "default"
|
||||||
|
- name: CURAFLOW_TASK_QUEUE
|
||||||
|
value: "curaflow-tasks"
|
||||||
|
- name: OLLAMA_BASE_URL
|
||||||
|
value: "http://ollama.ai-core.svc.cluster.local:11434/v1"
|
||||||
|
- name: LLM_MODEL
|
||||||
|
value: "qwen2.5:3b"
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
resources: []
|
resources:
|
||||||
|
- curaflow-agent/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from temporalio import activity
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from langchain_core.prompts import PromptTemplate
|
||||||
|
from .models import ClinicalNote, BillingCodesOutput
|
||||||
|
|
||||||
|
# In the cluster, Ollama provides an OpenAI-compatible endpoint.
|
||||||
|
# We fetch model/URL from env vars so it's easy to override for testing.
|
||||||
|
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://ollama.ai-core.svc.cluster.local:11434/v1")
|
||||||
|
LLM_MODEL = os.environ.get("LLM_MODEL", "qwen2.5:3b")
|
||||||
|
|
||||||
|
def get_llm():
|
||||||
|
return ChatOpenAI(
|
||||||
|
model=LLM_MODEL,
|
||||||
|
api_key="ollama", # Ollama doesn't strictly need an API key
|
||||||
|
base_url=OLLAMA_BASE_URL,
|
||||||
|
temperature=0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
@activity.defn
|
||||||
|
async def structure_note_activity(raw_dictation: str) -> dict:
|
||||||
|
"""
|
||||||
|
Takes raw doctor's dictation and uses an LLM to extract a structured ClinicalNote payload.
|
||||||
|
"""
|
||||||
|
llm = get_llm()
|
||||||
|
structured_llm = llm.with_structured_output(ClinicalNote)
|
||||||
|
|
||||||
|
prompt = PromptTemplate.from_template(
|
||||||
|
"You are an expert AI Medical Scribe. Extract the clinical information from the following raw dictation and structure it perfectly.\n\n"
|
||||||
|
"Dictation:\n{dictation}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = prompt | structured_llm
|
||||||
|
result: ClinicalNote = await chain.ainvoke({"dictation": raw_dictation})
|
||||||
|
|
||||||
|
# Return as dict so Temporal can serialize it natively
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
|
@activity.defn
|
||||||
|
async def generate_billing_codes_activity(clinical_note_dict: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Takes a structured ClinicalNote and uses an LLM to suggest ICD-10 and CPT codes.
|
||||||
|
"""
|
||||||
|
llm = get_llm()
|
||||||
|
structured_llm = llm.with_structured_output(BillingCodesOutput)
|
||||||
|
|
||||||
|
# Convert dict to nicely formatted string for the prompt
|
||||||
|
note_str = json.dumps(clinical_note_dict, indent=2)
|
||||||
|
|
||||||
|
prompt = PromptTemplate.from_template(
|
||||||
|
"You are an expert Medical Coder. Review the following structured clinical note and identify all applicable ICD-10 diagnosis codes and CPT procedure codes.\n"
|
||||||
|
"Provide a clear justification for each code based solely on the provided clinical note.\n\n"
|
||||||
|
"Clinical Note:\n{note}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = prompt | structured_llm
|
||||||
|
result: BillingCodesOutput = await chain.ainvoke({"note": note_str})
|
||||||
|
|
||||||
|
return result.model_dump()
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
class ClinicalNote(BaseModel):
|
||||||
|
chief_complaint: str = Field(description="The primary reason for the patient's visit.")
|
||||||
|
history_of_present_illness: str = Field(description="Detailed narrative of the patient's current symptoms and history.")
|
||||||
|
past_medical_history: List[str] = Field(description="List of known past medical conditions.")
|
||||||
|
medications: List[str] = Field(description="List of current medications the patient is taking.")
|
||||||
|
allergies: List[str] = Field(description="List of known allergies.")
|
||||||
|
assessment: str = Field(description="The physician's diagnosis or assessment of the patient's condition.")
|
||||||
|
plan: str = Field(description="The proposed treatment plan or next steps.")
|
||||||
|
|
||||||
|
class BillingCode(BaseModel):
|
||||||
|
code_type: str = Field(description="Either 'ICD-10' or 'CPT'")
|
||||||
|
code: str = Field(description="The specific alphanumeric code (e.g., J45.909, 99213)")
|
||||||
|
description: str = Field(description="Brief description of what the code represents")
|
||||||
|
justification: str = Field(description="Explanation of why this code was selected based on the clinical note")
|
||||||
|
|
||||||
|
class BillingCodesOutput(BaseModel):
|
||||||
|
codes: List[BillingCode] = Field(description="List of all applicable billing codes for the visit.")
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from temporalio.client import Client
|
||||||
|
from temporalio.worker import Worker
|
||||||
|
|
||||||
|
# Configure basic logging so we can see output in Kubernetes logs
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from curaflow_agent.workflows import ClinicalIntakeWorkflow
|
||||||
|
from curaflow_agent.activities import structure_note_activity, generate_billing_codes_activity
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
temporal_url = os.environ.get("TEMPORAL_URL", "temporal-frontend.ai-core.svc.cluster.local:7233")
|
||||||
|
temporal_namespace = os.environ.get("TEMPORAL_NAMESPACE", "default")
|
||||||
|
task_queue = os.environ.get("CURAFLOW_TASK_QUEUE", "curaflow-tasks")
|
||||||
|
|
||||||
|
logger.info(f"Connecting to Temporal cluster at {temporal_url} (Namespace: {temporal_namespace})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await Client.connect(temporal_url, namespace=temporal_namespace)
|
||||||
|
logger.info("Successfully connected to Temporal!")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to Temporal: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
worker = Worker(
|
||||||
|
client,
|
||||||
|
task_queue=task_queue,
|
||||||
|
workflows=[ClinicalIntakeWorkflow],
|
||||||
|
activities=[structure_note_activity, generate_billing_codes_activity],
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Starting CuraFlow agent worker on queue '{task_queue}'...")
|
||||||
|
await worker.run()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
from temporalio import workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
from .activities import structure_note_activity, generate_billing_codes_activity
|
||||||
|
|
||||||
|
@workflow.defn
|
||||||
|
class ClinicalIntakeWorkflow:
|
||||||
|
@workflow.run
|
||||||
|
async def run(self, raw_dictation: str) -> dict:
|
||||||
|
"""
|
||||||
|
Takes raw doctor's dictation, extracts structured clinical data,
|
||||||
|
and generates associated billing codes.
|
||||||
|
Returns the combined payload.
|
||||||
|
"""
|
||||||
|
# Step 1: AI Scribe (Structure the dictation)
|
||||||
|
structured_note = await workflow.execute_activity(
|
||||||
|
structure_note_activity,
|
||||||
|
raw_dictation,
|
||||||
|
start_to_close_timeout=timedelta(minutes=10)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Medical Coder (Generate ICD-10/CPT codes)
|
||||||
|
billing_codes = await workflow.execute_activity(
|
||||||
|
generate_billing_codes_activity,
|
||||||
|
structured_note,
|
||||||
|
start_to_close_timeout=timedelta(minutes=10)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Return the final, combined object
|
||||||
|
# Note: We are returning it directly here so it's visible in the Temporal UI.
|
||||||
|
# In a real integration, we would trigger a 3rd activity here to POST this
|
||||||
|
# JSON payload to the client's BYOD (Bring Your Own Database) API endpoint.
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"clinical_note": structured_note,
|
||||||
|
"billing_codes": billing_codes
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue