Agent tool for AIOrchestrator that answers Italian civil and criminal law questions from a local, verifiable archive of the codes and of the case law, and produces grounded legal opinions.
AI legal assistant for reliable legal opinions and legal consultations. LegalAssistantTool is an artificial-intelligence tool that answers a legal question or drafts a legal opinion only from a verifiable body of law — the Italian civil and criminal codes, article by article — correlated with the case law that interprets them. Every article and every judgment it cites exists in the local archive, with its exact reference and a link to the official source, so the answer can be checked line by line instead of taken on trust: no invented norms, no invented decisions.
It runs as a plugin of AgentBridge — the self-hosted AI assistant that executes agents and their tools in a single process — and therefore also behind its OpenAI-compatible API and its MCP connector, so the same legal research is available from the terminal, from any OpenAI client and from any MCP client.
It exposes exactly three methods to the agent:
| Method | Purpose |
|---|---|
GetLaws(jurisdiction, caseType, topic) |
The articles in force and the judgments about a topic, restricted to the branch of law requested. |
LegalOpinion(jurisdiction, caseType, facts, attachments?, suggestions?) |
A reasoned legal opinion on a concrete case, grounded on the archive and on a rated web enrichment. |
GetLegalForms(jurisdiction, documentName, scope) |
The blank form of a legal document in the field and court requested, normalised with [PLACEHOLDER] fields: mandatory before drafting any document for legal use. |
Everything the tool returns is quoted from the archive: an article number or a judgment number that cannot be found in the archive is never returned (see Anti-hallucination).
| Source | Content | Licence |
|---|---|---|
Andrea-Bruno/leggi-italiane |
Codice civile and Codice penale, article by article, with in-force status, Normattiva URL and PDF | MIT — article texts are official State acts (art. 5, L. 633/1941) |
Synthos-Logic/giurisprudenza-db |
Schede of judgments (Corte di cassazione, Corte costituzionale) with massime, articles interpreted, official URL | Code MIT; schede CC BY 4.0 — "giurisprudenza-db — Synthos Logic"; judgment texts are official State acts |
The attribution required by CC BY 4.0 is reproduced in the Fonti e verificabilità section of every document the tool returns, together with the link to each scheda.
At the time of writing the archive holds 4,251 articles and 23,049 judgments (22,413 of the Corte costituzionale, ~600 of the Corte di cassazione).
- The repositories are downloaded as ZIP (
codeload.github.com/.../zip/refs/heads/<branch>) and extracted; the commit SHA of each branch is compared with the stored one, and nothing is re-indexed when nothing changed. - The refresh runs every night at 03:30 local time of the machine that runs the host
(
LegalArchive.NightlyHour/NightlyMinute), and on the first call when the archive is missing. - After every real update the sources are re-normalised and the whole index is rebuilt
(
FormatVersionis bumped whenever the schema changes, which forces a rebuild). - Data root:
%LOCALAPPDATA%\Graphene\LegalAssistantToolon Windows,~/.local/share/Graphene/LegalAssistantToolelsewhere. Override withLEGALASSISTANTTOOL_DATA.
| Candidate | Licence | NuGet | Win/macOS/Linux | Verdict |
|---|---|---|---|---|
SQLite FTS5 + BM25 (Microsoft.Data.Sqlite, bundled e_sqlite3 with FTS5) |
Public domain (SQLite) / MIT | ✅ | ✅ | Chosen as the index |
Lucene.Net 4.8 (Lucene.Net, Analysis.Common, QueryParser) |
Apache-2.0 | ✅ | ✅ | Kept as an alternative engine and as a benchmark baseline |
| Tantivy (via FFI bindings) | MIT | Rejected: no first-party .NET package | ||
| Meilisearch / Typesense / Qdrant (client + server) | MIT / GPL / Apache | client only | ✅ | Rejected: requires a separate server process to install and keep alive |
Semantic Kernel / Microsoft.Extensions.AI vector search |
MIT | ✅ | ✅ | Rejected for the base index: needs embeddings, i.e. a model call per document (4,251 + 23,049 documents) and a network dependency |
bm25s-style pure managed BM25 |
— | — | ✅ | Rejected: no positional/phrase support, slower than FTS5 on the same corpus |
Conclusion. SQLite FTS5 is the best trade-off for this corpus: it is an embedded, zero-server, zero-native-install dependency (the native library ships inside the package and works on the three platforms), it supports BM25 out of the box, it survives being copied into a plugin folder, and it holds 27,300 documents in a ~50 MB file. Lucene.Net is technically stronger on analysis, but the Italian analyser alone is not enough to beat the Italian fold+stem applied on both sides of FTS5, as the numbers below show.
The benchmark project (LegalAssistantTool.Benchmark) runs 35 hand-labelled Italian queries
(20 criminal, 15 civil) over the real corpus. The gold set contains the article numbers a lawyer opens
for that fact pattern — it is not derived from the tool's thesaurus, otherwise the hybrid would be
rewarded for its own prior. Every gold number is checked against the archive before the run.
Run it with:
dotnet run --project LegalAssistantTool.Benchmark -c Release -- benchmark-results.mdResults on this machine (benchmark-results.md is regenerated by the command above):
| Engine | P@5 | R@10 | MRR@10 | Case-law affinity@10 | Latency |
|---|---|---|---|---|---|
| Hybrid: curated topics + rubric bonus + relevance floor + court mix (FTS5) | 0.274 | 0.814 | 0.760 | 0.280 | 560 ms |
| SQLite FTS5 + BM25 (Italian fold+stem) | 0.217 | 0.576 | 0.663 | 0.237 | 134 ms |
| Lucene.Net 4.8 BM25 + ItalianAnalyzer | 0.097 | 0.281 | 0.268 | 0.073 | 17 ms |
| Lucene.Net 4.8 BM25 + StandardAnalyzer | 0.011 | 0.024 | 0.034 | 0.016 | 16 ms |
- P@5 — share of the first five articles that are relevant.
- R@10 — share of the relevant articles found in the first ten.
- MRR@10 — mean reciprocal rank of the first relevant article.
- Case-law affinity@10 — share of the first ten judgments that cite at least one relevant article of the case. It measures whether the case law is anchored to the right norms, not merely similar in vocabulary: it is the metric that matters for a legal answer.
- Latency — mean per query, warm index.
The benchmark was not decoration: it drove five changes to the retrieval, each measured again.
- FTS5 filters inside SQL, not after
LIMIT. Filtering the branch or the kind after theLIMITstarved one document kind (0 judgments returned). The filters are nowUNINDEXEDcolumns and part of theWHERE, withLIMIT limit*4+20. - The curated topic list is a recall guarantee, not the ranking. Seeding curated articles at the top of the score band made the editorial order decide the answer (MRR 0.424). They are now merged as a bonus (+0.24) on the engine score when the engine already found them for these very facts, and appended below the lexical evidence otherwise → MRR 0.760. The +0.24 value was chosen by ablation (0.06 → 0.686, 0.12 → 0.695, 0.16 → 0.703, 0.20 → 0.723, 0.24 → 0.760, 0.26 → 0.720, 0.28 → 0.700, 0.30 → 0.681).
- The caller's words come first. Round 1 of the research loop now queries the raw facts; the expansion round (thesaurus vocabulary) and the rubric round (editorial article titles) are weighted 0.80 and 0.65. Passing an already-expanded query as round 1 cost 0.15 MRR.
- A relevance floor with a proportional case-law threshold. A judgment must share
clamp(ceil(anchors × 0.25), 2, 4)discriminating terms with the question, unless it cites one of the articles of the case (affinity is a precise signal, not a lexical one). Case law with no anchoring article is dropped entirely: a topic such as "pirateria spaziale su Marte" now returns 0 articles, 0 judgments instead of six unrelated decisions. - The article that is the case gets the slot. A flat curated bonus could not express
"this norm is the fact pattern": asking for furto aggravato returned art. 624 and art. 648
(ricettazione) but never art. 625, the aggravating circumstances of theft. Two measured fixes:
the curated articles whose rubric is a phrase of the question gain +0.20 (
furto aggravato→ art. 625) and those whose one-word rubric is a question term gain +0.12 (furto→ art. 624); and the curated articles the engine missed are appended instead of dropped (a band 0.55 → 0.40 by editorial position). Ablation: the two bonuses alone → MRR 0.760, R@10 0.771 → 0.814; letting a curated article evict the weakest lexical hit costs 0.05 MRR (0.760 → 0.709), so the guarantee is append-only. Raising the band above 0.55 drops MRR (0.75 → 0.757, 0.85 → 0.744, 0.95 → 0.688): editorial order must not outrank the facts.
Both repositories are normalised into a single JSONL corpus (normalized/articles.jsonl,
normalized/judgments.jsonl), one document per line:
leggi-italiane— one JSON file per code, one article per document;StateCodekeeps the in-force status (vigente,abrogato, …) andNotApplicablemarks the articles that cannot be cited.giurisprudenza-db— one Markdown scheda per judgment, split on its YAML frontmatter. The schede are split and normalised: court, section, decision number, date,materia, the articles interpreted (used as the sharpest retrieval key and as the affinity signal), the massime (with their internal headings demoted so they stay inside the quote), and the official URL. Questioni SU schede have no decision number and are cited by R.G. number and hearing date instead.- The text is folded and stemmed by
ItalianText(accents, apostrophes, elisions, Italian plurals) — the same transformation is applied to the indexed text and to the query, which is what makes FTS5 competitive with a full analyser chain.
The tool has no external database. It is a plain library/plugin: no server, no connection string, no migration step, no configuration file, no Windows service, nothing to install besides the package itself.
| What | Where | Created by |
|---|---|---|
| Raw sources (both repositories, extracted) | <root>\raw |
first call (ZIP download) |
| Normalised corpus (JSONL) | <root>\normalized |
first call |
| Search index (SQLite + FTS5, one file) | <root>\index\legal.db |
first call |
| Synchronisation state | <root>\state.json |
first call |
<root>is%LOCALAPPDATA%\Graphene\LegalAssistantToolon Windows and~/.local/share/…elsewhere (never next to the host executable, never inside the plugin folder, which is replaced on update).- SQLite is embedded, not a service.
Microsoft.Data.Sqlite+SQLitePCLRawship inside the package, ande_sqlite3is a native file loaded by absolute path from the plugin folder (Legal\LegalNative.cs) — nothing is installed system-wide, and FTS5 is compiled into the binary that ships. The database is created withReadWriteCreateon first use, so there is no setup step. - Only external requirement: internet, for the first download and the nightly refresh. With no connectivity the tool says so instead of answering from stale data.
- Measured cold install (empty folder, no configuration): 313 s — 62 s download, 238 s
normalisation of 4,251 articles and 23,049 judgments, 13 s FTS5 build. Warm calls then take
0.4–2.6 s. The first call waits up to 360 s (
LEGALASSISTANTTOOL_READY_SECONDS); after that it returns "still preparing" while the background sync keeps going, so the next call finds it ready. - Footprint: 158 MB raw + 196 MB JSONL + 113 MB index ≈ 466 MB. The Lucene indexes
(≈50 MB) are not built by a refresh — the tool searches FTS5; the benchmark builds them on
demand, or a refresh does it when
LEGALASSISTANTTOOL_LUCENE=1. - Uninstall = delete the folder. No registry keys, no shared state, no leftovers.
This is the strongest constraint of the tool, and it does not rely on the model's good will:
- The model never chooses the citations. Articles and judgments are selected deterministically by the retrieval layer; the model receives a closed list of citations and may only write prose about them.
LegalCitationGuardaudits the draft sentence by sentence: any sentence citing anArt. Nor an. N/YYYYthat is not in the selected set is deleted, and the removal is counted and reported in the Controllo delle citazioni section of the output.- Deterministic fallback. If no model answers within the budget, or the audit removes too much, the opinion is composed from the archive alone (same citations, no prose invented).
- Web pages are never a source of citations. A page found on the internet is rated for usefulness, reliability and obsolescence, and only used to orient the strategy; the section that lists them says so explicitly, and the benchmark verifier ignores those rows when it checks that every citation exists in the archive.
- Insufficient input is a deterministic output, not an opinion: fewer than 12 content terms (or no facts at all) returns a document that states what is missing and how to complete the request.
facts ──► input gate (deterministic) ──► insufficient-input document
│
├─► attachments: PDF/DOCX/HTML/text read, then summarised *only* when they exceed the
│ context budget, keeping the salient data (dates, amounts, names, articles)
├─► research loop (deterministic, bounded):
│ round 1 raw facts weight 1.00
│ round 2 thesaurus expansion weight 0.80
│ round 3 curated rubrics weight 0.65
│ citation pass (articles of the case → judgments that interpret them)
│ Cassation pass (dedicated court filter: the archive is 97% Constitutional Court)
│ relevance floor + court diversification (half of the slots for the Corte di cassazione)
├─► web enrichment (silently skipped when offline): ≤2 queries, ≤8 candidates,
│ lexical pre-filter, ≤4 pages read, ≤2 LLM ratings, deterministic rating fallback
└─► draft (closed citation list) ─► citation audit ─► Markdown
The whole model usage is bounded by a global time budget (LEGALASSISTANTTOOL_LLM_BUDGET_SECONDS,
default 240 s) that is reset at the start of every call: the tool always answers, and a slow or absent
model degrades the prose, never the citations.
All three methods return Markdown:
GetLaws— topic and branch, then Articoli di legge in vigore (verbatim text, in-force status, Normattiva link) and Sentenze e provvedimenti (exact citation, court, date, massima, link to the official scheda), then Fonti e verificabilità. When nothing relevant exists: Nessun risultato.LegalOpinion— 12 numbered sections: answer, facts as understood, leads proposed by the caller, applicable norms, case law, winning strategy, risks, external sources evaluated, missing data, recommended actions, attachments examined, citation control. When the input is insufficient: Esito deterministico, Informazioni mancanti, Come completare la richiesta.GetLegalForms— a fencedtemplateblock holding the blank form, with every field to fill in written as an uppercase placeholder ([NOME_COGNOME],[CITTÀ],[IMPORTO]). When no form exists it says so; when the connection is down it says that the function is unavailable.
GetLegalForms is the entry point for anything that will become a create_document call: the XML
<summary> — the only field the host turns into the tool description, together with <param> and
<returns> — makes it mandatory before creating any document for legal use, so the agent drafts on
a form instead of inventing a layout.
documentName in the language of the country ("ricorso in Cassazione")
scope the field and the court it is for ("civile, appello di Milano")
-> search 1: "<documentName> modello" -> up to 10 candidates
-> every candidate converted to Markdown -> the ones that share the document's vocabulary are kept
-> the model judges each one on its OWN full text, one call per document
isRequested = is this exactly the act asked for, in THIS scope? (controricorso != ricorso,
a penale or penitenziario form is not a civile one)
rating = how complete is it as a blank form? (only ranks the requested ones)
-> nothing usable? search 2 WITH the scope, and judge the new candidates
(a niche field can hide the right form behind generic results; the scope in the query
alone would tilt the choice, so it is only the fallback)
-> still nothing: search "<documentName> come fare" (per-language phrase), read the guides,
ask the model whether they are reliable and to write the form from them
(invented forms are never used: a guide judged unreliable ends in "no template")
-> normalisation: typos, structure, placeholders per role and per fact
([NOME_COGNOME_DIFENSORE] is not [NOME_COGNOME_RICORRENTE], [DATA_SENTENZA] is not [DATA_NOTIFICA])
-> spell check: the local Hunspell dictionary lists the words it does not know and the model
decides which are real typos (sigle, names and latinisms are left alone)
-> <data root>/templates/ricorso_in_cassazione.civile_appello_di_milano.it.md
(cache: the scope is part of the file name, so two scopes never share a form)
The jurisdiction is converted to the IANA country-code top-level domain of the country (IT → .it,
the ISO 3166-1 alpha-2 base every ccTLD is assigned from), so the cache file is named after the
document, its scope and the country. A run is best-effort by design: no candidate, no connection or no
model each ends in a stated answer, never in an invented form.
Choose a model that writes good Italian. The form's wording is rewritten by the model: a model with
a weak command of the language leaves typos (codice fiscal, Vogila) that the spell check can point
at but not fix by itself.
The Markdown is the payload an agent consumes, not the deliverable. The tool descriptions tell the
model how to use each method — a <remarks> block would not: the host renders only <summary>,
<param> and <returns>, so every rule lives in one of those three.
| Method | What the description says |
|---|---|
LegalOpinion |
The answer is a formal document: pass this Markdown to create_document as "Legal Opinion" and return the DOCX to the user. |
GetLaws |
The answer may stay in the chat; produce a document only when the user asked for one. |
GetLegalForms |
Mandatory before creating any document for legal use: obtain the form, fill its placeholders, then pass the result to create_document. |
create_document belongs to OfficeSupportTool, which ships the Legal Opinion template
(assets/templates/legal-opinion.html, a ten-section letterhead layout: parties, object, facts,
applicable law, case law, strategy, risks, actions, attachments, citation control). The template is
installed with OfficeSupportTool as a host plugin — this package does not depend on it, so the
workflow is optional and degrades to a plain Markdown answer when OfficeSupportTool is absent.
user asks for a legal opinion (optionally with attachments)
-> LegalOpinion(IT, Criminal|Civil, facts, attachments, suggestions)
-> create_document(template="Legal Opinion", content=<the Markdown>)
-> chat answer + .docx attachment
As an agent plugin (host: AIOffice / AgentBridge)
Install Graphene.LegalAssistantTool from the host's plugin manager, or drop the
LegalAssistantTool-<version>.zip release into Tools/LegalAssistantTool/. The plugin folder carries
its own dependencies (Microsoft.Data.Sqlite + SQLitePCLRaw natives, Lucene.Net, PdfPig); the
AIOrchestrator assemblies are never shipped.
dotnet add package Graphene.LegalAssistantToolusing AIOrchestrator.API;
var tool = new LegalAssistantTool();
Console.WriteLine(tool.GetLaws(LegalJurisdiction.IT, CaseType.Criminal, "furto aggravato"));
Console.WriteLine(tool.LegalOpinion(LegalJurisdiction.IT, CaseType.Civil,
"Il conduttore non paga il canone da otto mesi; il locatore ha inviato diffida …",
suggestions: ["il contratto non è stato registrato"]));| Variable | Default | Meaning |
|---|---|---|
LEGALASSISTANTTOOL_DATA |
%LOCALAPPDATA%\Graphene\LegalAssistantTool |
Data root (raw sources, normalised corpus, indexes, state). |
LEGALASSISTANTTOOL_ENGINE |
sqlite-fts5-italian |
Retrieval engine: sqlite-fts5-italian, lucene-italian, lucene-standard (the Lucene ones need their index, see LEGALASSISTANTTOOL_LUCENE). |
LEGALASSISTANTTOOL_LLM_BUDGET_SECONDS |
240 |
Global model budget of one call. 0 forces the deterministic path. |
LEGALASSISTANTTOOL_READY_SECONDS |
360 |
How long the first call waits for a cold install (30–900). |
LEGALASSISTANTTOOL_LUCENE |
(unset) | 1 also builds the Lucene indexes during a refresh (benchmark only; the tool uses FTS5). |
- The version is automatic and date-based:
Version = 1.yy.MM.ddcomputed at build time ($([System.DateTime]::Now.ToString("1.yy.MM.dd"))), like the other tools of the solution. AssemblyMetadataNuGetPackageId/NuGetPackageVersionletAIOrchestrator.PluginUpdaterknow which package and version a loaded plugin comes from.- Publishing is an explicit act: push a
v*tag →.github/workflows/plugin-release.ymlbuilds the self-contained plugin ZIP,.github/workflows/publish.ymlpacks and pushes the NuGet package using theNUGET_API_KEYrepository secret (same secret as the other Graphene-Lab repositories).
# end-to-end harness (sync, retrieval, opinions, forms, citation audit)
dotnet run --project LegalAssistantTool.Tests -c Debug -- all
# retrieval benchmark
dotnet run --project LegalAssistantTool.Benchmark -c Debug -- benchmark-results.mdThe harness writes the produced Markdown to .out/ and checks it: every Art. N c.p./c.c. and every
n. N/YYYY must exist in the archive, and the number of links must be non-zero. The forms scenario
searches and downloads real Word forms for "ricorso in Cassazione" and "atto notarile", asserts that
the envelope, the cache and the placeholders are intact, that a nonsense document name returns the
"no template" answer, and that the template contains no double-encoded text; it also checks the
rating → choice logic and the encoding repair without touching the network. The describe scenario
prints the tool catalogue the host builds — the exact text the model reads — and fails when a method
or the mandatory-form rule is missing from it. Strategic points of the pipeline log through
AIOrchestrator.Log.LogStep, so a run can be inspected step by step in logs/<pid>.txt.
A scenario can be pointed at any provider of providers.json without editing the file:
LEGALASSISTANTTOOL_TEST_LLM=Ornith LEGALASSISTANTTOOL_TEST_KEY=... \
LEGALASSISTANTTOOL_LLM_BUDGET_SECONDS=900 \
dotnet run --project LegalAssistantTool.Tests -- forms --echoTechnical documentation for anyone who has to maintain or extend the tool lives in docs/:
| Document | Contents |
|---|---|
| docs/ARCHITECTURE.md | Layers, component reference, object model, runtime lifecycle, storage layout, concurrency, plugin loading. |
| docs/PIPELINE.md | Change detection, zip download, normalisation of both repositories, FTS5 build, nightly scheduling, state.json, FormatVersion rules. |
| docs/RETRIEVAL.md | Candidate engines and why FTS5 won, text normalisation, the research loop, the scoring maths, the relevance floor and court mix, benchmark methodology and ablation recipe. |
| docs/OPINION-ENGINE.md | Input gate, attachment budget and summarisation, web enrichment with rating and reliability, drafting, the citation guard, report assembly, degradation matrix. |
| docs/DEVELOPER-GUIDE.md | Build/test/benchmark/pack commands, how to read a run in the log line by line, recipes for the common changes, troubleshooting, release checklist. |
All diagrams are Mermaid and render directly on GitHub.
- Italian law only.
jurisdictionacceptsIT; any other value returns a deterministic error instead of an opinion about a jurisdiction the archive does not cover. - Only the civil and criminal codes are indexed (plus the case law that interprets them): other sources (TUIR, Codice della strada, DPR 309/90, …) are not in the archive and are not cited.
- The archive is a support for legal analysis: the disclaimer is part of every output.
Nothing in the engine assumes Italy: retrieval, normalisation, ranking and the anti-hallucination guard
work on "whatever the local archive contains", and the jurisdiction parameter is the gate that refuses
a request the archive cannot cover. What is Italy-specific today is the source definition (the two
repositories it synchronises), the Italian text pipeline and the thesaurus, plus the citation
formats of the national courts. Italy ships first because its codes and case law are published as open
data that can be synchronised and re-indexed automatically.
We can extend LegalAssistantTool to any country on request, with the national sources that matter for that jurisdiction (statutes, consolidated codes, constitutional and supreme-court case law), the local language pipeline, and the same verifiable-citation contract. The work is a source adapter plus a lexicon, not a rewrite:
| Piece | What changes per country |
|---|---|
| Source adapter | Download and normalisation of the national repositories or of the official open-data endpoints. |
| Language pipeline | Folding, tokenisation, stop words and stemming for the local language (FTS5 or Lucene analyser). |
| Thesaurus | Topics, article numbers and defence points of the national codes. |
| Citation formats | How a judgment is cited locally, so the citation guard can verify it against the archive. |
Write to the maintainer with the country and the branch of law you need (issues are fine too) and we will scope the adapter and the benchmark set for that jurisdiction.
Andrea Bruno License 1.4 — see LICENSE.md. The bundled data keeps its own licences (see §1 and the third-party section of the licence file).
{ "Id": "art:penale:624", "Kind": 0, "CaseType": 1, "Heading": "Art. 624 c.p. — Furto", "Body": "Chiunque s'impossessa della cosa mobile altrui …", "Citation": "Art. 624 c.p.", "StateCode": "vigente", "Url": "https://www.normattiva.it/…", "CodeId": "penale", "ArticleNumber": "624", "SourceRepo": "leggi-italiane" }