Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ pnpm dev
- `POST /api/measures/{id}/cql/compile`
- `PUT /api/measures/{id}/tests`
- `POST /api/measures/{id}/tests/validate`
- `POST /api/measures/{id}/ai/draft-cql` — generate an AI-assisted CQL draft (with fallback template)
- `POST /api/measures/{id}/ai/generate-test-fixtures` — generate five AI-assisted outcome fixtures (with fallback set)
- `GET /api/measures/{id}/data-readiness` — per-measure data readiness: required element mapping status, source freshness, missingness rate, blockers and warnings
- `GET /api/admin/data-mappings` — list all canonical data element source mappings
- `POST /api/admin/data-mappings/validate` — cross-reference integration health into mapping statuses (marks STALE on degraded source)
Expand All @@ -83,6 +85,8 @@ pnpm dev
- `GET /api/value-sets/{id}/detail` — full value set detail including governance metadata and code list
- `GET /api/admin/terminology-mappings` — list local-to-standard terminology mappings with status and confidence
- `POST /api/admin/terminology-mappings` — create a new terminology mapping (PROPOSED by default)
- `GET /api/programs/{measureId}/risk-outlook?horizonDays=30` — upcoming due-soon pressure, repeat non-compliers, and site risk rates
- `GET /api/measures/{measureId}/versions/{versionId}/export/mat?format=xml` — FHIR R4 MAT-compatible bundle export; requires `ROLE_APPROVER` or `ROLE_ADMIN`
- `POST /api/runs/manual` (supports `ALL_PROGRAMS`, `MEASURE`, and `CASE` scopes)
- `GET /api/runs?limit=1`
- `GET /api/cases?status=open`
Expand Down
171 changes: 171 additions & 0 deletions backend/src/main/java/com/workwell/ai/AiAssistService.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@
import com.workwell.run.RunPersistenceService;
import com.workwell.security.SecurityActor;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.concurrent.ConcurrentHashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Value;
Expand Down Expand Up @@ -198,6 +201,28 @@ You are an HL7 CQL (Clinical Quality Language) expert. You generate CQL librarie
13. Do NOT make compliance decisions — only compute from structured FHIR data
""";

private static final List<String> REQUIRED_FIXTURE_OUTCOMES = List.of(
"COMPLIANT", "DUE_SOON", "OVERDUE", "MISSING_DATA", "EXCLUDED"
);
private static final String FIXTURE_SYSTEM_PROMPT = """
You are a CQL test engineer. Generate test fixtures for occupational health compliance measures.
Return ONLY a valid JSON array of fixture objects. No explanation, no markdown.

Each fixture: {
"name": "description",
"inputData": {
"examDate": "YYYY-MM-DD or null",
"programEnrolled": true/false,
"hasExemption": true/false,
"role": "string",
"site": "string"
},
"expectedOutcome": "COMPLIANT|DUE_SOON|OVERDUE|MISSING_DATA|EXCLUDED"
}

Generate exactly 5 fixtures covering all 5 outcome types.
""";

private String stripCodeFences(String raw) {
if (raw == null) return "";
String trimmed = raw.trim();
Expand Down Expand Up @@ -247,6 +272,145 @@ private String buildFallbackCqlTemplate(String safeMeasureName) {
""".formatted(safeMeasureName);
}

public List<GeneratedTestFixture> generateTestFixtures(UUID measureId, String actor) {
if (measureId == null) {
throw new IllegalArgumentException("measureId is required");
}
Map<String, Object> measureRow = jdbcTemplate.query(
"""
SELECT m.name AS name, COALESCE(mv.cql_text, '') AS cql_text
FROM measures m
JOIN measure_versions mv ON mv.measure_id = m.id
WHERE m.id = ?
ORDER BY mv.created_at DESC
LIMIT 1
""",
rs -> rs.next()
? Map.of("name", rs.getString("name"), "cqlText", rs.getString("cql_text"))
: null,
measureId
);
if (measureRow == null) {
throw new IllegalArgumentException("Measure not found: " + measureId);
}

String measureName = valueAsString(measureRow.get("name"), "Measure");
String cqlText = valueAsString(measureRow.get("cqlText"), "");
String prompt = "Measure name: " + measureName + "\n\n"
+ "CQL library:\n" + cqlText + "\n\n"
+ "Generate exactly 5 test fixtures covering each outcome type.";

List<GeneratedTestFixture> fixtures;
boolean fallbackUsed;
String provider;
try {
String raw = callWithModelFallback(FIXTURE_SYSTEM_PROMPT, prompt);
fixtures = parseGeneratedFixtures(raw);
fallbackUsed = false;
provider = modelName;
} catch (Exception ex) {
fixtures = buildFallbackFixtures();
fallbackUsed = true;
provider = "fallback-template";
}

insertAiAudit("AI_TEST_FIXTURES_GENERATED", actor, null, null, Map.of(
"measureId", measureId.toString(),
"measureName", measureName,
"count", fixtures.size(),
"model", provider,
"fallbackUsed", fallbackUsed
));
return fixtures;
}

private List<GeneratedTestFixture> parseGeneratedFixtures(String raw) throws JsonProcessingException {
String json = stripCodeFences(raw);
List<Map<String, Object>> parsed = objectMapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {});
List<GeneratedTestFixture> normalized = new ArrayList<>();
for (int i = 0; i < parsed.size(); i++) {
Map<String, Object> row = parsed.get(i);
String expectedOutcome = valueAsString(row.get("expectedOutcome"), "").trim().toUpperCase();
if (!REQUIRED_FIXTURE_OUTCOMES.contains(expectedOutcome)) {
continue;
}
String name = valueAsString(row.get("name"), "").trim();
if (name.isBlank()) {
name = "Generated fixture " + (i + 1);
}
normalized.add(new GeneratedTestFixture(
name,
safeMap(row.get("inputData")),
expectedOutcome
));
}

Set<String> seenOutcomes = new HashSet<>();
for (GeneratedTestFixture fixture : normalized) {
seenOutcomes.add(fixture.expectedOutcome());
}
if (!seenOutcomes.containsAll(REQUIRED_FIXTURE_OUTCOMES)) {
throw new IllegalStateException("Generated fixtures did not cover all required outcomes.");
}

List<GeneratedTestFixture> ordered = new ArrayList<>();
for (String requiredOutcome : REQUIRED_FIXTURE_OUTCOMES) {
GeneratedTestFixture match = normalized.stream()
.filter(fixture -> requiredOutcome.equals(fixture.expectedOutcome()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Missing generated fixture for outcome: " + requiredOutcome));
ordered.add(match);
}
return ordered;
}

private List<GeneratedTestFixture> buildFallbackFixtures() {
LocalDate today = LocalDate.now();
List<GeneratedTestFixture> fallback = new ArrayList<>();
fallback.add(new GeneratedTestFixture(
"Employee with exam 30 days ago → COMPLIANT",
fixtureInput(today.minusDays(30).toString(), true, false, "Maintenance Tech", "Plant A"),
"COMPLIANT"
));
fallback.add(new GeneratedTestFixture(
"Employee with exam 340 days ago → DUE_SOON",
fixtureInput(today.minusDays(340).toString(), true, false, "Nurse", "Clinic"),
"DUE_SOON"
));
fallback.add(new GeneratedTestFixture(
"Employee with exam 400 days ago → OVERDUE",
fixtureInput(today.minusDays(400).toString(), true, false, "Welder", "Plant B"),
"OVERDUE"
));
fallback.add(new GeneratedTestFixture(
"Employee with no exam on file → MISSING_DATA",
fixtureInput(null, true, false, "Office Staff", "Plant A"),
"MISSING_DATA"
));
fallback.add(new GeneratedTestFixture(
"Employee with medical exemption → EXCLUDED",
fixtureInput(null, true, true, "Industrial Hygienist", "Clinic"),
"EXCLUDED"
));
return fallback;
}

private Map<String, Object> fixtureInput(
String examDate,
boolean programEnrolled,
boolean hasExemption,
String role,
String site
) {
Map<String, Object> inputData = new LinkedHashMap<>();
inputData.put("examDate", examDate);
inputData.put("programEnrolled", programEnrolled);
inputData.put("hasExemption", hasExemption);
inputData.put("role", role);
inputData.put("site", site);
return inputData;
}

public CaseExplanationResponse explainCase(UUID caseId, String actor) {
CaseFlowService.CaseDetail detail = caseFlowService.loadCase(caseId)
.orElseThrow(() -> new IllegalArgumentException("Case not found"));
Expand Down Expand Up @@ -559,6 +723,13 @@ public record DraftCqlResponse(
) {
}

public record GeneratedTestFixture(
String name,
Map<String, Object> inputData,
String expectedOutcome
) {
}

public record CaseExplanationResponse(
String caseId,
String explanation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
.requestMatchers(HttpMethod.POST, "/api/runs/**").hasAnyAuthority("ROLE_CASE_MANAGER", "ROLE_ADMIN")
.requestMatchers(HttpMethod.POST, "/api/cases/**").hasAnyAuthority("ROLE_CASE_MANAGER", "ROLE_ADMIN")
.requestMatchers(HttpMethod.GET, "/api/measures/*/traceability").authenticated()
.requestMatchers(HttpMethod.GET, "/api/measures/*/versions/*/export/mat").hasAnyAuthority("ROLE_APPROVER", "ROLE_ADMIN")
.requestMatchers(HttpMethod.GET, "/api/**").authenticated()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
Expand Down
Loading
Loading