Skip to content

About

AI legal assistant for reliable legal opinions and legal consultations: verified Italian statutes and case law, exact citations, no invented norms. Agent tool for AgentBridge (MCP).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

LegalAssistantTool

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).


1. Data sources

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).

2. Synchronisation

  • 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 (FormatVersion is bumped whenever the schema changes, which forces a rebuild).
  • Data root: %LOCALAPPDATA%\Graphene\LegalAssistantTool on Windows, ~/.local/share/Graphene/LegalAssistantTool elsewhere. Override with LEGALASSISTANTTOOL_DATA.

3. Retrieval engine — candidates and benchmark

3.1 Candidates considered

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 ⚠️ community bindings only ⚠️ 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.

3.2 Benchmark

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.md

Results 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.

3.3 What the benchmark changed

The benchmark was not decoration: it drove five changes to the retrieval, each measured again.

  1. FTS5 filters inside SQL, not after LIMIT. Filtering the branch or the kind after the LIMIT starved one document kind (0 judgments returned). The filters are now UNINDEXED columns and part of the WHERE, with LIMIT limit*4+20.
  2. 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).
  3. 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.
  4. 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.
  5. 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.

4. Normalisation

Both repositories are normalised into a single JSONL corpus (normalized/articles.jsonl, normalized/judgments.jsonl), one document per line:

{ "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" }
  • leggi-italiane — one JSON file per code, one article per document; StateCode keeps the in-force status (vigente, abrogato, …) and NotApplicable marks 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.

4.1 Storage — embedded, zero configuration

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\LegalAssistantTool on 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 + SQLitePCLRaw ship inside the package, and e_sqlite3 is 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 with ReadWriteCreate on 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.

5. Anti-hallucination

This is the strongest constraint of the tool, and it does not rely on the model's good will:

  1. 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.
  2. LegalCitationGuard audits the draft sentence by sentence: any sentence citing an Art. N or a n. N/YYYY that is not in the selected set is deleted, and the removal is counted and reported in the Controllo delle citazioni section of the output.
  3. 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).
  4. 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.
  5. 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.

6. LegalOpinion pipeline

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.

7. Output format

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 fenced template block 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.

The form of a legal document

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.

From opinion to a Word document

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

8. Installation

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.

As a NuGet package

dotnet add package Graphene.LegalAssistantTool
using 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"]));

9. Configuration

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).

10. Versioning and release

  • The version is automatic and date-based: Version = 1.yy.MM.dd computed at build time ($([System.DateTime]::Now.ToString("1.yy.MM.dd"))), like the other tools of the solution.
  • AssemblyMetadata NuGetPackageId / NuGetPackageVersion let AIOrchestrator.PluginUpdater know which package and version a loaded plugin comes from.
  • Publishing is an explicit act: push a v* tag → .github/workflows/plugin-release.yml builds the self-contained plugin ZIP, .github/workflows/publish.yml packs and pushes the NuGet package using the NUGET_API_KEY repository secret (same secret as the other Graphene-Lab repositories).

11. Tests and harness

# 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.md

The 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 --echo

11.1 Developer documentation

Technical 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.

12. Limits

  • Italian law only. jurisdiction accepts IT; 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.

12.1 Other countries and jurisdictions — available on request

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.

13. Licence

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).

About

AI legal assistant for reliable legal opinions and legal consultations: verified Italian statutes and case law, exact citations, no invented norms. Agent tool for AgentBridge (MCP).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages