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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ QUARKUS_GOOGLE_CLOUD_STORAGE_HOST_OVERRIDE="http://127.0.0.1:9199"
GCS_BUCKET_NAME="demo-bdt-dev.appspot.com"
FIRESTORE_EMULATOR_HOST="127.0.0.1:8080"

# Optional: enables AI-generated eligibility check aliases. When omitted,
# checks are added without an alias.
# Optional: enables AI-generated eligibility check aliases and screener form drafts.
# Without a key, checks are added without an alias and AI form drafting is unavailable.
# GEMINI_API_KEY=
# GEMINI_MODEL=gemini-3.5-flash-lite
# FORM_GENERATION_ENABLED=true

# Library API Configuration
# Defaults to http://localhost:8083 (no config needed for dev)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.codeforphilly.bdt.builder.persistence.StorageService;
import org.codeforphilly.bdt.builder.service.DmnService;
import org.codeforphilly.bdt.builder.service.InputSchemaService;
import org.codeforphilly.bdt.builder.service.ScreenerFormDraftService;

import java.util.ArrayList;
import java.util.Collections;
Expand Down Expand Up @@ -51,6 +52,44 @@ public class ScreenerResource {
@Inject
InputSchemaService inputSchemaService;

@Inject ScreenerFormDraftService screenerFormDraftService;

@POST
@Path("/screener/{screenerId}/draft-form")
@Produces(MediaType.APPLICATION_JSON)
public Response draftForm(@Context SecurityIdentity identity,
@PathParam("screenerId") String screenerId) {
String userId = AuthUtils.getUserId(identity);
if (userId == null) return Response.status(Response.Status.UNAUTHORIZED).build();
Optional<Screener> existing = screenerRepository.getWorkingScreener(screenerId);
if (existing.isEmpty()) throw new NotFoundException();
Screener screener = existing.get();
if (!isUserAuthorizedToAccessScreenerByScreener(userId, screener)) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
try {
// Return an editable draft; saving remains an explicit editor action.
List<Benefit> benefits = screenerRepository.getBenefitsInScreener(screener);
List<FormPath> paths = inputSchemaService.extractUniqueInputPaths(benefits);
if (benefits.isEmpty() || paths.isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Add benefits with configured checks before drafting a form.")).build();
}
var draft = screenerFormDraftService.generate(benefits, paths);
if (draft.isEmpty()) {
return Response.status(Response.Status.SERVICE_UNAVAILABLE)
.entity(Map.of("error", "Could not draft the form with AI. Check the Gemini configuration or try again.")).build();
}
return Response.ok(Map.of("schema", draft.get())).build();
} catch (ScreenerFormDraftService.UndraftableFormException e) {
return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", e.getMessage())).build();
} catch (Exception e) {
Log.error("Could not prepare a screener form draft", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Could not load the configured benefits and checks.")).build();
}
}

@GET
@Path("/screeners")
public Response getScreeners(@Context SecurityIdentity identity) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
package org.codeforphilly.bdt.builder.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
Expand All @@ -21,25 +16,12 @@ public class EligibilityCheckAliasService {
@Inject
ObjectMapper objectMapper;

@Inject
GeminiClient geminiClient;

@ConfigProperty(name = "alias-generation.enabled", defaultValue = "true")
boolean enabled;

@ConfigProperty(name = "alias-generation.gemini.api-key")
Optional<String> apiKey;

@ConfigProperty(name = "alias-generation.gemini.model", defaultValue = "gemini-3.5-flash-lite")
String model;

@ConfigProperty(
name = "alias-generation.gemini.base-url",
defaultValue = "https://generativelanguage.googleapis.com/v1beta/models/"
)
String baseUrl;

private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();

/**
* Generates a display alias with Gemini. Returns empty when generation is disabled, no API
* key is configured, or generation fails, so callers can tell the user instead of storing a guess.
Expand All @@ -48,44 +30,27 @@ public Optional<String> generate(String checkName, Map<String, Object> parameter
if (!enabled) {
return Optional.empty();
}
if (apiKey.isEmpty() || apiKey.get().isBlank()) {
if (!geminiClient.isConfigured()) {
Log.warn("GEMINI_API_KEY is not configured; skipping eligibility check alias generation");
return Optional.empty();
}

try {
String prompt = buildPrompt(checkName, parameters);
Map<String, Object> body = Map.of(
"contents", List.of(Map.of("parts", List.of(Map.of("text", prompt)))),
"generationConfig", Map.of(
"temperature", 0.2,
"maxOutputTokens", 60,
"responseMimeType", "application/json",
"responseJsonSchema", Map.of(
"type", "object",
"properties", Map.of("alias", Map.of("type", "string")),
"required", List.of("alias")
)
)
Map<String, Object> responseSchema = Map.of(
"type", "object",
"properties", Map.of("alias", Map.of("type", "string")),
"required", List.of("alias")
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + model + ":generateContent"))
.timeout(Duration.ofSeconds(8))
.header("Content-Type", "application/json")
.header("x-goog-api-key", apiKey.get())
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
Log.warnf("Gemini alias generation returned HTTP %d", response.statusCode());
Optional<String> generatedJson = geminiClient.generateJson(
"alias generation", buildPrompt(checkName, parameters), responseSchema, 60, Duration.ofSeconds(8));
if (generatedJson.isEmpty()) {
return Optional.empty();
}

JsonNode responseJson = objectMapper.readTree(response.body());
String generatedJson = responseJson.path("candidates").path(0).path("content")
.path("parts").path(0).path("text").asText();
String alias = objectMapper.readTree(generatedJson).path("alias").asText().trim();
String alias = objectMapper.readTree(generatedJson.get()).path("alias").asText().trim();
return alias.isBlank() ? Optional.empty() : Optional.of(alias);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Optional.empty();
} catch (Exception e) {
Log.warn("Could not generate an eligibility check alias with Gemini", e);
return Optional.empty();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package org.codeforphilly.bdt.builder.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
* Sends structured-output requests to Gemini. Alias generation and form drafting share
* one connection, configured under the alias-generation.gemini.* properties.
*/
@ApplicationScoped
public class GeminiClient {
@Inject
ObjectMapper objectMapper;

@ConfigProperty(name = "alias-generation.gemini.api-key")
Optional<String> apiKey;

@ConfigProperty(name = "alias-generation.gemini.model", defaultValue = "gemini-3.5-flash-lite")
String model;

@ConfigProperty(
name = "alias-generation.gemini.base-url",
defaultValue = "https://generativelanguage.googleapis.com/v1beta/models/"
)
String baseUrl;

private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();

public boolean isConfigured() {
return apiKey.isPresent() && !apiKey.get().isBlank();
}

/**
* Requests JSON matching responseSchema and returns the generated text. Returns empty when
* Gemini rejects the request or stops before finishing, such as at the token limit.
* Never logs the prompt, the response, or the API key.
*/
public Optional<String> generateJson(String purpose, String prompt, Map<String, Object> responseSchema,
int maxOutputTokens, Duration timeout)
throws IOException, InterruptedException {
Map<String, Object> body = Map.of(
"contents", List.of(Map.of("parts", List.of(Map.of("text", prompt)))),
"generationConfig", Map.of(
"temperature", 0.2,
"maxOutputTokens", maxOutputTokens,
"responseMimeType", "application/json",
"responseJsonSchema", responseSchema
)
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + model + ":generateContent"))
.timeout(timeout)
.header("Content-Type", "application/json")
.header("x-goog-api-key", apiKey.orElse(""))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
Log.warnf("Gemini %s returned HTTP %d", purpose, response.statusCode());
return Optional.empty();
}
JsonNode candidate = objectMapper.readTree(response.body()).path("candidates").path(0);
String finishReason = candidate.path("finishReason").asText("no candidate");
if (!"STOP".equals(finishReason)) {
Log.warnf("Gemini %s did not finish: %s", purpose, finishReason);
return Optional.empty();
}
return Optional.of(candidate.path("content").path("parts").path(0).path("text").asText());
}
}
Loading
Loading