An event-driven document management platform: upload documents, convert them to PDF / PDF/A, archive them, classify and extract structured data, index them for search, and review/edit them as versioned revisions. Access is protected with Microsoft Entra ID (OIDC + role-based access control).
- Upload & storage – authenticated upload, list and download of documents (PostgreSQL metadata + file storage keyed by document GUID).
- Office → PDF conversion – LibreOffice (container) converts Office documents to PDF.
- PDF/A conversion & validation – Ghostscript (container) produces PDF/A-2b; veraPDF (REST) validates compliance.
- Revisions – every edit is saved as a new revision; list, download and restore earlier versions.
- Classification & orchestration –
OrchestratorServiceclassifies documents (invoice, contract, identity, financial statement, …) and routes them. - Extraction –
ExtractionServiceuses Azure Document Intelligence plus an LLM agent (Foundry Local or Microsoft Foundry) with a critic loop and validators (e.g. invoice validation). - Search –
SearchServicechunks, embeds and indexes content; deterministic, hybrid and agentic search endpoints. - Agentic AI – LLM agents classify unknown documents, extract and self-correct structured data, and answer search questions with tool calls and citations (see Agentic AI).
- RBAC – roles Viewer / Contributor / Reviewer / Admin enforced by the API and reflected in the UI.
- Reliable messaging – transactional outbox in each service, RabbitMQ with dead-letter queues.
flowchart LR
UI[Angular client :4200] -- Bearer token --> API[DocuManagementApp API :5176]
API --> PG[(documanagementdb)]
API --> CONV[LibreOffice / Ghostscript containers]
API --> VERA[veraPDF REST :8080]
API -- outbox --> MQ{{RabbitMQ documents.events}}
MQ --> ORC[OrchestratorService]
ORC -- extraction request --> MQ2{{extraction queue}}
MQ2 --> EXT[ExtractionService :5085 artifact API]
EXT --> DI[Document Intelligence :5000]
EXT --> LLM[Foundry Local / Microsoft Foundry]
EXT -- index request --> MQ3{{search queue}}
MQ3 --> SRCH[SearchService :5190]
SRCH --> EXT
API -- proxy --> SRCH
- Upload –
POST /api/fileupload/uploadstores the file;DocumentIngestionServicewrites the record and an outbox message in one transaction. - Conversion – Office files go through LibreOffice (
OfficeToPdfConversionService); PDFs can be converted to PDF/A with Ghostscript and validated with veraPDF (GhostscriptVeraPdfAProcessingService,/api/pdfa/*). - Archiving/storage –
DocumentStorageServicestores files under the document GUID; revisions are kept inDocumentRevisions. - Publish –
OutboxPublisherpublishesdocument.ingestedto exchangedocuments.events(publisher confirms, retry with backoff). - Orchestrate (rule-based, see Agentic AI) –
OrchestratorServiceconsumesorchestrator.document-ingested, classifies by file name/content type (DocumentClassifier) and emits extraction requests via its own outbox. - Extract –
ExtractionServicefetches the document from the API (DocumentContentApi), runs layout extraction, optional model extraction, the critic loop and validators, stores artifacts and serves them on its artifact API. - Index & search –
SearchServicereads artifacts, chunks (SearchChunker), creates embeddings and stores them insearchdb.
| Project | Role | Default URL | Database |
|---|---|---|---|
DocuManagementApp |
REST API, conversion, storage, revisions, auth, search proxy | http://localhost:5176 | documanagementdb |
OrchestratorService |
Consumes ingest events, classifies, routes | – (worker) | orchestratordb |
ExtractionService |
Extraction agent + artifact API | http://localhost:5085 | extractiondb |
SearchService |
Chunking, embeddings, search API | http://localhost:5190 | searchdb |
DocuManagementApp.Tests |
Unit tests | – | – |
The DMS combines deterministic code (routing, validation, persistence, messaging) with LLM agents where judgement or language understanding is needed. The principle: the agent proposes, deterministic code verifies and decides. All model access uses OpenAI-compatible endpoints, either local (Foundry Local / Ollama) or hosted (Microsoft Foundry).
flowchart TD
ING[document.ingested] --> ORC[OrchestratorService<br/>rule-based DocumentClassifier]
ORC -- kind known --> JOB[Extraction job]
ORC -- kind Unknown --> JOB
JOB --> LAY[Document Intelligence layout to markdown]
LAY --> CLS{Kind unknown?}
CLS -- yes --> CA[Classifier agent]
CA -- confidence below threshold --> MAN[NeedsClassification: manual review]
CA -- ok --> LOOP
CLS -- no --> LOOP
LOOP[Critic loop, max 3 attempts] --> EA[Extraction agent -> JSON]
EA --> VAL[Validator]
VAL -- issues --> CORR[Correction agent]
CORR --> VAL
VAL -- valid --> OK[ExtractionSuccess -> artifacts -> index]
VAL -- still invalid after 3 --> FAIL[ExtractionFailed + issues]
Q[User question] --> SA[Search agent]
SA -- tool call --> HS[execute_hybrid_search]
HS --> SA
SA --> ANS[Short answer with citations]
OrchestratorService is the decision layer, and intentionally not model-driven. DocumentClassifier.Classify(fileName, contentType) maps file-name keywords (invoice/bill, contract/agreement/nda, passport/identity/driver-license, financial-statement/annual-report/balance-sheet) to a DocumentKind and otherwise falls back on content type or extension (Pdf, Image, Spreadsheet, Text, Unknown). It then emits an extraction request through its outbox. This keeps routing cheap, predictable and testable; documents it cannot classify (Unknown) are handed to the classifier agent in ExtractionService, which sees the actual content.
FoundryDocumentAgent implements three agent roles against the configured model:
| Role | Interface | Purpose |
|---|---|---|
| Classifier | IDocumentClassifierAgent |
Reads the first ~6000 characters of the document markdown and returns {kind, confidence, reason} as JSON. Treats the document as untrusted data (prompt-injection guard). Invalid JSON or unsupported kinds become Unknown with confidence 0. |
| Extractor | IDocumentExtractionAgent |
Produces candidate JSON for the document kind, following the schema in DocumentExtractionSchemas. |
| Corrector | IDocumentCorrectionAgent |
Receives the markdown, the previous candidate and the validator's issues, and returns a fixed candidate. |
Processing in ExtractionJobProcessor:
- Layout extraction (Azure Document Intelligence) produces markdown.
- If the kind is not already a supported structured kind, the classifier agent proposes one. If its confidence is below
ClassificationConfidenceThreshold(default 0.7) or the kind is unsupported, the job ends asNeedsClassificationfor manual review instead of guessing. DocumentCriticLoopruns a generate → validate → correct loop (MaximumAttempts = 3): extract, validate withDocumentExtractionValidator, and if issues exist feed them back to the corrector. The result isExtractionSuccess(valid JSON) orExtractionFailed(last candidate plus remaining issues).- The outcome is stored as artifacts and an index request is published.
Model extraction is opt-in (EnableModelExtraction, default false); startup validation fails if it is enabled but no model is configured.
- Embeddings –
SearchChunkersplits artifacts into chunks, a text-embedding model (Embeddings:*) vectorises them, andSearchIndexConsumerstores them insearchdb. - Deterministic (
/api/search/deterministic) – exact filters only; Hybrid (/api/search/hybrid) – semantic similarity combined with filters. - Agentic (
/api/search/agentic) –AgenticSearchServiceruns a tool-calling agent (DocumentSearchAgent) that turns a natural-language question into a search and answers from the results:- It has exactly one tool,
execute_hybrid_search(semanticQuery, optionaldocumentKind,entityName,dateFrom,dateTo,limit). - It converts relative dates ("last month") into ISO
yyyy-MM-ddfilters using the current date supplied with each request. - Tenant identity stays implicit; the agent can neither request nor produce a tenant ID, and it cannot write SQL or call other services.
- The tool-call budget is
MaximumToolCalls(default 2, range 1–5); once reached, the tool tells the agent to answer only from results already returned. - Answers must be grounded in returned chunks, cite source URL and page, and be at most two sentences; if nothing is found it says no matching documents were found.
- With provider
OpenAICompatiblethe Microsoft Agent Framework drives the loop; withFoundryLocalthe service runs a manual tool-calling loop (role=toolmessages). - If agentic search is disabled or its endpoint/model or the embedding endpoint is not configured, an
AgenticSearchUnavailableExceptionis raised, so use the hybrid endpoint instead.
- It has exactly one tool,
| Setting | Service | Meaning |
|---|---|---|
EnableModelExtraction |
Extraction | Turns the extraction/classifier/corrector agents on. |
ClassificationConfidenceThreshold |
Extraction | Minimum classifier confidence (default 0.7). |
Foundry:Provider |
Extraction | FoundryLocal (default, http://localhost:5273/v1/) or MicrosoftFoundry (Endpoint, DeploymentName, ApiKey). |
Embeddings:Provider/Endpoint/ModelName/ApiKey |
Search | FoundryLocal, Ollama or MicrosoftFoundry; empty endpoint = embeddings disabled. |
AgenticSearch:Enabled/Provider/Endpoint/ModelName/ApiKey/MaximumToolCalls |
Search | Chat model for the search agent (OpenAICompatible or FoundryLocal). |
Keep API keys in user secrets or environment variables, not in appsettings.json.
- Agents never write to the database directly: output is validated JSON, and the search agent only calls a read-only search tool scoped to the tenant.
- Model output is non-deterministic and costs tokens/time; low-confidence classifications and failed extractions are surfaced rather than silently accepted.
- The local or hosted model must be running/reachable, otherwise extraction jobs retry/fail and agentic search is unavailable.
- Edited revisions are not re-extracted or re-indexed automatically.
| Endpoint | Policy |
|---|---|
POST /api/fileupload/upload, GET /api/fileupload/files, GET /api/fileupload/download/{id}, POST /api/fileupload/{id}/convert-to-pdf |
authenticated |
/api/pdfa/generate | convert | validate | suggestions | last-report |
authenticated |
GET /api/documents/{id}/revisions, GET …/{n}/content |
DmsRead |
POST /api/documents/{id}/revisions |
DmsWrite |
POST …/{n}/restore |
DmsReview |
POST /api/search/deterministic | hybrid | agentic |
authenticated |
Angular 21 (standalone components, Vitest), MSAL (@azure/msal-angular). An MSAL interceptor attaches the bearer token to http://localhost:5176/api/* (protectedResourceMap). Features: file upload and list (with "Verlauf / Bearbeiten" link), revision history/editor, search. Authenticated downloads are fetched as blobs, because a plain <a href> cannot send a bearer token.
- API: Microsoft.Identity.Web JWT bearer,
MapInboundClaims=false,RoleClaimType="roles". - Policies:
DmsRead,DmsWrite,DmsReview,DmsAdmin. - Entra app registration (
DocumentManagementApp): identifier URIapi://<ClientId>, scopeaccess_as_user, SPA redirect URIhttp://localhost:4200/, app rolesViewer,Contributor,Reviewer,Admin(assign users in Enterprise applications → Users and groups). - Client scope:
api://<ClientId>/access_as_user. Do not setAzureAd:Audienceon the API.
- .NET 9 SDK,
dotnet-eftool (dotnet tool install -g dotnet-ef) - Node.js LTS and Angular CLI (
npm i -g @angular/cli) - Docker or Podman
- PostgreSQL 16+ (pgvector recommended for embeddings)
- RabbitMQ (provided by
compose.yaml) - veraPDF REST service on
:8080 - Optional: Foundry Local / Microsoft Foundry, Azure Document Intelligence (or a compatible container on
:5000)
Keep secrets out of source control; use user-secrets or environment variables (ConnectionStrings__DefaultConnection, RabbitMq__UserName, …).
| Service | Key settings |
|---|---|
| API | AzureAd (TenantId, ClientId), ConnectionStrings:DefaultConnection, PdfA (container runtime, GhostscriptImage, VeraPdfRestBaseUrl), LibreOffice, RabbitMq (host, exchange documents.events, outbox tuning, UserName/Password), SearchProxy (Enabled, BaseUrl, ApiKey) |
| Orchestrator | ConnectionStrings:Orchestrator, RabbitMq (queues, dead-letter, credentials) |
| Extraction | ConnectionStrings:Extraction, ExtractionArtifactApi, DocumentContentApi:BaseUrl, DocumentIntelligence, Foundry, ExtractionProcessing, RabbitMq |
| Search | ConnectionStrings:Search, Search (ListenUrl, ApiKey, TenantId, SourceDocumentBaseUrl), ArtifactApi:BaseUrl, Embeddings, AgenticSearch, Search:RabbitMq |
Example:
cd DocuManagementApp
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Host=localhost;Port=5432;Database=documanagementdb;Username=postgres;Password=<pw>"
dotnet user-secrets set "RabbitMq:UserName" "dms"
dotnet user-secrets set "RabbitMq:Password" "<pw>"Angular: set the tenant, client ID, API scope and API base URL in the MSAL configuration under angClientapp/src/app.
compose.yaml starts Postgres and creates orchestratordb and extractiondb on first start (db/init/01-create-databases.sql). The services create their own tables on startup.
# .env (not committed): POSTGRES_PASSWORD=<your password>
docker compose up -d postgres
cd OrchestratorService
dotnet user-secrets set "ConnectionStrings:Orchestrator" "Host=localhost;Port=5432;Database=orchestratordb;Username=postgres;Password=<pw>"
cd ..\ExtractionService
dotnet user-secrets set "ConnectionStrings:Extraction" "Host=localhost;Port=5432;Database=extractiondb;Username=postgres;Password=<pw>"Without Docker, run CREATE DATABASE orchestratordb; CREATE DATABASE extractiondb; on your own Postgres.
- RabbitMQ
$env:RABBITMQ_DEFAULT_USER="dms"; $env:RABBITMQ_DEFAULT_PASS="<pw>" docker compose up -d rabbitmq # AMQP 5672, management UI http://localhost:15672
- PostgreSQL – start it and create
documanagementdb,orchestratordb,extractiondb,searchdb. - veraPDF REST – run a veraPDF REST container publishing port 8080. Pull the Ghostscript and LibreOffice images once (
docker pull docker.io/minidocks/ghostscript:latest docker.io/linuxserver/libreoffice:latest). - Migrations (API; workers create/migrate their schema on start-up as configured)
cd DocuManagementApp; dotnet ef database update
- Start services (one terminal each; start order: API → Orchestrator → Extraction → Search)
To enable search from the UI set
dotnet run --project DocuManagementApp # :5176 dotnet run --project OrchestratorService dotnet run --project ExtractionService # :5085 dotnet run --project SearchService # :5190
SearchProxy:Enabled=truein the API. - Frontend
cd angClientapp; npm install; ng serve # http://localhost:4200
- RabbitMQ:
restart: unless-stoppedin compose (ordocker compose up -d) keeps the broker and itsrabbitmq-datavolume alive across reboots. - Services: use
dotnet watch runduring development, or publish (dotnet publish -c Release) and run as Windows Services / systemd units / containers with automatic restart. Messages are durable and outboxes retry, so workers can be restarted without data loss. - VS Code
.vscode/launch.jsoncontains "Attach to …" configurations for debugging. - Stop a running API before
dotnet build(the running exe is locked).
dotnet test DocuManagementApp.Tests
cd angClientapp; ng test- 401 – no bearer token sent: check
protectedResourceMapand that the API scope isapi://<ClientId>/access_as_user. - 403 – user has no app role: assign a role in Entra, sign out/in; confirm the token's
rolesclaim. - Revision endpoints fail – run
dotnet ef database update. - PDF/A fails – verify container runtime path, images and veraPDF at
:8080. - Messages stuck – inspect RabbitMQ UI and the
*.dead-letterqueues.
- Saving a revision does not yet trigger re-indexing/re-extraction.
- Worker services do not receive the user token; they use service-to-service API keys.
- The Angular production bundle exceeds its size budget (warning only).