Skip to content

Commit 3d62f1a

Browse files
authored
Merge pull request #12 from surveyjs/bug/10-signature
Signature isn't copied from the scanned document fix #10
2 parents 482e7e5 + 5f96c05 commit 3d62f1a

7 files changed

Lines changed: 179 additions & 2 deletions

File tree

SPEC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ console.log(result.data); // Structured responses matching form sc
9090
- Matrix column keys: column `title`/`text` -> column `name` (or `value` when `name` is absent)
9191
- Matrix row keys: row `text` -> row `value`
9292
- Choice values for `radiogroup`, `dropdown`, `checkbox`, `tagbox`, `ranking`, `imagepicker`: display `text` -> canonical `value`
93+
- `signaturepad` fields: captured signature image -> Base64 string value
9394
- **JSON Schema Adapter**: Support for standard JSON Schema.
9495
- **Custom Adapter**: Simple interface for users to define their own mapping.
9596

prompts/milestone-3-adapter-layer.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Implement SurveyJS and JSON Schema adapters that convert form definitions into L
3333
- `imagepicker` — single or multiple choice from image options (extract by choice value/text)
3434
- `imagemap` — clickable regions on an image (extract selected region names)
3535
- `slider` — numeric slider with min/max/step (extract numeric value)
36+
- `signaturepad` — signature image capture (extract as Base64 string)
3637
- `boolean` — true/false toggle
3738
- `signature` — skip (handwritten signature, cannot reliably extract as data)
3839
- `html` — skip (display-only, no data to extract)
@@ -44,6 +45,7 @@ Implement SurveyJS and JSON Schema adapters that convert form definitions into L
4445
- Include clear instructions about expected value formats per type
4546
- Clarify canonical output expectations in the prompt and schema notes:
4647
- Use question `name` as the canonical key even when the form shows a `title`
48+
- For `signaturepad`, return a Base64-encoded image string value
4749
- For `multipletext`, use item `name` keys even if extracted labels use item `title`
4850
- For matrix types, use canonical row `value` and column `name` (or `value` fallback)
4951
- For ItemValue arrays (`choices`, `rows`), map display `text` back to canonical `value`
@@ -72,6 +74,7 @@ Implement SurveyJS and JSON Schema adapters that convert form definitions into L
7274
- `imagepicker``z.string()` (single select) or `z.array(z.string())` (multi select)
7375
- `imagemap``z.string()` or `z.array(z.string())` (selected region names)
7476
- `slider``z.number()`
77+
- `signaturepad``z.string()` (Base64-encoded image string)
7578
- `signature` → skip
7679
- `html` → skip
7780
- `image` → skip

prompts/milestone-5-core-pipeline.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ Wire up the full end-to-end extraction pipeline in `createExtractor()`.
3232
- "Return valid JSON only, using canonical schema keys/values (`name`/`value`)"
3333
- "For each field, include your confidence (0.0-1.0) in a parallel `_confidence` object"
3434
- "If a field is not visible or unreadable, use null"
35+
- If the form contains `signaturepad` elements, append signature-specific guidance:
36+
- instruct extraction of the actual handwritten signature marks
37+
- require Base64-encoded image string output for signature fields
38+
- include signature field names/titles so model can match labels in the scanned document
3539

3640
4. **Call LLM Provider**
3741
- Convert image to base64 via `imageToBase64()`

src/adapters/__tests__/surveyjs.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,22 @@ describe('SurveyJSAdapter.toPrompt', () => {
315315
expect(adapter.toPrompt({ pages: [] })).toBe('');
316316
expect(adapter.toPrompt({})).toBe('');
317317
});
318+
319+
it('describes signaturepad as base64 string field', () => {
320+
const form = {
321+
pages: [{
322+
name: 'page1',
323+
elements: [
324+
{ type: 'signaturepad', name: 'customerSignature', title: 'Customer Signature', isRequired: true },
325+
],
326+
}],
327+
};
328+
329+
const prompt = adapter.toPrompt(form);
330+
expect(prompt).toContain('"customerSignature"');
331+
expect(prompt).toContain('signature pad');
332+
expect(prompt).toContain('base64-encoded image string');
333+
});
318334
});
319335

320336
// ─── toOutputSchema tests ───────────────────────────────────────
@@ -403,6 +419,21 @@ describe('SurveyJSAdapter.toOutputSchema', () => {
403419
expect(keys).not.toContain('doc');
404420
});
405421

422+
it('handles signaturepad as base64 string', () => {
423+
const form = {
424+
pages: [{
425+
name: 'page1',
426+
elements: [
427+
{ type: 'signaturepad', name: 'customerSignature', title: 'Customer Signature', isRequired: true },
428+
],
429+
}],
430+
};
431+
432+
const schema = adapter.toOutputSchema(form);
433+
expect(schema.safeParse({ customerSignature: 'iVBORw0KGgoAAAANSUhEUgAAAAUA' }).success).toBe(true);
434+
expect(schema.safeParse({ customerSignature: 123 }).success).toBe(false);
435+
});
436+
406437
it('maps inputType=number to z.number()', () => {
407438
const form = {
408439
pages: [{

src/adapters/surveyjs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,12 @@ function describeElement(el: SurveyElement, index: number): string {
313313
case 'boolean':
314314
lines.push(' Type: boolean (true/false)', ' Expected value: true or false');
315315
break;
316+
case 'signaturepad':
317+
lines.push(
318+
' Type: signature pad',
319+
' Expected value: a base64-encoded image string of the captured signature',
320+
);
321+
break;
316322
case 'matrix': {
317323
const r = rowLabels(el.rows);
318324
const c = columnLabels(el.columns);
@@ -435,6 +441,8 @@ function elementToZod(el: SurveyElement): z.ZodTypeAny | null {
435441
return z.number();
436442
case 'boolean':
437443
return z.boolean();
444+
case 'signaturepad':
445+
return z.string();
438446
case 'matrix':
439447
return z.record(z.string());
440448
case 'matrixdynamic':

src/core/__tests__/extractor.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,21 @@ const itemValueTextMappedSurveyDef = {
155155
],
156156
};
157157

158+
const signaturePadSurveyDef = {
159+
pages: [
160+
{
161+
elements: [
162+
{
163+
type: 'signaturepad',
164+
name: 'customerSignature',
165+
title: 'Customer Signature',
166+
isRequired: true,
167+
},
168+
],
169+
},
170+
],
171+
};
172+
158173
const simpleJsonSchemaDef = {
159174
type: 'object',
160175
properties: {
@@ -429,6 +444,32 @@ describe('createExtractor', () => {
429444
},
430445
});
431446
});
447+
448+
it('extracts signaturepad as base64 string value', async () => {
449+
const base64Signature = 'iVBORw0KGgoAAAANSUhEUgAAAAUA';
450+
const provider = createMockProvider([
451+
{
452+
content: JSON.stringify({
453+
customerSignature: base64Signature,
454+
}),
455+
},
456+
]);
457+
458+
const extractor = createExtractor({
459+
provider,
460+
adapter: 'surveyjs',
461+
options: { preprocessImage: false },
462+
});
463+
464+
const result = await extractor.extractFromImage({
465+
image: TINY_PNG,
466+
formDefinition: signaturePadSurveyDef,
467+
});
468+
469+
expect(result.data).toEqual({
470+
customerSignature: base64Signature,
471+
});
472+
});
432473
});
433474

434475
describe('retry logic', () => {
@@ -922,6 +963,30 @@ describe('createExtractor', () => {
922963
expect(call.systemPrompt).toContain('document data extraction assistant');
923964
expect(call.systemPrompt).toContain('Return valid JSON only');
924965
expect(call.systemPrompt).toContain('_confidence');
966+
expect(call.systemPrompt).not.toContain('signaturepad fields');
967+
});
968+
969+
it('adds signaturepad-specific guidance with field names and labels', async () => {
970+
const provider = createMockProvider([
971+
{ content: JSON.stringify({ customerSignature: 'iVBORw0KGgoAAAANSUhEUgAAAAUA' }) },
972+
]);
973+
974+
const extractor = createExtractor({
975+
provider,
976+
adapter: 'surveyjs',
977+
options: { preprocessImage: false },
978+
});
979+
980+
await extractor.extractFromImage({
981+
image: TINY_PNG,
982+
formDefinition: signaturePadSurveyDef,
983+
});
984+
985+
const call = (provider.extractFromImage as ReturnType<typeof vi.fn>).mock.calls[0][0];
986+
expect(call.systemPrompt).toContain('For signaturepad fields');
987+
expect(call.systemPrompt).toContain('actual handwritten signature marks');
988+
expect(call.systemPrompt).toContain('Base64-encoded image string');
989+
expect(call.systemPrompt).toContain('"customerSignature" (label: "Customer Signature")');
925990
});
926991
});
927992
});

src/core/extractor.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,77 @@ function resolveAdapter(config: ExtractorConfig): FormAdapter {
3535
}
3636
}
3737

38-
const SYSTEM_PROMPT =
38+
const BASE_SYSTEM_PROMPT =
3939
'You are a document data extraction assistant. ' +
4040
'Extract field values from the scanned form image. ' +
4141
'Return valid JSON only, matching the specified field names exactly. ' +
4242
'For each field, include your confidence (0.0-1.0) in a parallel "_confidence" object. ' +
4343
'If a field is not visible or unreadable, use null.';
4444

45+
interface FormElementLike {
46+
type?: string;
47+
name?: string;
48+
title?: string;
49+
elements?: FormElementLike[];
50+
templateElements?: FormElementLike[];
51+
}
52+
53+
interface SignatureFieldDescriptor {
54+
name: string;
55+
label: string;
56+
}
57+
58+
function collectFormElements(elements: FormElementLike[] | undefined): FormElementLike[] {
59+
if (!elements || elements.length === 0) return [];
60+
61+
const result: FormElementLike[] = [];
62+
for (const el of elements) {
63+
result.push(el);
64+
if (el.elements && el.elements.length > 0) {
65+
result.push(...collectFormElements(el.elements));
66+
}
67+
if (el.templateElements && el.templateElements.length > 0) {
68+
result.push(...collectFormElements(el.templateElements));
69+
}
70+
}
71+
72+
return result;
73+
}
74+
75+
function getSignaturePadFields(formDefinition: Record<string, unknown>): SignatureFieldDescriptor[] {
76+
const pages = formDefinition.pages as Array<{ elements?: FormElementLike[] }> | undefined;
77+
if (!pages || pages.length === 0) return [];
78+
79+
const fields: SignatureFieldDescriptor[] = [];
80+
for (const page of pages) {
81+
const elements = collectFormElements(page.elements);
82+
for (const el of elements) {
83+
if (el.type !== 'signaturepad' || !el.name) continue;
84+
fields.push({ name: el.name, label: el.title ?? el.name });
85+
}
86+
}
87+
88+
return fields;
89+
}
90+
91+
function buildSystemPrompt(formDefinition: Record<string, unknown>): string {
92+
const signatureFields = getSignaturePadFields(formDefinition);
93+
if (signatureFields.length === 0) {
94+
return BASE_SYSTEM_PROMPT;
95+
}
96+
97+
const fieldLabels = signatureFields
98+
.map((f) => `"${f.name}" (label: "${f.label}")`)
99+
.join(', ');
100+
101+
return (
102+
BASE_SYSTEM_PROMPT +
103+
' For signaturepad fields, use field names and labels to identify the signature areas and extract the actual handwritten signature marks.' +
104+
' Return each signature as a Base64-encoded image string without markdown, code fences, or data URL prefixes.' +
105+
` Signature fields: ${fieldLabels}.`
106+
);
107+
}
108+
45109
/**
46110
* Extracts JSON content from an LLM response that may contain markdown
47111
* code fences and/or preamble/postamble text.
@@ -160,6 +224,7 @@ export function createExtractor(config: ExtractorConfig) {
160224
// 3. Generate prompt
161225
const fieldPrompt = adapter.toPrompt(input.formDefinition);
162226
const basePrompt = fieldPrompt;
227+
const systemPrompt = buildSystemPrompt(input.formDefinition);
163228

164229
// 4. Get output schema for validation
165230
const outputSchema = adapter.toOutputSchema(input.formDefinition);
@@ -177,7 +242,7 @@ export function createExtractor(config: ExtractorConfig) {
177242
const response = await provider.extractFromImage({
178243
image: imageBase64,
179244
prompt,
180-
systemPrompt: SYSTEM_PROMPT,
245+
systemPrompt,
181246
});
182247

183248
const rawContent = response.content;

0 commit comments

Comments
 (0)