Een nieuw contract registreren in een contractmanagementsysteem is vooral typen: titel, partner, startdatum, verlengingstype, opzegtermijn, eigenaar, betalingstermijn. Alle informatie staat in de pdf, maar iemand moet die lezen en in de juiste velden zetten, in de juiste site. Voor een klant in de Benelux bouwden we een AI PDF-scan in hun module voor contractmanagement in SharePoint op Shareflex: zet een getekend contract als pdf in een bibliotheek, en even later is de metadata uitgelezen, gematcht en klaar om te controleren.
In dit artikel laten we zien hoe we het bouwden, met echte TypeScript-code uit het project, en vooral de ontwerpkeuzes die het betrouwbaar genoeg maken voor echte contracten.

Stap 1 – Een rule die reageert op de upload
De scan draait als Shareflex-rule (een server-side event receiver), niet in de browser. De gebruiker uploadt de pdf en kan de pagina sluiten; de rule doet het werk. Dezelfde rule handelt drie gebeurtenissen af: een nieuwe upload, een verzoek om opnieuw te scannen en een goedkeuring.
async function main() {
const trigger = await rules.getItemById({
listUrl: rules.Event.listUrl,
itemId: rules.Event.itemId,
fields: ['FSObjType', 'AIRescanRequested', 'Approved'],
});
const isRescanRequested = trigger?.AIRescanRequested === true || String(trigger?.AIRescanRequested ?? '') === '1';
const isApproved = trigger?.Approved === true || String(trigger?.Approved ?? '') === '1';
if (rules.Event.type === 'ItemAdded' && String(trigger?.FSObjType ?? '') === '0') {
await runScan(); // new PDF uploaded
} else if (rules.Event.type === 'ItemUpdated' && isRescanRequested) {
await runScan(); // user asked for a rescan
} else if (rules.Event.type === 'ItemUpdated' && isApproved) {
await runApprove(); // user approved the result
}
}
Alle endpoints, modelnamen, sleutels en de prompt zelf staan in een lijst AI PDF Scan Settings in SharePoint. Overstappen op een nieuwer model of de prompt bijschaven is een configuratiewijziging, geen nieuwe uitrol.

Stap 2 – De pdf lezen met Azure AI Document Intelligence
Een LLM is goed in tekst begrijpen, maar een pdf is geen tekst: het kan een scan zijn, met kolommen, kopteksten, tabellen en handtekeningen. Daarom stuurt de eerste stap het bestand naar Azure AI Document Intelligence, dat schone tekst teruggeeft. De analyse is asynchroon, dus we sturen het bestand in en vragen daarna periodiek de status op.
const submitResponse = await rules.httpService({
connectionId: connection.id,
url: `${hostUrl}/documentintelligence/documentModels/${encodeURIComponent(modelId)}:analyze`
+ `?api-version=${encodeURIComponent(apiVersion)}`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ base64Source: base64Content }),
timeoutInSeconds: 120,
});
const operationLocation = String(submitResponse.headers?.['operation-location'] ?? '').trim();
// Poll for completion - Document Intelligence analysis is asynchronous.
for (let attempt = 1; attempt <= 30; attempt++) {
await rules.Utils.sleep(2000);
const pollResponse = await rules.httpService({ connectionId: connection.id, url: operationLocation, method: 'GET' });
const pollBody = typeof pollResponse.body === 'string' ? tryParseJson(pollResponse.body) : pollResponse.body;
const status = String(pollBody?.status ?? '').trim().toLowerCase();
if (status === 'succeeded') return String(pollBody?.analyzeResult?.content ?? '').trim();
if (status === 'failed') throw new Error(`Document Intelligence analysis failed: ${formatApiErrorBody(pollBody?.error ?? pollBody)}`);
}
Stap 3 – SharePoint bepaalt wat de AI moet uitlezen
Dit is de kern van de aanpak. We zetten geen vaste lijst met velden in de prompt. De rule leest de velden van de AI Scan-lijst zelf en bouwt daar een JSON-schema van: tekstvelden worden strings, keuzevelden worden enums met hun echte keuzes, datums moeten ISO 8601 zijn. Voeg een kolom toe aan de lijst en de AI leest die bij de volgende scan ook uit.
async function buildExtractionSchema(): Promise<ExtractionSchema> {
const listFields = await rules.getListFields({
listUrl: rules.Event.listUrl,
filter: 'Hidden eq false and ReadOnlyField eq false',
select: 'InternalName,Title,TypeAsString,Choices',
});
const properties: Record<string, unknown> = {};
const fields: SchemaField[] = []; // same fields, kept for mapping the answer back
for (const field of listFields) {
const name = field.InternalName;
if (!name || EXCLUDED_FROM_SCHEMA.has(name)) continue;
const description = CUSTOM_SCHEMA_DESCRIPTIONS[name] ?? field.Title;
switch (field.TypeAsString) {
case 'Text': case 'Note':
properties[name] = { type: 'string', description }; break;
case 'Number': case 'Currency':
properties[name] = { type: 'number', description }; break;
case 'DateTime':
properties[name] = { type: 'string', format: 'date',
description: `${field.Title} - ISO 8601 (YYYY-MM-DD), strictly - no other format allowed` }; break;
case 'Choice':
properties[name] = { type: 'string', enum: field.Choices, description }; break;
case 'Boolean':
properties[name] = { type: 'boolean', description }; break;
}
}
// ...plus Country/Division/Sub-Division/Contract Type as enums generated from the contract catalog
return { jsonSchema: { type: 'object', properties, additionalProperties: false }, fields };
}
We leerden dat we streng moeten filteren: een documentbibliotheek heeft tientallen systeemkolommen, en toen die in het schema belandden, werd de extractie merkbaar slechter. Een schoon schema is een betere prompt.
Heeft een veld meer uitleg nodig dan alleen de titel, dan voegen we een beschrijving toe. Bij ja/nee-vragen vragen we eerst om bewijs: het model moet de zin citeren waarop het antwoord gebaseerd is, of beide weglaten.
// Evidence first: a yes/no classification is only accepted together with the quote it is based on
const CUSTOM_SCHEMA_DESCRIPTIONS: Record<string, string> = {
DpaPresentRaw: 'REQUIRED whenever DpaPresentMatched is included in your response: quote the exact sentence(s) '
+ 'stating whether a Data Processing Agreement or GDPR/data-processing clause is present. '
+ 'Omit both DpaPresentRaw and DpaPresentMatched together if nothing relevant is stated.',
DpaPresentMatched: 'Whether the document includes a Data Processing Agreement or a GDPR/data-processing clause - '
+ 'based on the quote you put in DpaPresentRaw. Include this field ONLY together with DpaPresentRaw.',
SubDivisionMatched: 'Only applicable when Division is HR or Operations - omit otherwise.',
// ...
};
Stap 4 – Azure OpenAI vragen om alleen JSON
De uitgelezen tekst gaat naar een Azure OpenAI-deployment, met het schema in de systeemprompt en temperature 0 voor consistente uitkomsten. Is het antwoord geen geldige JSON, dan krijgt het model nog één kans met een expliciete correctie.
const systemPrompt = `${promptInstructions}\n\nRespond with ONLY a single JSON object matching this JSON schema, `
+ `no markdown formatting, no commentary:\n${JSON.stringify(schema.jsonSchema)}`;
for (let attempt = 1; attempt <= 2; attempt++) {
const messages = attempt === 1
? [{ role: 'system', content: systemPrompt }, { role: 'user', content: documentText }]
: [{ role: 'system', content: systemPrompt }, { role: 'user', content: documentText },
{ role: 'assistant', content: lastRawContent },
{ role: 'user', content: 'That response was not valid JSON matching the schema. Respond again with ONLY the JSON object, nothing else.' }];
const response = await rules.httpService({
connectionId: connection.id,
url: `${hostUrl}/openai/deployments/${encodeURIComponent(deploymentName)}/chat/completions`
+ `?api-version=${encodeURIComponent(apiVersion)}`,
method: 'POST',
body: JSON.stringify({ messages, temperature: 0 }),
timeoutInSeconds: 120,
});
// ...strip a possible ```json fence, JSON.parse, return on success
}
Elke waarde die terugkomt wordt gecontroleerd tegen het veld: een keuze die niet bestaat wordt overgeslagen, een getal moet een getal zijn, een datum moet een echte ISO-datum zijn. Niets wordt blind weggeschreven.
Stap 5 – Matchen, nooit gokken
Sommige waarden zijn geen vrije tekst maar verwijzingen: land, divisie en contracttype bepalen in welke site het contract komt; de eigenaar moet een echte gebruiker zijn; partner, juridische entiteit en betalingstermijn zijn lookups naar stamgegevens. De AI classificeert, maar code doet het matchen, en alleen bij een exacte match. Bestaat de combinatie niet, dan blijft het veld leeg en kiest de gebruiker zelf.
// Never fuzzy-match: this decides which division's site the contract lands in
const matches = await rules.getItems({
listUrl: catalogListUrl,
fields: ['ID', 'Country', 'Division', 'SubDivision', 'ContractType'],
filter: [
`Country eq '${escape(country)}'`,
`Division eq '${escape(division)}'`,
`SubDivision eq '${escape(subDivision)}'`,
`ContractType eq '${escape(contractType)}'`,
].join(' and '),
rowLimit: 1,
});
const match = matches[0];
if (!match) {
logWarning(`LLM-classified combination "${country} > ${division} > ${subDivision} > ${contractType}" `
+ `was not found in AIContractCatalog; left empty for manual selection.`);
return {};
}
const id = String(match.ID);
return {
CountryMatched: `${id};#${match.Country}`,
DivisionMatched: `${id};#${match.Division}`,
SubDivisionMatched: `${id};#${match.SubDivision}`,
ContractTypeMatched: `${id};#${match.ContractType}`,
};
Hetzelfde geldt voor de contracteigenaar: de uitgelezen naam wordt gematcht met de gebruikers van de site, en bij nul of meerdere treffers blijft het veld leeg. Een leeg veld is beter dan een fout veld.
Stap 6 – Controle door een mens en goedkeuren met één klik
Na de scan krijgt het item de status Review Needed. Het controleformulier toont de ruwe uitgelezen tekst naast de gematchte waarden, met de pdf-preview open in het zijpaneel. Land, divisie, subdivisie en contracttype zijn trapsgewijze keuzelijsten, zodat alleen combinaties gekozen kunnen worden die echt bestaan:
const contractCatalogSet = SF.createCascadingSet({
key: 'AIContractCatalogCascade',
autoFill: true,
steps: [
{ field: 'CountryMatched', lookupField: 'Country', listUrl: AI_CONTRACT_CATALOG_LIST_URL },
{ field: 'DivisionMatched', lookupField: 'Division', refField: 'Country', listUrl: AI_CONTRACT_CATALOG_LIST_URL },
{ field: 'SubDivisionMatched', lookupField: 'SubDivision', refField: 'Division', listUrl: AI_CONTRACT_CATALOG_LIST_URL },
{ field: 'ContractTypeMatched', lookupField: 'ContractType', refField: 'SubDivision', listUrl: AI_CONTRACT_CATALOG_LIST_URL },
],
});


Klopt alles, dan klikt de gebruiker op Approve. De rule controleert dan op een dubbele titel, maakt het echte contract aan in de juiste contractsite en kopieert de pdf naar de bijbehorende documentbibliotheek:
const contractFields = rules.createItemFields()
.setText('ContractId', contractId)
.setText('Title', contractTitle)
.setText('ContractType', contractTypeName)
.setText('ContractStatus', contractStatus)
.setText('Country', hierarchy[0])
.setText('Division', divisionValue)
.setUser('Owners', [ownerLoginName]);
if (item?.StartDateMatched) contractFields.setDate('StartDate', new Date(String(item.StartDateMatched)));
if (item?.NoticePeriodUnitMatched) contractFields.setChoice('NoticePeriodUnit', String(item.NoticePeriodUnitMatched));
if (partnerItemId) contractFields.setLookup(partnerLookupFieldName, [partnerItemId]);
// ...more fields
const newContractItemId = await rules.createItem({ listUrl: contractsListUrl, fields: contractFields, eventsActivated: true });
// Then copy the scanned PDF into the new contract's folder in Documents
await rules.createFile({
listUrl: documentsListUrl, fileName, content: base64Encode(fileBytes),
folderPath: contractId, ensureFolderPath: true, encoding: 'Base64', overwrite: true,
});
Het scanitem houdt een link naar het nieuwe contract bij en een volledig AI-logboek van elke stap, zodat iedereen kan zien wat de AI heeft uitgelezen en wat er daarna gebeurde.
Gebouwd om veilig te falen
Elke uitkomst heeft een eigen status, zodat niets stilletjes blijft hangen: Scanning In Progress, Review Needed, Pending Contract Registration, Processed – Added to Contract, en bij problemen Failed, Scan Problem, Duplicate Contract Error, Contract Generation Error of Document Copy Error. Fouten komen in het AI-logboek van het item en kunnen per e-mail naar de mensen in de instellingenlijst worden gestuurd. Opnieuw scannen is één vinkje.
Wat het oplevert
- Minuten in plaats van een kwartier per contract: de gebruiker controleert in plaats van typt.
- Consistente metadata: keuzes, datums en lookups worden gevalideerd voordat ze worden opgeslagen.
- De juiste plek: het contract komt in de site van het juiste land, de juiste divisie en het juiste contracttype.
- Transparante AI: ruwe uitgelezen tekst, citaten als bewijs en een volledig logboek per document.
- De mens houdt de regie: niets wordt geregistreerd zonder goedkeuring.
- Toekomstbestendig: nieuwe velden, modellen of prompts zijn configuratie, geen code.
Wat we geleerd hebben
- Scheid lezen (Document Intelligence) van begrijpen (LLM).
- Genereer het schema uit SharePoint, en houd het schoon.
- Laat de AI classificeren, maar laat code matchen met echte gegevens – exact, nooit bij benadering.
- Vraag om het citaat, niet alleen om het antwoord.
- Ontwerp voor fouten: een duidelijke status en een logboek zijn beter dan een stille fout.
AI in uw contractproces?
De AI PDF-scan is onderdeel van het contractmanagementwerk dat we doen op Shareflex Contract. Lees ook hoe we in dezelfde module een veilige functie om contracten te kopiëren bouwden. Neem contact op voor een demo.
