Fund Compliance Analyzer · AfifAI
Case study · AI for financial compliance · 2024–2025

From fund policy documents to automated compliance checks

Analysts at a national financial regulator have to check, fund by fund, that each investment fund stays inside the limits its own policy document sets. I led the build of a system that reads the Hebrew policy text with Claude, turns every limit into a structured rule, and checks it against the fund's monthly holdings report. The model translates legal wording into filters. Code does every sum and every comparison.

Claude on Amazon Bedrock.NET 8 / C#EF Core · SQL ServerAmazon S3 · SNSReact · TypeScriptPython · pandas
Client
National financial regulator (confidential)
My role
Team lead & architect (hands-on)
Timeline
Jun 2024 – Jun 2025 · 3 iterations to an MVP
Status
Delivered
At a glance
38% to 0%checks left as an unexplained “cannot determine” (27 of 72 in the all-LLM first version)
0sums or limit comparisons done by the model. All arithmetic is in code.
14 / 21regulator-measured exposures matched within 0.05 pp in the first ground-truth round
≈2 h to ≈3 minto check one fund's policy against its holdings (estimate)
12filter dimensions in the typed schema the model must fill
3Claude generations swapped through configuration, not code
16 · 33API endpoints · database tables in the MVP
The problem

Limits written in legal prose, holdings filed as spreadsheets

Every fund publishes an investment policy in free Hebrew text: a floor on government bonds, a ceiling on “other channels as permitted by law”, a rating threshold, a foreign-currency band, no equities at all. Each month the fund manager files a holdings report with one row per security, 64 to 106 rows per fund in the pilot set. Checking one against the other meant reading the legal wording, working out which rows it covers, and adding up exposures by hand. Then the analyst moved on to the next fund.

What I owned

Three iterations, one design idea

  • Led the technical direction from an all-LLM feasibility test, through a Python prototype checked against the regulator's own numbers, to the .NET MVP.
  • Designed the architecture: the job pipeline, the 33-table data model, the prompt configuration in the database, and the filter schema the model has to fill.
  • Wrote most of the server myself: policy extraction, holdings ETL, the filter engine, exposure and compliance logic, and the Bedrock integration.
  • Ran the ground-truth comparison against regulator-measured exposures and traced every mismatch to its cause.
  • Set the API contracts for the admin app (funds, managers, policies, compliance results, asset drill-down) and guided the front-end work.
Architecture

An API, three job types and one rule: the model never does the math

The diagram is wider than the screen: swipe sideways to see all of it →

Each run is a job with a status (start, preliminary pass, primary pass, completed), a timed duration and a step-by-step log in the database. The text of every stage (raw, cleaned, model output) is written to S3, so any result can be traced back to the exact words the model saw.

Design decisions

Let the model read. Let the code count.

The model writes filters, not answers

In the first version Claude extracted the limits, extracted the actuals and compared them. 27 of 72 checks came back “cannot determine”, and one row called 1.6% “within” a 10% minimum. Now the model only turns a clause into a JSON filter over known values (asset types, trading venues, currencies, rating range). C# applies it, sums exposure and compares it to the limits. Trade-off: the schema has to grow with new kinds of clause (10 dimensions in the prototype, 12 in the MVP).

“Untested” beats a confident guess

Some clauses depend on data the holdings report doesn't contain: maturity date, domestic affinity, credit-risk group. The model marks those filters, the engine stores no verdict, and the UI shows Untested. Trade-off: a lower coverage number, but never a false “compliant”.

Two passes over the policy

Pass one isolates and cleans the investment-policy section of the filing. Pass two returns typed restrictions: source clause, type (exposure, holding, permitted), minimum, maximum and wording. Trade-off: two model calls per document, in exchange for much smaller and cleaner input to the pass that matters.

Prompts are data, with a regression check

Prompts and their six parameters (max tokens, temperature, top-k, top-p, thinking mode, thinking budget) live in the database, one row per prompt type. That made moving from Claude 3.5 to 3.7 with extended thinking a configuration change. To catch drift, a reference check compares each extraction with a known-good set on count, minimum, maximum, source clause, type and required keywords.

Engineering log

What broke, and what changed

Most of the design came from things that failed on real filings. A selection:

Jun 2024

The all-LLM version couldn't be trusted

Policy documents arrived as scanned PDFs, so I ran Hebrew OCR at 300 dpi and asked the model to extract limits, extract actuals and compare them. 38% of checks ended in “cannot determine”, and the model got the comparisons themselves wrong.

Took arithmetic away from the model entirely and redesigned around one narrow task: clause in, filter out.

Dec 2024

“And/or” and “other than”

Clauses such as “assets other than government bonds and/or deposits” can't be written as a single filter.

Filters became an array (their union), each filter can be inverted, and the whole array can be inverted. The model picks the structure; code computes the set.

Dec 2024

Some exposure isn't a sum of rows

Equity and foreign-currency exposure come from summary lines in the report, not from adding up security rows. The row sum gave the wrong number.

Added a “single asset type” filter that reads the summary line directly.

Dec 2024

Two rating scales in one file

Holdings mix S&P-style and Moody's-style ratings, and “A and above” has to include A- and A3.

Mapped both 26-grade scales to a single ordinal index, held in configuration. Rating ranges compare indexes, not strings.

Dec 2024

First ground-truth round

Across 3 pilot funds and 28 restrictions, the prototype ran with 0 errors. 14 of the 21 exposures the regulator had measured matched within 0.05 percentage points.

Traced each miss. Most needed maturity, domestic-affinity or credit-risk data the holdings report doesn't carry, and one reference value was itself flagged for review. That finding became the Untested path, plus a list of external data sources for the next phase.

Feb 2025

Policies arrive as filing forms

In production the policy is an HTML filing, and the policy text sits in a named form field whose name changed between form versions.

Parsed with AngleSharp and fell back between the two field names. A missing field returns an explicit “no policy text” status instead of an empty analysis.

Mar 2025

Every manager's file is slightly different

Holdings reports are tab-separated, in a legacy Hebrew code page, with header variants between fund managers. For two asset types, one column carries a different meaning.

Eight header-normalisation rules run before typed CSV mapping, plus a per-asset-type column correction. Rows that don't belong to a known fund are dropped and logged.

Apr 2025

Extended thinking rejects custom sampling

Moving to Claude 3.7 with extended thinking, Bedrock refused requests that set temperature, top-k or top-p.

The provider sends sampling parameters only when thinking is off. When it's on, it sets temperature to 1 and a token budget, chosen per prompt type in the database.

Try it

Follow one clause through the pipeline

These are synthetic clauses and numbers written for this page. They contain no client data or code. Each one shows what the model returns and what the code then does with it.

Outcome

Where it landed

Aspect
Before
After
Who computes exposure
An analyst with a spreadsheet, then the model (v1)
Code. The model only fills a typed filter.
Unanswered checks
38% “cannot determine” (27 of 72), no reason given
0%. Every restriction gets a number or is marked Untested, with the filter that needed the missing data stored.
Time per fund
≈2 h of manual reading and summing (estimate)
≈3 min: about one model call per restriction, with drill-down to the matched assets (estimate)
Changing the model
Model id and parameters hard-coded in scripts
A configuration value. 3 Claude generations used over the project.
Extraction regressions
Spotted by eye
Automatic 6-point comparison against reference restrictions
Traceability
Console output from local scripts
Job record with 4 statuses, step log, duration and stored text for every stage
  • An MVP API (16 endpoints) and a Hebrew-first admin app covering funds, managers, policies, compliance results and asset drill-down.
  • Policy, holdings-ETL and compliance jobs run per fund or for all funds, with an SNS notification when a run finishes.
  • Validated against figures the regulator had measured itself, with every gap explained rather than tuned away.
  • Clear next steps: external sources for maturity, domestic-affinity and credit-risk data so that Untested clauses can be checked, and parallel runs across funds.
Stack

Technology stack

AI
Claude 3 → 3.5 Sonnet → 3.7 Sonnet (extended thinking) on Amazon Bedrock
Back end
.NET 8 Web API, C#, EF Core, AutoMapper, CsvHelper, AngleSharp, NLog, JWT
Data
SQL Server (33 tables, 2 views), Amazon S3
Messaging
Amazon SNS notifications
Front end
React 18, TypeScript, Vite, Redux Toolkit, Ant Design, i18next (Hebrew RTL + English)
Prototyping
Python, pandas, boto3, Tesseract OCR

Free scoping call · 30 minutes · No commitment

Bring one process. Leave with a plan and a number to beat.

Write a few lines: the process or system, what hurts today (time, errors, cost or an AI pilot that stalled) and who uses it. After a short call you get a written plan and a fixed quote within ≈3 days.

Get a free scoping callafif.mansour@gmail.com

Opens a pre-filled email · reply within one business day

The model reads. The code counts.

All projects