Skip to content

Commit bc7d469

Browse files
committed
Add local print summaries and trusted result components to AI drafts
1 parent e88a993 commit bc7d469

20 files changed

Lines changed: 674 additions & 24 deletions

File tree

‎builder-api/src/main/java/org/codeforphilly/bdt/builder/service/ScreenerFormDraftService.java‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ public Optional<JsonNode> generate(List<Benefit> benefits, List<FormPath> paths)
5959
buildPrompt(benefits, questionPaths), responseSchema(questionPaths, optionValues),
6060
16384, Duration.ofSeconds(60));
6161
if (generated.isEmpty()) return Optional.empty();
62-
return Optional.of(toFormSchema(objectMapper.readTree(generated.get()), questionPaths, optionValues));
62+
JsonNode form = toFormSchema(objectMapper.readTree(generated.get()), questionPaths, optionValues);
63+
appendResultsComponents(form, benefits);
64+
return Optional.of(form);
6365
} catch (InterruptedException e) {
6466
Thread.currentThread().interrupt();
6567
return Optional.empty();
@@ -118,8 +120,11 @@ private String buildPrompt(List<Benefit> benefits, Map<String, FormPath> questio
118120
when needed. Do not over-explain commonly understood things. Keep dates and numeric
119121
inputs in their original units; do not replace a birth date or income with a yes/no proxy.
120122
Do not supply default answers, invent eligibility rules, or ask for configured parameters.
121-
The runtime evaluates the actual checks, hides questions no longer needed, and displays
122-
a clear 'Screening complete' message only when ALL benefits are eligible or ineligible.
123+
The runtime evaluates the actual checks and hides questions no longer needed.
124+
The server adds Next steps and Print summary components after your questions.
125+
Next steps shows each benefit as it is decided, and announces completion only when
126+
ALL benefits are eligible or ineligible. Do not write application instructions,
127+
application URLs, help contacts, or ineligibility notes; the server copies configured content.
123128
Do not include an unconditional completion message or try to calculate eligibility yourself.
124129
Return a questions object with one entry for EVERY required question ID in the mapping
125130
below. Each entry has label, description, and values (an array of
@@ -139,6 +144,25 @@ across ALL checks (e.g. every benefit enrollment code), using friendly option la
139144
""".formatted(objectMapper.writeValueAsString(questionPaths), objectMapper.writeValueAsString(context));
140145
}
141146

147+
/** Trusted benefit content is copied after generation, never supplied or rewritten by Gemini. */
148+
private void appendResultsComponents(JsonNode form, List<Benefit> benefits) {
149+
var components = (com.fasterxml.jackson.databind.node.ArrayNode) form.path("components");
150+
ObjectNode nextSteps = components.addObject();
151+
nextSteps.put("id", "Field_draft_next_steps").put("type", "next_steps")
152+
.put("showWhen", "whenDecided");
153+
nextSteps.putObject("layout").put("row", "Row_draft_next_steps");
154+
ObjectNode content = nextSteps.putObject("benefitContent");
155+
for (Benefit benefit : benefits) {
156+
// Omit missing content so the editor can flag it for the analyst.
157+
if (benefit.getNextSteps() != null) {
158+
content.set(benefit.getId(), objectMapper.valueToTree(benefit.getNextSteps()));
159+
}
160+
}
161+
ObjectNode printSummary = components.addObject();
162+
printSummary.put("id", "Field_draft_print_summary").put("type", "print_summary");
163+
printSummary.putObject("layout").put("row", "Row_draft_print_summary");
164+
}
165+
142166
// Short question IDs keep provider property names simple. Actual form bindings are assigned
143167
// server-side from this mapping, never generated or rewritten by the model.
144168
Map<String, FormPath> questionPaths(List<FormPath> paths) {

‎builder-api/src/test/java/org/codeforphilly/bdt/builder/service/ScreenerFormDraftServiceTest.java‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ void restrictsChoicesToSchemaEnumsAndConfiguredBenefitCodes() throws Exception {
169169
void generatesUsingConfiguredChecksAndDeduplicatedTransformedSchemas() throws Exception {
170170
startGemini(200, DRAFT, "STOP");
171171
var form = service.generate(benefits(), paths).orElseThrow();
172-
assertEquals(4, form.path("components").size());
172+
assertEquals(6, form.path("components").size());
173173
assertEquals("test-key", requestKey.get());
174174
var config = request.get().path("generationConfig");
175175
assertEquals("application/json", config.path("responseMimeType").asText());
@@ -190,6 +190,34 @@ void generatesUsingConfiguredChecksAndDeduplicatedTransformedSchemas() throws Ex
190190
assertTrue(prompt.contains("Residency and income"));
191191
}
192192

193+
@Test
194+
void appendsResultsComponentsWithTrustedContentInsteadOfModelInstructions() throws Exception {
195+
var configured = benefits();
196+
var content = new org.codeforphilly.bdt.builder.model.domain.NextSteps(
197+
new org.codeforphilly.bdt.builder.model.domain.NextSteps.Eligible(
198+
"https://example.org/apply", "https://example.org/help", "215-555-0100", "Bring **ID**."),
199+
new org.codeforphilly.bdt.builder.model.domain.NextSteps.Ineligible("Ask about other help."));
200+
configured.get(0).setNextSteps(content);
201+
startGemini(200, DRAFT, "STOP");
202+
var form = service.generate(configured, paths).orElseThrow();
203+
var components = form.path("components");
204+
var next = components.get(4);
205+
assertEquals("next_steps", next.path("type").asText());
206+
assertEquals("whenDecided", next.path("showWhen").asText());
207+
assertEquals(mapper.valueToTree(content), next.path("benefitContent").get(configured.get(0).getId()));
208+
assertFalse(next.path("benefitContent").has(configured.get(1).getId()));
209+
assertEquals("print_summary", components.get(5).path("type").asText());
210+
assertFalse(next.has("key"));
211+
assertFalse(components.get(5).has("key"));
212+
assertEquals(6, components.findValuesAsText("id").stream().distinct().count());
213+
String prompt = request.get().path("contents").get(0).path("parts").get(0).path("text").asText();
214+
assertTrue(prompt.contains("server adds Next steps and Print summary"));
215+
assertFalse(prompt.contains("https://example.org/apply"));
216+
configured.get(0).setNextSteps(null);
217+
assertEquals("https://example.org/apply", next.path("benefitContent").get(configured.get(0).getId())
218+
.path("eligible").path("applyUrl").asText());
219+
}
220+
193221
@Test
194222
void skipsRequestsWithoutConfigurationOrWhenDisabled() throws Exception {
195223
startGemini(200, DRAFT, "STOP");

‎builder-frontend/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,27 @@ viewer fields. Consumers outside Preact can use `getSnapshot()` and `subscribe()
5858
on the service; `subscribe()` returns an unsubscribe function and updates fire
5959
`screeningResults.changed` on the form's event bus.
6060

61+
## Print summary
62+
63+
Analysts can place a **Print summary** component anywhere in the form. It opens
64+
the browser's Print / Save as PDF dialog after screening results arrive. The
65+
summary includes the screener name, current date, benefit decisions, and the
66+
content from the first Next steps component in schema order (including groups).
67+
It respects that component's `showWhen` setting. Without a Next steps component,
68+
it prints decisions and unmet requirements without application instructions.
69+
70+
The print renderer shares the on-screen Next steps renderer and adds full HTTP(S)
71+
URLs next to link text. Printing hides the rest of the page and excludes answers.
72+
It uses no PDF service or upload, and removes the temporary print tree after the
73+
dialog closes. Printing is disabled for editor samples and while evaluations are
74+
pending or failed. Use Preview to print real results while building a screener.
75+
76+
AI-drafted schemas append Next steps (`whenDecided`) and Print summary after the
77+
questions. The server copies benefit content into the schema after generation;
78+
the model neither receives nor generates application instructions or contacts.
79+
Benefits without configured next-steps content remain missing so the editor can
80+
warn the analyst.
81+
6182
## Next steps component
6283

6384
Analysts can place **Next steps** from the presentation palette. Its `next_steps`

‎builder-frontend/src/components/screener/FormRenderer.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function FormRenderer({
3333
pending = () => false,
3434
failed = () => false,
3535
onNextStepsShownChange,
36+
screenerName,
3637
}: {
3738
schema: { [key: string]: any };
3839
formData: Accessor<any>;
@@ -41,6 +42,7 @@ function FormRenderer({
4142
/** Evaluate host prefill on load, keeping its unknown answers null until answered. */
4243
evaluateInitialData?: boolean;
4344
onDataChange?: () => void;
45+
screenerName?: Accessor<string>;
4446
results?: Accessor<ScreenerResult | undefined>;
4547
pending?: Accessor<boolean>;
4648
failed?: Accessor<boolean>;
@@ -82,6 +84,7 @@ function FormRenderer({
8284
});
8385
connectScreeningResults(form, {
8486
results,
87+
screenerName,
8588
pending,
8689
failed,
8790
onNextStepsShownChange,

‎builder-frontend/src/components/screener/Screener.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export default function Screener() {
144144
<div class="flex flex-col lg:flex-row">
145145
<section class="flex-1 overflow-y-auto p-4">
146146
<FormRenderer
147+
screenerName={() => screener()?.screenerName ?? ""}
147148
schema={screener()?.formSchema || {}}
148149
formData={formData}
149150
hiddenQuestionPaths={hiddenQuestionPaths}

‎builder-frontend/src/components/screenerEditor/FormEditorView.bindings.test.tsx‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,53 @@ afterEach(async () => {
6464
});
6565
// form-js has deferred Preact effects; keep the DOM shim for this file's environment.
6666

67+
it("imports drafted result components without treating them as answer bindings", async () => {
68+
vi.mocked(fetchFormPaths).mockResolvedValue({
69+
paths: [{ path: "custom.resident", type: "boolean" }],
70+
});
71+
vi.mocked(draftFormSchema).mockResolvedValue({
72+
id: "draft",
73+
type: "default",
74+
schemaVersion: 18,
75+
components: [
76+
{
77+
id: "question",
78+
type: "yes_no",
79+
key: "custom.resident",
80+
label: "Resident?",
81+
},
82+
{
83+
id: "next",
84+
type: "next_steps",
85+
showWhen: "whenDecided",
86+
benefitContent: {},
87+
},
88+
{ id: "print", type: "print_summary" },
89+
],
90+
});
91+
const [schema, setSchema] = createSignal<any>();
92+
const host = document.body.appendChild(document.createElement("div"));
93+
dispose = render(
94+
() => <FormEditorView formSchema={schema} setFormSchema={setSchema} />,
95+
host,
96+
);
97+
await vi.waitFor(() => expect(editor.instance).toBeDefined());
98+
await vi.waitFor(() =>
99+
expect(
100+
Array.from(host.querySelectorAll("button")).find((b) =>
101+
b.textContent?.includes("Draft with AI"),
102+
)?.disabled,
103+
).toBe(false),
104+
);
105+
Array.from(host.querySelectorAll("button"))
106+
.find((b) => b.textContent?.includes("Draft with AI"))!
107+
.click();
108+
await vi.waitFor(() =>
109+
expect(schema()?.components.at(-1)?.type).toBe("print_summary"),
110+
);
111+
expect(extractFormPaths(schema())).toEqual(["custom.resident"]);
112+
});
113+
67114
it("preserves saved component content after a failed source load and retry", async () => {
68115
vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] });
69116
const benefit = {

‎builder-frontend/src/components/screenerEditor/FormEditorView.tsx‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ import "@bpmn-io/form-js/dist/assets/form-js.css";
4545
import "@bpmn-io/form-js-editor/dist/assets/form-js-editor.css";
4646
import { Benefit, FormPath } from "@/types";
4747

48-
function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
48+
function FormEditorView({
49+
formSchema,
50+
setFormSchema,
51+
benefits = () => [],
52+
screenerName = () => "",
53+
}) {
4954
const [isUnsaved, setIsUnsaved] = createSignal(false);
5055
const [isSaving, setIsSaving] = createSignal(false);
5156
const [saveError, setSaveError] = createSignal("");
@@ -159,7 +164,9 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
159164
});
160165
const screeningResults =
161166
formEditor.get<ScreeningResultsService>("screeningResults");
162-
createEffect(() => screeningResults.showEditorSamples(benefits()));
167+
createEffect(() =>
168+
screeningResults.showEditorSamples(benefits(), screenerName()),
169+
);
163170

164171
if (formSchema()) {
165172
formEditor.importSchema(formSchema()).catch((err) => {
@@ -191,6 +198,7 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
191198
field &&
192199
field.id &&
193200
field.type !== NEXT_STEPS_TYPE &&
201+
field.type !== "print_summary" &&
194202
field.type !== "group" &&
195203
field.type !== "default"
196204
) {
@@ -326,15 +334,25 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
326334
(formPaths() || []).map(({ path, type }) => [path, type]),
327335
);
328336
const components = schema?.components;
337+
const questions = Array.isArray(components)
338+
? components.filter(
339+
(field) => ![NEXT_STEPS_TYPE, "print_summary"].includes(field.type),
340+
)
341+
: [];
329342
if (
330343
!required.size ||
331344
!Array.isArray(components) ||
332-
components.length !== required.size ||
333-
!components.every(
345+
questions.length !== required.size ||
346+
!questions.every(
334347
(field) =>
335348
required.has(field.key) &&
336349
isTypeCompatible(required.get(field.key), field.type),
337350
) ||
351+
components.some(
352+
(field) =>
353+
[NEXT_STEPS_TYPE, "print_summary"].includes(field.type) &&
354+
field.key !== undefined,
355+
) ||
338356
extractFormPaths(schema).length !== required.size
339357
) {
340358
throw new Error(

‎builder-frontend/src/components/screenerEditor/ScreenerEditor.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ function ScreenerEditor() {
106106
<Title>BDT - {loadedScreener()?.screenerName}</Title>
107107
{activeTab() == "formEditor" && (
108108
<FormEditorView
109+
screenerName={() => loadedScreener()?.screenerName ?? ""}
109110
formSchema={formSchema}
110111
setFormSchema={setFormSchema}
111112
benefits={() => loadedScreener()?.benefits ?? []}

‎builder-frontend/src/components/screenerEditor/draftFormCompatibility.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ it("imports the draft's generated field types into the real editor and viewer",
7676
{ label: "Own", value: "own" },
7777
],
7878
},
79+
{
80+
id: "Field_draft_next_steps",
81+
type: "next_steps",
82+
showWhen: "whenDecided",
83+
benefitContent: {},
84+
},
85+
{ id: "Field_draft_print_summary", type: "print_summary" },
7986
],
8087
};
8188
try {

‎builder-frontend/src/components/screenerEditor/formJsExtensions/customFormFields/index.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { YesNoQuestion } from './YesNoQuestion';
22
import { ChecklistWithNone } from './ChecklistWithNone';
33
import { NextSteps } from '../nextSteps/NextSteps';
4+
import { PrintSummary } from '../printSummary/PrintSummary';
45

56
/*
67
* This is a module definition to register custom
@@ -11,6 +12,7 @@ class CustomFormFieldsModule {
1112
formFields.register(YesNoQuestion.config.type, YesNoQuestion);
1213
formFields.register(ChecklistWithNone.config.type, ChecklistWithNone);
1314
formFields.register(NextSteps.config.type, NextSteps);
15+
formFields.register(PrintSummary.config.type, PrintSummary);
1416
}
1517
}
1618

0 commit comments

Comments
 (0)