diff --git a/.gitignore b/.gitignore index c521112..586e238 100644 --- a/.gitignore +++ b/.gitignore @@ -403,4 +403,7 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml -.idea/ \ No newline at end of file +.idea/ + +.DS_Store +**/.venv/ diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/README.md b/samples/DragonCopilot/Workflow/pythonSampleExtension/README.md new file mode 100644 index 0000000..9ea971f --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/README.md @@ -0,0 +1,166 @@ +## Dragon Copilot Python Sample Extension + +A Python FastAPI implementation that mirrors the C# `SampleExtension.Web` for Dragon Copilot. It demonstrates: + +- Clinical entity heuristic extraction (vitals, diabetes code, medication concept) +- DSP-style `ProcessResponse` with multiple payload keys +- Adaptive Card visualization via the `adaptive-card` payload +- Comparison tooling against the C# service +- Section-based test payload simulating real clinical note structure + +> Disclaimer: This is a learning/sample artifact – not production hardened. Do **not** use with real PHI. Authentication is intentionally disabled for development. + +--- +## 📚 Contents + +- [Dragon Copilot Python Sample Extension](#dragon-copilot-python-sample-extension) + - [1. Features](#1-features) + - [2. Quick Start](#2-quick-start) + - [2.1 Quick Start for Linux and Mac](#21-quick-start-for-linux-and-mac) + - [2.2 Quick Start for Windows](#22-quick-start-for-windows) + - [3. Access the Swagger / OpenAPI](#3-access-the-swagger--openapi) + - [4. Testing APIs with Sample Requests](#4-testing-apis-with-sample-requests) + - [4.1 Testing APIs for Linux / Mac](#41-testing-apis-for-linux--mac) + - [4.2 Testing APIs for Windows](#42-testing-apis-for-windows) + - [5. Response Structure Example](#5-response-structure-example) + - [6. Deploying Your Extension](#6-deploying-your-extension) + - [7. License](#7-license) + +--- +## 1. Features +**Implemented** +- `/health` & `/v1/health` endpoints +- `/v1/process` returning: + - `sample-entities` + - `adaptive-card` + +--- +## 2. Quick Start + +**Choosing Python 3.12**\ +We recommend Python 3.12 over 3.14 in this python sample extension, as a number of ML/AI (especially OSS) Python SDKs do not yet fully support 3.14, which may lead to avoidable integration issues. + +**Starting Directory**\ +From `pythonSampleExtension` dir: + +### 2.1 Quick Start for Linux and Mac +Ensure `python3.12` is installed and can be executed from your cmd shell as `python3.12`. + +Run the following cmds in bash/zsh to start server. +```shell +# 1. change to the pythonSampleExtension directory +cd ./samples/DragonCopilot/Workflow/pythonSampleExtension; + +# 2. create venv, activate venv and install packages +python3.12 -m venv .venv && source .venv/bin/activate && python3.12 -m pip install --upgrade pip && python3.12 -m pip install -r requirements.txt; + +# 3. start server with uvicorn invocation +python3.12 -m uvicorn app.main:app --host 0.0.0.0 --port 5181 --reload +``` + +### 2.2 Quick Start for Windows +Ensure `python3.12` is installed over Microsoft Store and can be executed from your powershell as `python3.12`. + +Run the following cmds in the Powershell to start server. +```powershell +# 1. change to the pythonSampleExtension directory +cd .\samples\DragonCopilot\Workflow\pythonSampleExtension; + +# 2. create venv, activate venv and install packages +python3.12 -m venv .venv; . .\.venv\Scripts\Activate.ps1; python3.12 -m pip install --upgrade pip; python3.12 -m pip install -r requirements.txt; + +# 3. start server with uvicorn invocation +python3.12 -m uvicorn app.main:app --host 0.0.0.0 --port 5181 --reload +``` + +## 3 Access the Swagger / OpenAPI +After server start, you shall be able to access the python workflow sample server via Swagger / OpenAPI from your browser with the: `http://localhost:5181/docs` + +--- +## 4. Testing APIs with Sample Requests +After server started successfully, you can test the python workflow samples from commandline. + +### 4.1 Testing APIs for Linux / Mac +**Health API**: +```shell +curl -s http://localhost:5181/health | jq +curl -s http://localhost:5181/v1/health | jq +``` + +**Process API**: +Minimal process payload: +```shell +curl -s -X POST http://localhost:5181/v1/process \ + -H 'Content-Type: application/json' \ + -d '{"note":{"resources":[{"content":"Patient has history of diabetes and currently taking metformin. BP recorded."}]}}' | jq +``` + +Include IDs: +```shell +curl -s -X POST http://localhost:5181/v1/process \ + -H 'Content-Type: application/json' \ + -H 'x-ms-request-id: demo-req-1' \ + -H 'x-ms-correlation-id: demo-corr-1' \ + -d '{"note":{"resources":[{"content":"BP 145/98 mmHg; Diabetes risk; taking metformin"}]}}' | jq +``` + +### 4.2 Testing APIs for Windows +**Health API**: +```powershell +Invoke-RestMethod -Uri "http://localhost:5181/health" | ConvertTo-Json -Depth 5 +Invoke-RestMethod -Uri "http://localhost:5181/v1/health" | ConvertTo-Json -Depth 5 +``` + +**Process API**: +Minimal process payload: +```powershell +Invoke-RestMethod -Uri "http://localhost:5181/v1/process" -Method Post -ContentType "application/json" -Body '{"note":{"resources":[{"content":"Patient has history of diabetes and currently taking metformin. BP recorded."}]}}' | ConvertTo-Json -Depth 10 +``` + +Include IDs: +```powershell +Invoke-RestMethod -Uri "http://localhost:5181/v1/process" -Method Post -ContentType "application/json" -Headers @{"x-ms-request-id"="demo-req-1"; "x-ms-correlation-id"="demo-corr-1"} -Body '{"note":{"resources":[{"content":"BP 145/98 mmHg; Diabetes risk; taking metformin"}]}}' | ConvertTo-Json -Depth 10 +``` + +--- +## 5. Response Structure Example +You shall see the workflow sample server returns response similar to the following response structure. + +```json +{ + "success": true, + "message": "Payload processed successfully", + "payload": { + "sample-entities": { + "schema_version": "0.1", + "resources": [ { "type": "ObservationNumber" }, { "type": "MedicalCode" } ] + }, + "adaptive-card": { + "schema_version": "0.1", + "resources": [ + { + "id": "card1", + "type": "AdaptiveCard", + "subtype": "note", + "adaptive_card_payload": { + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.6", + "body": [] + }, + "payloadSources": [], + "dragonCopilotCopyData": "metadata_for_platform" + } + ] + } + } +} +``` +--- +## 6. Deploying Your Extension + +The steps above cover running and testing the Python sample extension locally. To fully deploy your extension—including setting up DevTunnels, packaging with the dragon-extension CLI, registering in Azure, uploading to the Dragon Admin Center, and testing inside Dragon Copilot—follow the instructions in the repository root [QUICKSTART.md](../../../../QUICKSTART.md). + +--- +## 7. License +See root `LICENSE`. diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/app/.env b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/.env new file mode 100644 index 0000000..c55bcfc --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/.env @@ -0,0 +1,21 @@ +# NOTE: These environment variables are placeholders for Azure AI Foundry integration. +# They do NOT use the DGEXT_ prefix required by Settings and are loaded separately +# by future AI agent/client code (not by pydantic-settings). +# +# This file is committed to source control ONLY as a documented example. +# Do NOT put real secrets or production endpoints here. +# Instead, create a local untracked `.env` file (listed in .gitignore) or use +# your environment manager to define these values outside of version control. +# +# PROJECT_ENDPOINT is from the new AI Foundry (project) / Azure AI Service (new version) +# Example: +# PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +# +# MODEL_DEPLOYMENT_NAME is the name of your deployed model in Azure AI Foundry. +# Example: +# MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +# +# AGENT_ID is the identifier of the AI agent you want to invoke. +# Example: +# AGENT_ID="asst_xxxxxxx" + diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/app/__init__.py b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/__init__.py new file mode 100644 index 0000000..af9bf10 --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/__init__.py @@ -0,0 +1 @@ +"""Python FastAPI sample extension mirroring C# SampleExtension.Web.""" diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/app/config.py b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/config.py new file mode 100644 index 0000000..498b9c4 --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/config.py @@ -0,0 +1,13 @@ +from functools import lru_cache +from pydantic_settings import BaseSettings, SettingsConfigDict + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="DGEXT_") + + app_name: str = "Dragon Sample Extension (Python)" + version: str = "0.1.0" + # enable_auth: bool = False # Placeholder toggle — not referenced anywhere yet; uncomment when auth middleware is wired up + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/app/main.py b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/main.py new file mode 100644 index 0000000..7e8b4e6 --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/main.py @@ -0,0 +1,80 @@ +from fastapi import FastAPI, Header, HTTPException, Request +# RequestValidationError import kept for reference; handler is commented out below +# from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, RedirectResponse +from .models import DragonStandardPayload, ProcessResponse +from .service import ProcessingService +from datetime import datetime, timezone +from .config import get_settings +import logging + +logger = logging.getLogger("dragon.pyextension") +logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s %(name)s - %(message)s") + +settings = get_settings() +app = FastAPI(title=settings.app_name, version=settings.version) +service = ProcessingService() + +@app.middleware("http") +async def header_logging_middleware(request: Request, call_next): # basic structured log of tracing headers + req_id = request.headers.get("x-ms-request-id") + corr_id = request.headers.get("x-ms-correlation-id") + logger.info("Incoming %s %s req_id=%s corr_id=%s", request.method, request.url.path, req_id, corr_id) + try: + response = await call_next(request) + except Exception as exc: # noqa: BLE001 + logger.exception("Unhandled exception processing request") + return JSONResponse(status_code=500, content={"success": False, "error": "Internal server error"}) + return response + +@app.get("/", include_in_schema=False) +async def root_redirect(): + # Mirror C# swagger root exposure + return RedirectResponse(url="/docs") + +@app.get("/health") +async def root_health(): + return {"status": "healthy", "version": settings.version} + +@app.get("/v1/health") +async def versioned_health(): + return { + "service": settings.app_name, + "status": "healthy", + "version": settings.version, + "endpoints": {"process": "/v1/process", "health": "/v1/health"} + } + +# @app.exception_handler(RequestValidationError) +# async def validation_exception_handler(request: Request, exc: RequestValidationError): # noqa: D401 +# """Return structured details for 422 errors and log them for diagnostics.""" +# logger.warning( +# "Validation failed on %s %s errors=%s", request.method, request.url.path, exc.errors() +# ) +# return JSONResponse( +# status_code=422, +# content={ +# "success": False, +# "message": "Request validation failed", +# "errors": exc.errors(), +# }, +# ) + +@app.post("/v1/process", response_model=ProcessResponse) +async def process_endpoint( + payload: DragonStandardPayload, + x_ms_request_id: str | None = Header(default=None, alias="x-ms-request-id"), + x_ms_correlation_id: str | None = Header(default=None, alias="x-ms-correlation-id"), +): + try: + start_time = datetime.now(timezone.utc) + logger.info("Processing incoming request at %s", start_time) + resp = service.process(payload, x_ms_request_id, x_ms_correlation_id) + elapsed = datetime.now(timezone.utc) - start_time + logger.info("Request processed in %s", elapsed) + return resp + except HTTPException: + raise + except Exception: # noqa: BLE001 + logger.exception("Processing failure") + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/app/models.py b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/models.py new file mode 100644 index 0000000..1aba211 --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/models.py @@ -0,0 +1,80 @@ +from __future__ import annotations +from typing import Any, Dict, List, Optional +from pydantic import BaseModel, Field +from enum import Enum + +# Expanded model layer to better mirror the C# sample (not full parity but structurally closer) + +class Priority(str, Enum): + High = "High" + Medium = "Medium" + Low = "Low" + +class ObservationValue(BaseModel): + text: Optional[str] = None + conceptId: Optional[str] = None + +class BaseResource(BaseModel): + id: Optional[str] = None + +class MedicalCode(BaseResource): + type: str = Field("MedicalCode", frozen=True) + code: Dict[str, Any] | None = None + priority: Optional[Priority] = None + reason: Optional[str] = None + +class ObservationNumber(BaseResource): + type: str = Field("ObservationNumber", frozen=True) + value: Optional[float] = None + valueUnit: Optional[str] = None + priority: Optional[Priority] = None + +class ObservationConcept(BaseResource): + type: str = Field("ObservationConcept", frozen=True) + value: Optional[ObservationValue] = None + priority: Optional[Priority] = None + +class VisualizationResource(BaseResource): + type: str = Field("AdaptiveCard", frozen=True) + subtype: str | None = None + cardTitle: str | None = None + adaptive_card_payload: Any | None = None + payloadSources: List[Dict[str, Any]] | None = None + dragonCopilotCopyData: str | None = None + partnerLogo: str | None = None + references: List[Dict[str, Any]] | None = None + +class NoteResource(BaseModel): + content: Optional[str] = None + +## subtype of note shall be lower case 'note' +# _lower_alias is not currently used because the alias config on Note is +# commented out. Uncomment when alias generation is re-enabled. +# def _lower_alias(field_name: str) -> str: +# """Generate lowercase aliases for JSON serialization.""" +# return field_name.lower() + +class Note(BaseModel): + # Ensure all fields serialize with lowercase keys (even if Python attribute had capitals) + # model_config = ConfigDict(alias_generator=_lower_alias, populate_by_name=True) + # # Explicit type indicator (commonly used in DSP resources) kept lowercase per comment + # type: str = Field(default="note", frozen=True) + document: Dict[str, Any] | None = None + resources: List[NoteResource] | None = None + +class SessionData(BaseModel): + sessionId: Optional[str] = None + +class DspResponse(BaseModel): + schema_version: str | None = None + document: Dict[str, Any] | None = None + resources: List[Any] = Field(default_factory=list) + +class DragonStandardPayload(BaseModel): + note: Optional[Note] = None + sessionData: SessionData | None = None + +class ProcessResponse(BaseModel): + success: bool = False + message: Optional[str] = None + payload: Dict[str, DspResponse | Any] = Field(default_factory=dict) diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/app/service.py b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/service.py new file mode 100644 index 0000000..c342fe8 --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/service.py @@ -0,0 +1,311 @@ +"""Processing logic replicating simplified entity extraction from C# sample.""" +from __future__ import annotations +from typing import Any, Dict, List +from uuid import uuid4 +from datetime import datetime, timezone +from . import models +import logging + +KEYWORD_SETS = { + "BLOOD PRESSURE": ["BLOOD PRESSURE", "BP"], + "DIABETES": ["DIABETES", "DIABETIC"], + "MEDICATION": ["MEDICATION", "PRESCRIBED", "TAKING", "METFORMIN"], +} + +EXTENSION_PREFIX = "Dragon Predict" + + +logger = logging.getLogger("dragon.pyextension") + +class ProcessingService: + def process(self, payload: models.DragonStandardPayload, request_id: str | None, correlation_id: str | None) -> models.ProcessResponse: + response = models.ProcessResponse(success=True, message="Payload processed successfully") + + if payload.note: + # Structured logging; avoid eager f-string serialization + try: + logger.info("note model repr: %s", payload.note) + # logger.info( + # "note model json: %s", + # payload.note.model_dump_json(by_alias=True, exclude_none=True), + # ) + except Exception: # noqa: BLE001 + logger.exception("Failed to log note model") + + sample_entities, adaptive_card = self._process_note(payload.note) + response.payload["sample-entities"] = sample_entities + response.payload["adaptive-card"] = adaptive_card + # NOTE: "samplePluginResult" output is not currently supported by the + # consuming application and has been removed from the response. + # Uncomment the line below (and restore the composite logic in + # _process_note) when samplePluginResult support is re-enabled. + # response.payload["samplePluginResult"] = composite_plugin + logger.info("extension response:\n %s", response) + + # TODO: use the payload fields to call out to AI Agents + + + # placeholder for future: transcript / iterative transcript / audio handling + + return response + + def _process_note(self, note: models.Note): + resources: List[Any] = [] + + if note.resources: + for r in note.resources: + content = r.content + if not content: + continue + upper = content.upper() + if any(k in upper for k in KEYWORD_SETS["BLOOD PRESSURE"]): + resources.append(self._vital_sign(145.0, "mmHg")) + if any(k in upper for k in KEYWORD_SETS["DIABETES"]): + resources.append(self._medical_code("E11.9", "Type 2 diabetes mellitus without complications")) + if any(k in upper for k in KEYWORD_SETS["MEDICATION"]): + resources.append(self._observation_concept("Prescription medication detected", "medication-concept-001")) + + dsp_entities = models.DspResponse( + schema_version="0.1", + document=note.document, + resources=resources, + ) + + adaptive_card_resource = self._adaptive_card(resources) + adaptive_card = models.DspResponse( + schema_version="0.1", + document=note.document, + resources=[adaptive_card_resource], + ) + + # NOTE: Composite plugin result (samplePluginResult) is not currently + # supported by the consuming application. The composite construction + # below has been removed. To re-enable, uncomment this block and update + # _process_note to return (dsp_entities, adaptive_card, composite). + # Also uncomment _composite_medication_summary and _timeline_card below. + # + # composite = models.DspResponse( + # schema_version="0.1", + # document={ + # "title": note.document.get("title") if note.document else "Clinical Note Analysis", + # "type": (note.document or {}).get("type") if note.document else {"text": "note"} + # }, + # resources=[self._composite_medication_summary(resources), self._timeline_card(resources)], + # ) + + return dsp_entities, adaptive_card + + def _medical_code(self, code_value: str, description: str) -> models.MedicalCode: + return models.MedicalCode( + id=str(uuid4()), + code={ + "identifier": code_value, + "description": description, + "system": "ICD-10-CM", + "systemUrl": "http://hl7.org/fhir/sid/icd-10-cm" + }, + priority=models.Priority.Medium, + reason="Detected from clinical documentation" + ) + + def _vital_sign(self, value: float, unit: str) -> models.ObservationNumber: + return models.ObservationNumber( + id=str(uuid4()), + value=value, + valueUnit=unit, + priority=models.Priority.High, + ) + + def _observation_concept(self, concept_text: str, concept_id: str) -> models.ObservationConcept: + return models.ObservationConcept( + id=str(uuid4()), + value=models.ObservationValue(text=concept_text, conceptId=concept_id), + priority=models.Priority.Medium, + ) + + def _adaptive_card(self, entities: List[Any]) -> models.VisualizationResource: + # Build card body similar in spirit to C# version + body: List[Dict[str, Any]] = [ + { + "type": "TextBlock", + "text": "🔍 Clinical Entities Extracted", + "weight": "Bolder", + "size": "Default" + }, + { + "type": "TextBlock", + "text": f"Found {len(entities)} clinical {'entity' if len(entities)==1 else 'entities'} in the note", + "wrap": True, + "size": "Default", + "spacing": "Small" + }, + ] + if entities: + for e in entities: + etype = getattr(e, 'type', 'Entity') + eid = getattr(e, 'id', '') + body.append({ + "type": "Container", + "style": "emphasis", + "spacing": "Medium", + "items": [ + {"type": "TextBlock", "text": f"**{etype}**", "weight": "Bolder", "size": "Default"}, + {"type": "TextBlock", "text": eid, "size": "Small", "wrap": True}, + ], + }) + else: + body.append({ + "type": "Container", + "style": "attention", + "items": [ + {"type": "TextBlock", "text": "ℹ️ No clinical entities were detected in this note.", "wrap": True} + ], + }) + body.append({ + "type": "TextBlock", + "text": f"Processed at {datetime.now(timezone.utc).isoformat()}", + "size": "Small", + "spacing": "Medium", + }) + + # TODO: add extension prefix to title + return models.VisualizationResource( + id=str(uuid4()), + subtype="note", + cardTitle=EXTENSION_PREFIX, + adaptive_card_payload={ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.6", + "body": body, + "actions": [ + { + "type": "Action.Execute", + "title": "Append to note", + "verb": "appendToNoteSection", + "id": "appendToNoteSectionAction", + "data": { + "dragonAppendContent": "appended text content" + } + }, + { + "type": "Action.Execute", + "title": "Dismiss", + "verb": "reject", + "id": "rejectAction", + "data": { + "dragonExtensionToolName": "RejectCardTool" + } + } + ] + }, + payloadSources=[ + { + "identifier": str(uuid4()), + "description": "Sample Extension Clinical Entity Extractor (Python)", + "url": "http://localhost:5181/v1/process" + } + ], + dragonCopilotCopyData="Clinical entities extracted from note content", + partnerLogo="https://contoso.com/logo.png", + references=[], + ) + + # NOTE: _composite_medication_summary and _timeline_card are not currently + # used because the samplePluginResult output is not supported by the + # consuming application. Uncomment these methods (and the composite + # construction in _process_note) when samplePluginResult support is + # re-enabled. + # + # def _composite_medication_summary(self, entities: List[Any]) -> models.VisualizationResource: + # # Simplified medication summary card (parity style example) + # body: List[Dict[str, Any]] = [ + # { + # "type": "Container", + # "spacing": "Medium", + # "items": [ + # {"type": "TextBlock", "text": "Patient Medication Analysis", "weight": "Bolder", "size": "Default", "spacing": "None"}, + # {"type": "TextBlock", "text": "Demo analysis based on detected entities", "size": "Small", "spacing": "Small", "wrap": True} + # ] + # } + # ] + # fact_items: List[Dict[str, str]] = [] + # med_count = sum(1 for e in entities if getattr(e, 'type', None) == 'ObservationConcept') + # condition_count = sum(1 for e in entities if getattr(e, 'type', None) == 'MedicalCode') + # vital_count = sum(1 for e in entities if getattr(e, 'type', None) == 'ObservationNumber') + # fact_items.append({"title": "Medications Detected:", "value": f"{med_count}"}) + # fact_items.append({"title": "Conditions Detected:", "value": f"{condition_count}"}) + # fact_items.append({"title": "Vitals Detected:", "value": f"{vital_count}"}) + # body.append({ + # "type": "FactSet", + # "facts": fact_items + # }) + # + # return models.VisualizationResource( + # id=str(uuid4()), + # subtype="note", + # cardTitle="Medication Summary & Recommendations (Demo)", + # adaptive_card_payload={ + # "type": "AdaptiveCard", + # "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + # "version": "1.6", + # "body": body, + # "actions": [ + # { + # "type": "Action.Execute", + # "title": "Dismiss", + # "verb": "reject", + # "id": "rejectAction", + # "data": { + # "dragonExtensionToolName": "RejectCardTool" + # } + # } + # ] + # }, + # payloadSources=[ + # {"identifier": str(uuid4()), "description": "Python Demo Medication Analysis Service", "url": "http://localhost:5181/v1/process"} + # ], + # dragonCopilotCopyData="medication_analysis|demo:1|generated:" + datetime.now(timezone.utc).isoformat(), + # references=[], + # partnerLogo="https://contoso.com/logo.png", + # ) + # + # def _timeline_card(self, entities: List[Any]) -> models.VisualizationResource: + # # Simplified timeline card + # body: List[Dict[str, Any]] = [ + # { + # "type": "Container", + # "items": [ + # {"type": "TextBlock", "text": "Lab / Clinical Trend Analysis (Demo)", "weight": "Bolder", "size": "Default"}, + # {"type": "TextBlock", "text": f"Detected {len(entities)} entities to date", "size": "Small", "spacing": "Small"} + # ] + # } + # ] + # return models.VisualizationResource( + # id=str(uuid4()), + # subtype="timeline", + # cardTitle="Recent Clinical Entities Timeline (Demo)", + # adaptive_card_payload={ + # "type": "AdaptiveCard", + # "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + # "version": "1.6", + # "body": body, + # "actions": [ + # { + # "type": "Action.Execute", + # "title": "Dismiss", + # "verb": "reject", + # "id": "rejectAction", + # "data": { + # "dragonExtensionToolName": "RejectCardTool" + # } + # } + # ] + # }, + # payloadSources=[ + # {"identifier": str(uuid4()), "description": "Python Demo Timeline Service", "url": "http://localhost:5181/v1/process"} + # ], + # dragonCopilotCopyData="lab_timeline|demo:1|generated:" + datetime.now(timezone.utc).isoformat(), + # partnerLogo="https://contoso.com/logo.png", + # references=[], + # ) \ No newline at end of file diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/conftest.py b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/conftest.py new file mode 100644 index 0000000..d8b97c3 --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/conftest.py @@ -0,0 +1,19 @@ +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +# Ensure the pyextension root (parent of the 'app' package) is on sys.path so 'app' can be imported +CURRENT_FILE = Path(__file__).resolve() +PYEXT_ROOT = CURRENT_FILE.parents[2] # .../pyextension +if str(PYEXT_ROOT) not in sys.path: + sys.path.insert(0, str(PYEXT_ROOT)) + +from app.main import app # type: ignore # noqa: E402 + + +@pytest.fixture() +def client(): + """Shared FastAPI TestClient fixture.""" + return TestClient(app) diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_adaptive_card.py b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_adaptive_card.py new file mode 100644 index 0000000..5e78d99 --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_adaptive_card.py @@ -0,0 +1,50 @@ +# Minimal note payload that triggers at least one entity (blood pressure) so counts are deterministic +NOTE_CONTENT = "BP: 145/98 mmHg Patient denies chest pain. Diabetes risk evaluated. Medication review done." + +payload = { + "note": { + "resources": [ + {"content": NOTE_CONTENT} + ] + } +} + +# Disabled: samplePluginResult is no longer included in the response payload +# because it caused an extra section rejected by the final application. + + +def test_adaptive_card_structure(client): + """Validate the adaptive-card payload key and its AdaptiveCard content.""" + resp = client.post("/v1/process", json=payload) + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + + # Payload should contain 'sample-entities' and 'adaptive-card' (not 'samplePluginResult') + assert "adaptive-card" in body["payload"], "adaptive-card key missing from payload" + assert "samplePluginResult" not in body["payload"], "samplePluginResult should not be present" + + adaptive_wrapper = body["payload"]["adaptive-card"] + assert isinstance(adaptive_wrapper, dict) + resources = adaptive_wrapper.get("resources") or [] + assert len(resources) == 1, "Expected 1 resource in adaptive-card" + + card = resources[0] + assert card.get("type") == "AdaptiveCard" + assert card.get("subtype") == "note" + + # Validate adaptive card payload + assert "adaptive_card_payload" in card, "adaptive_card_payload missing" + ac = card["adaptive_card_payload"] + assert isinstance(ac, dict) + assert ac.get("type") == "AdaptiveCard" + assert ac.get("version") == "1.6" + + # Validate actions exist + actions = ac.get("actions") or [] + assert len(actions) >= 1, "Card missing actions" + + # Validate metadata fields + assert card.get("dragonCopilotCopyData"), "dragonCopilotCopyData missing" + assert card.get("payloadSources"), "payloadSources missing" + diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_clinic_note.py b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_clinic_note.py new file mode 100644 index 0000000..84d1264 --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_clinic_note.py @@ -0,0 +1,96 @@ +CLINIC_NOTE = """Clinic Note\nPatient Name: John Doe\nDate of Note: 09/09/2025\nLocation: Haddox Medi-clinic \n\nSubjective:\nPatient is a 65-year-old white male here for evaluation of elevated blood pressure and fatigue. No prior diagnosis of hypertension or diabetes. Family history includes hypertension and diabetes in both parents. Patient reports sedentary lifestyle and BMI is elevated.\n\nObjective:\n\nVitals:\nBP: 145/98 mmHg\nHR: 78 bpm\nTemp: 98.6°F\nRR: 16\nSpO₂: 98% RA\n\nHeight: 5'10"\nWeight: 210 lbs\nBMI: 30.1 kg/m²\n\nLabs:\nFasting glucose: 108 mg/dL\nHbA1c: 5.9%\nLipid panel: Total cholesterol 210 mg/dL, LDL 140 mg/dL, HDL 42 mg/dL, Triglycerides 180 mg/dL\n\nPhysical Examination:\nGeneral: Well-nourished, well-developed male in no acute distress\nHEENT: Normocephalic, atraumatic, PERRLA, EOMI, oropharynx clear\nNeck: Supple, no lymphadenopathy or thyromegaly\nCardiovascular: Regular rate and rhythm, no murmurs, rubs, or gallops\nRespiratory: Clear to auscultation bilaterally, no wheezes or rales\nAbdomen: Soft, non-tender, no hepatosplenomegaly\nGenitourinary: Normal external genitalia, no CVA tenderness\nExtremities: No edema, pulses intact\nNeurological: Alert and oriented x3, normal gait\nSkin: Warm, dry, intact\n\nImpression:\nHypertension\n\nPlan:\nHypertension:\nLifestyle counseling: DASH diet, physical activity.\nConsider outpatient follow-up for repeat BP and possible initiation of antihypertensives if persistently elevated.\n\nDiabetes:\nRepeat fasting glucose and HbA1c in 3 months.\nRefer to outpatient diabetes prevention program.\nEncourage weight loss and increased physical activity.\n\nFollow-up:\nPCP visit in 1–2 weeks.\nNutritionist referral.""" + + +def _extract_sections(note: str): + """Extract logical sections from the clinic note to simulate structured document sections. + Returns list of tuples: (section_key, display_description, content, loinc_code) + """ + # Basic markers + markers = [ + ("Subjective:", "HISTORY OF PRESENT ILLNESS", "10164-2"), + ("Vitals:", "VITAL SIGNS", "8716-3"), # sample vitals code + ("Labs:", "RESULTS", "30954-2"), + ("Physical Examination:", "PHYSICAL EXAM", "29545-1"), + ("Impression:", "ASSESSMENT", "51847-2"), + ("Plan:", "PLAN", "51847-2"), + ("Follow-up:", "FOLLOW-UP", "8653-8"), + ] + + # Find indices + idx_map = {} + for marker, desc, code in markers: + pos = note.find(marker) + if pos != -1: + idx_map[marker] = pos + + # Sort markers by position + ordered = [m for m in markers if m[0] in idx_map] + ordered.sort(key=lambda t: idx_map[t[0]]) + + sections = [] + for i, (marker, desc, code) in enumerate(ordered): + start = idx_map[marker] + # Advance past marker line + start_content = start + len(marker) + end = len(note) + if i + 1 < len(ordered): + end = idx_map[ordered[i + 1][0]] + content = note[start_content:end].strip() + if content: + sections.append((marker.rstrip(':'), desc, content, code)) + return sections + + +def _build_structured_resources(): + # NoteResource only accepts 'content'; section metadata (key, desc, code) + # from _extract_sections is not used in the resource payload. + return [{"content": content} for _, _, content, _ in _extract_sections(CLINIC_NOTE)] + + +def test_clinic_note_entity_extraction(client): + # Structured payload modeled after samples/requests/note-payload.json but minimized for test speed + resources = _build_structured_resources() + # Ensure at least one resource retains BP reading for entity extraction + assert any("BP:" in r.get("content", "") for r in resources) + + payload = { + "sessionData": { + "correlation_id": "test-correlation-123", + "session_start": "2025-09-13T00:00:00Z", + "environment_id": "test-env" + }, + "note": { + "payload_version": "1.1.0", + "schema_version": "0.1", + "language": "en-US", + "document": { + "title": "Outpatient Note", + "type": {"text": "Clinic Note"} + }, + "resources": resources + } + } + resp = client.post("/v1/process", json=payload) + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + entities_wrapper = body["payload"].get("sample-entities", {}) + if isinstance(entities_wrapper, dict): + entities = entities_wrapper.get("resources", []) + else: + entities = entities_wrapper + # Expect at least one vital sign (BP) and one diabetes-related code or concept + types = {e.get("type") for e in entities if isinstance(e, dict)} + assert "ObservationNumber" in types # blood pressure + assert "MedicalCode" in types or "ObservationConcept" in types # diabetes indicators + # adaptive-card key replaced sample-entities-adaptive-card to avoid errors in final application + adaptive_wrapper = body["payload"].get("adaptive-card") + assert adaptive_wrapper is not None + # Unwrap DSP response structure -> resources[0] should be the actual adaptive card visualization resource + if isinstance(adaptive_wrapper, dict) and "resources" in adaptive_wrapper: + adaptive_resources = adaptive_wrapper.get("resources", []) + assert len(adaptive_resources) == 1 + adaptive = adaptive_resources[0] + else: + adaptive = adaptive_wrapper + assert adaptive.get("type") == "AdaptiveCard" \ No newline at end of file diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_process.py b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_process.py new file mode 100644 index 0000000..2bb6cea --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_process.py @@ -0,0 +1,24 @@ +def test_health(client): + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "healthy" + +def test_v1_health(client): + r = client.get("/v1/health") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "healthy" + assert "/v1/process" in body["endpoints"]["process"] + +def test_process_empty_payload(client): + r = client.post("/v1/process", json={}) + assert r.status_code == 200 + data = r.json() + assert data["success"] is True + assert "payload" in data + # payload keys may now map to DSP response objects + if "sample-entities" in data["payload"]: + se = data["payload"]["sample-entities"] + if isinstance(se, dict): + # DSP response shape expectation + assert "resources" in se diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/extension.yaml b/samples/DragonCopilot/Workflow/pythonSampleExtension/extension.yaml new file mode 100644 index 0000000..6c44670 --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/extension.yaml @@ -0,0 +1,20 @@ +name: sample-clinical-extraction-py +description: Python FastAPI version of clinical entity extraction sample +version: 0.0.1 +auth: + tenantId: 00000000-0000-0000-0000-000000000000 +tools: + - name: clinical-entity-extractor-py + description: Extracts certain clinical entities (Python FastAPI) + endpoint: http://localhost:5181/v1/process + inputs: + - name: note + description: Note + content-type: application/vnd.ms-dragon.dsp.note+json + outputs: + - name: sample-entities + description: Coded clinical entities + content-type: application/vnd.ms-dragon.dsp+json + - name: adaptive-card + description: Coded clinical entities in Adaptive Card format + content-type: application/vnd.microsoft.card.adaptive+json diff --git a/samples/DragonCopilot/Workflow/pythonSampleExtension/requirements.txt b/samples/DragonCopilot/Workflow/pythonSampleExtension/requirements.txt new file mode 100644 index 0000000..faca270 --- /dev/null +++ b/samples/DragonCopilot/Workflow/pythonSampleExtension/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.116.1 +uvicorn==0.35.0 +pydantic==2.11.7 +pytest==8.4.2 +pydantic-settings==2.10.1 \ No newline at end of file