From 1c3f63d94eca13518cd81bdd10c8a865a349e458 Mon Sep 17 00:00:00 2001 From: Taleef Date: Fri, 22 May 2026 01:58:26 -0400 Subject: [PATCH 1/2] feat(ai): AI Draft CQL endpoint + Studio UI (Sprint 7.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/measures/{id}/ai/draft-cql generates a CQL skeleton from the measure's spec_json plus optional OSHA policy text. Returns a deterministic fallback template (annotated TODOs, full Outcome Status define) when the model call fails so authoring is never blocked. Audit row written with fallbackUsed. CqlTab adds an "AI Draft CQL" button that opens an OSHA-text modal; the returned CQL is pushed into Monaco and surfaced with a dismissible amber review banner. Compile state resets so the user must compile before activation — AI never decides compliance. Closes #47 --- .../java/com/workwell/ai/AiAssistService.java | 134 ++++++++++++++++++ .../java/com/workwell/web/AiController.java | 18 +++ docs/JOURNAL.md | 20 +++ .../features/studio/components/CqlTab.tsx | 88 ++++++++++++ 4 files changed, 260 insertions(+) diff --git a/backend/src/main/java/com/workwell/ai/AiAssistService.java b/backend/src/main/java/com/workwell/ai/AiAssistService.java index 285eaea..6ed0959 100644 --- a/backend/src/main/java/com/workwell/ai/AiAssistService.java +++ b/backend/src/main/java/com/workwell/ai/AiAssistService.java @@ -121,6 +121,132 @@ public DraftSpecResponse draftSpec(String policyText, String measureName, String return response; } + public DraftCqlResponse draftCql(UUID measureId, String oshaText, String actor) { + if (measureId == null) { + throw new IllegalArgumentException("measureId is required"); + } + Map measureRow = jdbcTemplate.query( + """ + SELECT m.name AS name, mv.spec_json::text AS spec_json + FROM measures m + JOIN measure_versions mv ON mv.measure_id = m.id + WHERE m.id = ? + ORDER BY (CASE WHEN mv.status = 'Active' THEN 0 ELSE 1 END), mv.created_at DESC + LIMIT 1 + """, + rs -> rs.next() + ? Map.of("name", rs.getString("name"), "specJson", rs.getString("spec_json") == null ? "{}" : rs.getString("spec_json")) + : null, + measureId + ); + if (measureRow == null) { + throw new IllegalArgumentException("Measure not found: " + measureId); + } + String measureName = String.valueOf(measureRow.get("name")); + String specJson = String.valueOf(measureRow.get("specJson")); + String safeMeasureName = measureName.replaceAll("\\s+", ""); + String policyText = oshaText == null ? "" : oshaText.trim(); + + String userPrompt = "Generate a CQL library for this occupational health compliance measure.\n\n" + + "Measure name: " + measureName + "\n" + + "Spec JSON: " + specJson + "\n" + + "OSHA/Policy text: " + policyText + "\n\n" + + "The CQL must:\n" + + "- Define patient eligibility based on program enrollment\n" + + "- Define exemption conditions\n" + + "- Compute days since last qualifying exam from Procedure resources\n" + + "- Map outcome status to: COMPLIANT | DUE_SOON | OVERDUE | MISSING_DATA | EXCLUDED\n"; + + DraftCqlResponse response; + try { + String raw = callWithModelFallback(DRAFT_CQL_SYSTEM_PROMPT, userPrompt); + String cql = stripCodeFences(raw); + if (cql.isBlank()) { + throw new IllegalStateException("Empty CQL response from model"); + } + response = new DraftCqlResponse(true, cql, modelName, false); + } catch (Exception ex) { + response = new DraftCqlResponse(false, buildFallbackCqlTemplate(safeMeasureName), "fallback-template", true); + } + insertAiAudit("AI_DRAFT_CQL_GENERATED", actor, null, null, Map.of( + "measureId", measureId.toString(), + "measureName", measureName, + "model", response.provider(), + "promptLength", userPrompt.length(), + "outputLength", response.cql() == null ? 0 : response.cql().length(), + "fallbackUsed", response.fallbackUsed() + )); + return response; + } + + private static final String DRAFT_CQL_SYSTEM_PROMPT = """ + You are an HL7 CQL (Clinical Quality Language) expert. You generate CQL libraries for FHIR R4 measures. + + Rules: + 1. Return ONLY valid CQL code — no explanation, no markdown, no code fences. + 2. Start with: library CQL version '1.0.0' + 3. Use: using FHIR version '4.0.1' + 4. Include: include FHIRHelpers version '4.0.1' called FHIRHelpers + 5. Define: context Patient + 6. Eligibility define must evaluate to Boolean + 7. Exemption define must evaluate to Boolean + 8. Compliance define must evaluate to Boolean + 9. Final define named "Outcome Status" must return one of: 'COMPLIANT' | 'DUE_SOON' | 'OVERDUE' | 'MISSING_DATA' | 'EXCLUDED' + 10. Use value set references via: valueset "ValueSetName": 'urn:oid:...' + 11. Use FHIRHelpers.ToDate() for date comparisons + 12. Do NOT hard-code patient IDs or dates + 13. Do NOT make compliance decisions — only compute from structured FHIR data + """; + + private String stripCodeFences(String raw) { + if (raw == null) return ""; + String trimmed = raw.trim(); + if (trimmed.startsWith("```")) { + int firstNewline = trimmed.indexOf('\n'); + if (firstNewline > 0) trimmed = trimmed.substring(firstNewline + 1); + if (trimmed.endsWith("```")) trimmed = trimmed.substring(0, trimmed.length() - 3); + } + return trimmed.trim(); + } + + private String buildFallbackCqlTemplate(String safeMeasureName) { + return """ + library %sCQL version '1.0.0' + + using FHIR version '4.0.1' + include FHIRHelpers version '4.0.1' called FHIRHelpers + + // TODO: Define value sets + // valueset "Program Enrollment": 'urn:oid:...' + + context Patient + + // TODO: Define eligibility criteria + define "In Program": + false // Replace with enrollment condition + + // TODO: Define exemption + define "Has Exemption": + false // Replace with exemption condition + + // TODO: Define recency check + define "Most Recent Exam Date": + null as Date // Replace with procedure lookup + + define "Days Since Last Exam": + if "Most Recent Exam Date" is null then null + else difference in days between "Most Recent Exam Date" and Today() + + define "Outcome Status": + if "Has Exemption" then 'EXCLUDED' + else if not "In Program" then 'EXCLUDED' + else if "Most Recent Exam Date" is null then 'MISSING_DATA' + else if "Days Since Last Exam" > 365 then 'OVERDUE' + else if "Days Since Last Exam" > 335 then 'DUE_SOON' + else 'COMPLIANT' + """.formatted(safeMeasureName); + } + public CaseExplanationResponse explainCase(UUID caseId, String actor) { CaseFlowService.CaseDetail detail = caseFlowService.loadCase(caseId) .orElseThrow(() -> new IllegalArgumentException("Case not found")); @@ -425,6 +551,14 @@ public record DraftSpecResponse( ) { } + public record DraftCqlResponse( + boolean success, + String cql, + String provider, + boolean fallbackUsed + ) { + } + public record CaseExplanationResponse( String caseId, String explanation, diff --git a/backend/src/main/java/com/workwell/web/AiController.java b/backend/src/main/java/com/workwell/web/AiController.java index 391881d..6472b20 100644 --- a/backend/src/main/java/com/workwell/web/AiController.java +++ b/backend/src/main/java/com/workwell/web/AiController.java @@ -34,6 +34,19 @@ public AiAssistService.DraftSpecResponse draftSpec( } } + @PostMapping("/api/measures/{measureId}/ai/draft-cql") + public AiAssistService.DraftCqlResponse draftCql( + @PathVariable UUID measureId, + @RequestBody(required = false) DraftCqlRequest request + ) { + try { + String oshaText = request == null ? "" : request.oshaText(); + return aiAssistService.draftCql(measureId, oshaText, SecurityActor.currentActor()); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, ex.getMessage()); + } + } + @PostMapping({"/api/cases/{caseId}/explain", "/api/cases/{caseId}/ai/explain"}) public AiAssistService.CaseExplanationResponse explainCase( @PathVariable UUID caseId @@ -61,4 +74,9 @@ public record DraftSpecRequest( @NotBlank String policyText ) { } + + public record DraftCqlRequest( + String oshaText + ) { + } } diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 722f986..6b5fe07 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -1,5 +1,25 @@ # Journal +## 2026-05-22 — Sprint 7.1: AI Draft CQL + +### What changed + +**Backend** — added `AiAssistService.draftCql(measureId, oshaText, actor)`: +- Reads measure name + active `spec_json` for the given measure +- Sends a CQL-specialist system prompt + user prompt containing measure name, spec JSON, and pasted OSHA text +- Strips code fences from model output +- Writes `AI_DRAFT_CQL_GENERATED` audit event with `measureId`, `model`/`provider`, `promptLength`, `outputLength`, `fallbackUsed` +- Deterministic fallback CQL template returned when AI call fails — TODO-annotated skeleton with `Outcome Status` define covering all five buckets + +**Endpoint** — `POST /api/measures/{measureId}/ai/draft-cql` on `AiController`, accepts `{ oshaText }` body, returns `{ success, cql, provider, fallbackUsed }`. + +**Frontend** — `CqlTab` now has an "AI Draft CQL" button next to Compile. Opens a modal with an OSHA text textarea; on submit the returned CQL is pushed into the Monaco editor and a dismissible amber banner appears above the editor. Compile state is reset so the user must compile the AI draft before approval. + +### Why +Sprint 7 §7.1 differentiator — competitors don't offer CQL authoring assist. CQL is still validated by the existing compile gate before activation, so the rule that AI cannot decide compliance is preserved. + +Issues filed: #47 (this), #48, #49, #50, #51 for the rest of Sprint 7. + ## 2026-05-21 — 2026 eCQM catalog upgrade (49 measures), infra cleanup ### What changed diff --git a/frontend/features/studio/components/CqlTab.tsx b/frontend/features/studio/components/CqlTab.tsx index 627234c..60b552e 100644 --- a/frontend/features/studio/components/CqlTab.tsx +++ b/frontend/features/studio/components/CqlTab.tsx @@ -56,6 +56,38 @@ export function CqlTab({ const [showNewVersionDialog, setShowNewVersionDialog] = useState(false); const [newVersionSummary, setNewVersionSummary] = useState(""); const [creatingVersion, setCreatingVersion] = useState(false); + const [showDraftCqlDialog, setShowDraftCqlDialog] = useState(false); + const [oshaText, setOshaText] = useState(""); + const [drafting, setDrafting] = useState(false); + const [draftBanner, setDraftBanner] = useState(null); + + async function handleDraftCql() { + setDrafting(true); + onError(""); + try { + const result = await api.post<{ oshaText: string }, { cql: string; fallbackUsed: boolean; provider: string; success: boolean }>( + `/api/measures/${measureId}/ai/draft-cql`, + { oshaText } + ); + onCqlChange(result.cql); + if (editorRef.current) { + editorRef.current.setValue(result.cql); + } + setDraftBanner( + result.fallbackUsed + ? "AI unavailable — template inserted. Fill in the TODO sections before compiling." + : `AI-generated draft (${result.provider}) — review all logic before compiling. Not valid until compiled.` + ); + onCompileErrors([]); + onCompileWarnings([]); + setShowDraftCqlDialog(false); + emitToast(result.fallbackUsed ? "Fallback CQL template inserted" : "AI CQL draft inserted"); + } catch (err) { + onError(err instanceof Error ? err.message : "AI Draft CQL failed"); + } finally { + setDrafting(false); + } + } async function handleSubmitNewVersion() { if (!newVersionSummary.trim()) { @@ -145,6 +177,20 @@ export function CqlTab({ return (
+ {draftBanner ? ( +
+ AI draft + {draftBanner} + +
+ ) : null}
Compile + {formatStatusLabel(measure.compileStatus ?? "UNKNOWN")} @@ -246,6 +299,41 @@ export function CqlTab({
) : null} + + {showDraftCqlDialog && ( +
+
+

AI Draft CQL

+

+ Paste relevant OSHA/policy text below. The AI will use your saved Spec and this text to generate + a starting CQL library. You must compile and review before activating. +

+