DocTreeAnalyzer

Automated Legal Folder Structure Analysis

A .NET 10 desktop application that analyzes legal folder structures, classifies folders by role, detects anomalies, extracts entities, and provides an AI-powered chat interface for exploring results — all while keeping client data fully pseudonymized.

15
Projects in Solution
196
Automated Tests
4
Host Applications
3B
LLM Parameters
📁

Folder Classification

ML-powered classification of folders into roles: Client, Matter, DocumentCategory, SubCategory, AdHoc, TemporalGroup. Confidence scoring with rule-based + ML hybrid approach.

⚠️

Anomaly Detection

Detects 10 anomaly types: spelling errors, case inconsistencies, semantic duplicates, redundant nesting, role mismatches, path-too-long, and more. Severity scoring 0.0-1.0.

🤖

AI Chat Interface

Conversational Q&A about analysis results using a local LLM (Qwen2.5:3b via Ollama, CPU-only). All data pseudonymized before reaching the model, then compacted with per-session ID aliases (Folder_9991607 → f12) to keep prompts fast. Full conversation history support.

🔒

Privacy-First Design

Client names, folder names, and all PII are replaced with pseudonyms (Folder_XXXXXXX) before any data leaves the machine. Pseudonym mappings stored locally in SQLite.

Solution Architecture

Clean architecture with a Core project holding only interfaces and models. Host applications (WPF, API, Server, CLI) consume Core through DI. Satellite projects provide specific implementations.

Project Dependency Graph
Host Applications
DocTreeAnalyzer.Wpf
WPF Desktop App
Main UI
DocTreeAnalyzer.Api
ASP.NET Minimal API
REST + Python
DocTreeAnalyzer.Server
Background Worker
ML Retraining
DocTreeAnalyzer.Cli
Console App
Batch Analysis
▼ ▼ ▼ ▼
Satellite Projects
Obfuscator
DataObfuscator
Sqlite
PseudonymService
SubmissionQueue
ML
FolderClassifier
PythonAi
LLM Providers
▼ ▼ ▼ ▼
Core (Interfaces + Models only)
DocTreeAnalyzer.Core
Contracts (interfaces) • Models • Services
Zero implementation dependencies Only DI Abstractions NuGet
Infrastructure
FileStorage
JSON Files
JsonAdapter
System.Text.Json
Database
EF Core + SQL
Excel / Word
Report Export
Host Isolation via IAppNameProvider

Each host provides its name via IAppNameProvider. AppDataPaths uses it to isolate SQLite databases per host, preventing concurrent-access conflicts when API and WPF run on the same machine.

IAppNameProvider
Name → string
▼ implements
WPF
Name = "Presentation"
API
Name = "API"
Server
Name = "Server"
CLI
Name = "CLI"
▼ AppDataPaths resolves to
...\DocTreeAnalyzer\Presentation\
pseudonyms.db submissions.db analysis.db
...\DocTreeAnalyzer\API\
pseudonyms.db python.log
...\DocTreeAnalyzer\Server\
Logs\
...\DocTreeAnalyzer\CLI\
pseudonyms.db Logs\
Why isolate?
SQLite does not support concurrent writes from multiple processes. When API and WPF both run on a developer's machine, each process gets its own database directory — zero locking conflicts.
Operational Health Endpoints (API)

The API exposes lightweight health probes for post-deploy verification. Each returns 200 when healthy and 503 with a diagnostic JSON payload when not.

EndpointChecks503 hint when unhealthy
GET /health/dbEF Core can connect to the training databaseConnection error detail
GET /health/llmPython bridge ready, Ollama reachable, model pulled, test prompt round-tripsollama pull qwen2.5:3b
GET /health/loggingLog directory is writable (catches the IIS systemprofile path issue)Real resolved path + error
GET /health/server newThe DocTreeRetrainer Windows service is installed and Running (queried via ServiceController)sc.exe create "DocTreeRetrainer" binPath=...

/health/server works because the API and the retraining Server are installed on the same machine, so the API queries the Windows Service Control Manager directly — no extra HTTP surface on the worker.

Analysis Pipeline

Two execution modes: Progressive (live UI updates, two-phase) and Performance (single-pass parallel). The orchestrator manages the full lifecycle including feedback application and training data submission.

End-to-End Pipeline Overview

Every analysis follows this six-stage pipeline. Data flows left to right; each stage feeds the next.

Folder Scan
Parse .txt tree
into FolderNode graph
Pseudonymization
Real names →
Folder_XXXXXXX
Classification
Rule-based +
ML ensemble voting
Anomaly Detection
10 anomaly types
+ ML duplicates
Entity Extraction
Person / Corp /
Partnership
Report
Dashboard, DMS plan,
export & chat
1. Folder Scan
• ITreeParser.ParseWithFiles
• Flatten to List<FolderNode>
• Compute depth & child counts
2. Pseudonymization
• SqlitePseudonymService
• Deterministic mapping
• Applied before LLM & API calls
3. Classification
• Role overrides (highest priority)
• Rule-based fallback (8 threads)
• ML.NET ensemble prediction
4. Anomaly Detection
• 10 rule-based detectors
• ML semantic duplicate check
• Severity & auto-dismiss scoring
5. Entity Extraction
• ML entity type classifier
• Person / Corp / Partnership
• Confidence scoring per entity
6. Report
• Dashboard with stats
• DMS migration plan
• Excel/Word export & LLM chat
Progressive Analysis Flow (Default Mode)
File Input
.txt tree structure
Parse Tree
ITreeFileReader
Phase 1
Classification +
Entity Extraction
Show Results
Live UI Update
Phase 2
Anomaly Detection +
Pattern Analysis
Final Results
Dashboard + DMS
What the Pipeline Produces
OutputDescriptionCount (typical)
Folder ClassificationsEach folder assigned a role (Client, Matter, DocumentCategory, etc.) with confidence 0.0-1.0~1,800 folders
Anomalies10 types: SpellingError, CaseInconsistency, SemanticDuplicate, RoleMismatch, RedundantNesting, etc.~1,100 anomalies
Entity ExtractionDetected entities: Person, Corporation, Partnership with confidence scores~20 entities
PatternsNaming conventions and structural patterns (Client > Matter > Category)~5 patterns
DMS Migration PlanGenerated migration mapping for NetDocumentsPer workspace
ML Classification Pipeline
FolderNode
Name, Depth, Parent
Feature Extraction
FeatureExtractor +
RegexPatterns
Rule-Based
Legal categories,
corporate keywords
ML Model
ML.NET trained on
community data
Result
FolderRole +
Confidence

Privacy & Obfuscation

All sensitive data is pseudonymized before leaving the user's machine. The pseudonym mapping is stored locally in SQLite and never transmitted. The LLM only ever sees Folder_XXXXXXX. Names are first reduced to non-identifying features, the key that could reverse a token never leaves the device, and four privacy levels let each firm tune the privacy/utility trade-off.

Pseudonymization Pipeline
RAW DATA
"FTI Professional Grade Inc"
"Smith, John - Real Estate"
"CORRESPONDENCE"
SqlitePseudonymService
MaskAsync / UnmaskAsync
Local SQLite DB
Async IDisposable
SAFE DATA
"Folder_9991607"
"Folder_7285759"
"Folder_3164887"
Names Are Reduced to Features Before Masking

The real folder name is read once by IFeatureExtractor to compute structural signals, then the name itself is replaced with a token. The model learns from the features — never the text.

RAW FOLDER NAME · on device only
"FTI Professional Grade Inc"
▼  IFeatureExtractor.ExtractFeatures(node) reads the real text  ▼
STRUCTURAL FEATURES · kept
• ContainsCorpIdentifier = true
• ContainsPersonName = false
• MatchesLegalCategory = false
• NameLength = 26 · IsAllCaps = false
• Depth = 1 · ChildCount = 47
NAME STRING · masked
MaskAsync(name)
"Folder_9991607"
(dropped entirely at Full Anonymization)
SENT TO SERVER = features + token + predicted label
Zero raw folder or client text ever crosses the wire.
The Re-Identification Key Never Leaves the Device

A token is meaningless on its own. The only thing that can reverse it — the pseudonyms.db mapping — stays on the user's machine and is never transmitted.

USER'S MACHINE · %LocalAppData%
pseudonyms.db — the KEY
"FTI Professional Grade Inc" ↔ "Folder_9991607"
"Smith, John" ↔ "Client_7285759"
Per-user Windows ACL · only this machine can reverse a token.
HTTPS
only tokens + features cross
SERVER · SQL Server training DB
Receives only
"Folder_9991607"
+ structural features
+ predicted label
No mapping present → cannot re-identify anyone.
Four Configurable Privacy Levels

Chosen per firm in Settings (PrivacyLevel). Higher privacy means less training signal. Pseudonymized is the default.

Full Anonymization
Strongest privacy
Boolean + numeric features only. No names — not even pseudonyms.
Pseudonymized DEFAULT
Recommended
All names → stable tokens Folder_XXXXXXX.
Category Names Visible
Partial
Generic legal categories (e.g. PLEADINGS) sent as-is; client & person names still tokenized.
No Obfuscation
Weakest privacy
Everything as-is. Only for firms that explicitly consent / internal use.
What Gets Obfuscated vs. Preserved
DataActionReason
Folder namesPseudonymizedContains client names, personal info
Entity namesPseudonymizedPII — person/company names
Free-text descriptionsDropped entirelyMay contain real names in context
Anomaly suggestionsDroppedReferences original folder names
Folder role (Client, Matter...)PreservedStructural — no PII
Confidence scoresPreservedNumeric — no PII
Depth, counts, severityPreservedStatistical — no PII
Anomaly typePreservedEnum value — no PII

Training Data Flow

After each analysis, obfuscated training data is queued locally, then sent to the API. The Server periodically retrains ML models from accumulated submissions and uploads model bundles.

End-to-End Training Pipeline
WPF / CLI
Analysis completes
DataObfuscator
ObfuscateAsync()
SubmissionQueue
SQLite persistence
Retry up to 10x
API
POST /api/submissions
Schema validation
SQL Database
EF Core
Training tables
Server
RetrainingService
BackgroundService
Training Submission Schema
ObfuscatedTrainingSubmission
SchemaVersion SubmissionId ClientId AppVersion TotalFolders MaxDepth
▼ contains lists of
FolderClassifications
Role + confidence
12 ML features
No real names
AnomalyFeedback
Type + severity
User resolution
Context features
EntityTypes
Detected type
Confidence score
Classification features
DuplicatePairs
Similarity score
User confirmation
Pair features
All names are pseudonymized — the submission contains zero real folder or client names
Model Retraining Cycle (Server)
Accumulate
Wait for N submissions
(min 10-20 samples)
Retrain
5 ML.NET models
Classifier, Severity,
Dismiss, Entity, Duplicate
Bundle
ZIP with manifest
+ model files
Upload to API
BundleUploader
ETag versioning
Clients Pull
ModelUpdater checks
for new bundles

Chat with Analysis

Users ask natural-language questions about their analysis results. The entire pipeline ensures no real client data ever reaches the LLM — all names are pseudonymized before transmission.

Chat Pipeline — Full Request/Response Flow
User types question
"How many folders does FTI Professional Grade Inc have?"
IPseudonymService.MaskTextAsync()
"How many folders does Folder_9991607 have?" Local SQLite
SectionSelector + ContextSectionBuilder
Selects: Summary, ClientBreakdown, Anomalies, Classifications, Entities
+ keyword-triggered: TreeStructure, Patterns
ContextSectionTrimmer — safety-net cap
Keeps whole lines only, max 40 lines / 2500 chars per section Bounds pathological trees
PromptAliasMap.ToAlias() — per-session ID compaction
Folder_9991607 → f12 — "How many folders does f12 have?" (~5 tokens → ~1) 60–70% smaller IDs · primary size lever
ChatService.SendAsync() → POST /api/chat
Aliased message + sections + history + questionId trace 10-min timeout
PythonBridge → chat_engine.py → OllamaProvider
pythonnet GIL → sys.path injection → Ollama HTTP → qwen2.5:3b CPU-only, 3B params
PromptAliasMap.ToPseudonym() — restore pseudonyms
"f12 has 47 subfolders..." → "Folder_9991607 has 47 subfolders..." unknown codes left untouched
IPseudonymService.UnmaskTextAsync()
"FTI Professional Grade Inc has 47 subfolders and 6 anomalies" De-obfuscated
User sees real names in the response
LLM never knew the real client name
Context Sections Sent to LLM
SectionAlways SentContent
SummaryYesTotal folders, max depth, confidence breakdown, anomaly totals by type
Client BreakdownYesPer-client rollup: subfolder count + anomaly count with type breakdown
AnomaliesYesGrouped by type with total counts, 5 distinct examples per type
ClassificationsYesAll folders for small roles (Client, Matter), top 15 for large roles
EntitiesYesTop 20 extracted entities with type and confidence
Tree StructureOn demandDepth distribution, leaf folder count
PatternsOn demandDetected naming conventions with prevalence

Each section is then bound to a safety-net cap of 40 lines / 2500 chars by the ContextSectionTrimmer, and every pseudonym is swapped for a short per-session alias (Folder_9991607f12) before it is sent — so far more real data fits in the same token budget.

How we keep the LLM prompt small

We don't have a powerful LLM — so we keep the prompt small.

The model runs on a modest CPU-only server (6 vCPU, 12 GB RAM, no GPU) with a small 3B-parameter model. A large prompt is the bottleneck: the time to process the input (prefill) grows with its size, and on this hardware a full analysis dumped into the prompt took ~7 minutes per question.

Our mitigations:

  • Per-session ID aliasing (primary lever)PromptAliasMap swaps each long pseudonym for a tiny code (Folder_9991607f12), ~5 tokens down to ~1. A 60–70% cut on every identifier, so far more real data fits in the same budget instead of being thrown away.
  • Context trimming (safety net) — caps every section to 40 lines / 2500 chars (whole lines only, so pseudonyms are never cut). Now that IDs are small this just bounds pathological trees; truncated lists get a "N more omitted" marker.
  • MaxTokens 512 — bound the output length.
  • Temperature 0 — deterministic, grounded answers (no "retry until it works").
  • Smaller modelqwen2.5:3b instead of a 7B model that the CPU can't keep up with.

Aliasing replaced trimming as the main size lever. Aliases are assigned per chat session and reversed on the way back (ToPseudonym) before pseudonyms are un-masked to real names — so the user still sees real names while the model only ever saw f12. The Python SYSTEM_PREAMBLE was updated to tell the model how to read the codes.

LLM Provider Architecture
LlmProvider
Abstract base class (Python ABC)
chat(system_prompt, messages, config) → str
▼ extends
OllamaProvider
Current default
Connects to local Ollama server via HTTP
qwen2.5:3b localhost:11434 keep_alive 30m num_thread 6
LlamaCppProvider
Loads GGUF models directly
Downloads from HuggingFace Hub on first use
Local GGUF No server needed
HuggingFaceProvider
Uses HuggingFace Transformers
Full pipeline with tokenizer + model
transformers Auto-download
▲ selected by
appsettings.json
Switch provider without code changes
Provider ModelName BaseUrl MaxTokens Temperature KeepAlive NumThreads

Technical Decisions

Why pythonnet instead of calling Ollama directly from C#?
The Python bridge gives us access to the entire HuggingFace/ML ecosystem. We can swap between llama.cpp (local GGUF), Ollama, and HuggingFace transformers without changing C# code. The provider pattern in Python makes this a config change.
Why pseudonymize instead of running the LLM fully locally?
Even local LLMs can leak data through logs, crash dumps, or model fine-tuning. Pseudonymization ensures that if any data is ever exposed, it's meaningless without the local SQLite mapping. It also enables future cloud LLM support without privacy concerns.
Why SQLite for pseudonym storage instead of in-memory?
Pseudonym mappings must be deterministic across sessions — "ACME Corp" must always map to "Folder_9991607". SQLite persists these mappings. The async API (OpenAsync, ExecuteScalarAsync, ReadAsync) prevents blocking the UI thread.
Why isolate AppData paths per host (API vs WPF)?
SQLite doesn't support concurrent write access from multiple processes. When API and WPF run on the same dev machine, they'd corrupt each other's databases. IAppNameProvider gives each host its own subdirectory under AppData.
Why Core has zero implementation NuGet dependencies?
Core holds only interfaces, models, and DI registration helpers. No Microsoft.Data.Sqlite, no System.Text.Json, no Microsoft.Extensions.Http. This keeps it light and prevents transitive dependency issues. Implementations live in satellite projects (Sqlite, JsonAdapter, etc.).
Why a two-phase progressive pipeline instead of single-pass?
Phase 1 (classification + entity extraction) is fast and gives the user immediate visual feedback. Phase 2 (anomaly detection + pattern analysis) runs heavier algorithms. Users can start reviewing classifications while anomalies are still being computed.
Why keep the conversation history short?
Every token in the prompt — system preamble, context sections, and prior turns — adds to the prefill cost on the CPU server. With context now trimmed to ~1-2K tokens, history is the other variable: keeping only recent turns leaves room for the data sections and keeps each question fast. History is stored obfuscated (pseudonyms), never real names.
Why PythonEngine.BeginAllowThreads() after Initialize?
PythonEngine.Initialize() acquires the GIL on the main thread. Without BeginAllowThreads(), no background thread can ever acquire it — Py.GIL() inside Task.Run deadlocks forever. This was a critical bug we discovered during integration.
Why qwen2.5:3b on the server (not 7B, not 1.7B)?
SmolLM2-1.7B was too weak (28-55 char answers, couldn't reason over structured data). Qwen2.5:7B reasons well but is too slow on our CPU-only production server (6 vCPU, no GPU) — a single question took ~7 minutes. Qwen2.5:3b is the sweet spot: good enough reasoning, ~2-3× faster than 7B, fits comfortably in 12 GB RAM. We pair it with context trimming so the prompt stays small.
Why alias pseudonyms to short codes (Folder_9991607 → f12) before the LLM call?
To fit more real data in the prompt instead of trimming it away. Prompt prefill on a CPU-only server scales with token count, and a pseudonym like Folder_9991607 costs ~5 tokens — almost all of it wasted on random digits. PromptAliasMap assigns each pseudonym a tiny per-session code (f12, c3, m5) just for the LLM round-trip, ~1 token each — a 60–70% cut per identifier. The map is reversed on the response (ToPseudonym) before un-masking, so it's invisible to the user; unknown codes the model invents are left untouched. This let us relax the trimmer from 12 lines / 800 chars to 40 / 2500 (now just a safety net). The Python SYSTEM_PREAMBLE was updated to tell the model how to read the codes.
Why Temperature 0 for chat?
At 0.3 the small model sampled randomly — the same question could refuse ("I don't have that information") twice and then answer on the third try, even with the data present. Temperature 0 (greedy decoding) makes answers deterministic and grounded, which is exactly what you want for factual Q&A over data — no "retry until it works" lottery.
Why warm the model with a background hosted service?
The first request to a cold Ollama model pays the full model-load cost. LlmWarmupService (an IHostedService) initializes the Python runtime and sends a throwaway "hello" right after startup — on a thread-pool thread so it never blocks the host. Combined with Ollama's keep_alive (30m), the model stays resident in RAM, so real user requests never pay the cold-start penalty.
Why set the IIS app pool to a single worker process?
pythonnet has one Global Interpreter Lock and must own a single Python runtime. With multiple IIS worker processes (a "web garden"), each loads its own model (wasting RAM) and several fail to initialize Python cleanly. processModel.maxProcesses = 1 gives one process clean ownership of the runtime and model.
Why make the Python DLL path and data folder configurable?
Under an IIS app pool, %LocalAppData% resolves to C:\Windows\System32\config\systemprofile\… (not the user profile), and the app-pool identity often has neither python on PATH nor the env var. We made Python:DllPath configurable in appsettings.json and require loadUserProfile + setProfileEnvironment on the pool, so logs, prompts, and the Python runtime resolve deterministically on the server.
Why the IContextCapturingLogger pattern?
LoggerConsumer defers log IO to a background thread, where request-scoped AsyncLocal state (HttpContext URL + trace id) is gone — so cloud logs lost their context. Context-capturing loggers snapshot that context synchronously on the original request thread (Capture/CaptureError) and defer only the HTTP POST. Per-sink timeouts + concurrent fan-out keep one slow sink from blocking the file logger.
Why centralize Task.Run and Guid generation?
Scattered Task.Run calls dropped the CancellationToken and couldn't be controlled or mocked; IBackgroundTaskRunner centralizes background work and carries the token through. Likewise IIdGenerator replaces raw Guid.NewGuid() so ids (submission ids, log correlation ids, the chat questionId for end-to-end tracing) come from one seam that's substitutable in tests.
Why IFileSystem abstraction for all File/Path/Directory calls?
Every service that touches the filesystem goes through IFileSystem. This enables unit testing without real disk I/O (mock the interface) and makes services host-agnostic. No service directly calls File.Exists, Path.Combine, or Directory.CreateDirectory.

Database Schema

Two database systems: SQL Server (API/Server — EF Core) for centralized training data, and local SQLite databases (WPF/CLI) for pseudonyms, submission queue, and analysis history.

SQL Server — Training Database (EF Core)

Centralized on the API server. Stores all anonymized training submissions received from clients. Used by the Server's RetrainingService to retrain ML models periodically.

TrainingSubmissions

Parent table. One row per analysis run submitted by any client.

ColumnTypeDescription
Idint (PK)Auto-increment primary key
SubmissionIdstringUnique GUID per analysis run
ClientIdstringAnonymous client identifier (persisted locally)
SchemaVersionintSubmission format version for backward compatibility
SubmittedAtDateTimeWhen the client ran the analysis
ReceivedAtDateTimeWhen the API received it
AppVersionstringClient app version (e.g. "v7.0")
TotalFoldersintNumber of folders in the analyzed tree
MaxDepthintMaximum nesting depth

FolderClassifications

One row per classified folder. Contains ML features for retraining. FK → TrainingSubmissions.

ColumnTypeDescription
Idint (PK)Auto-increment
SubmissionIdint (FK)Parent submission
NamePseudonymstringFolder pseudonym (e.g. "Folder_9991607")
ParentNamePseudonymstringParent folder pseudonym
DepthfloatNesting level in tree
ChildCountfloatNumber of direct children
SiblingCountfloatNumber of siblings at same level
HasDraftsSubfolderboolFeature: has a "Drafts" child
ContainsPersonNameboolFeature: name matches person pattern
ContainsCorpIdentifierboolFeature: name contains Inc/Ltd/Corp/LLC
MatchesLegalCategoryboolFeature: name is a known legal category
NameLengthfloatFeature: character count of folder name
IsAllCapsboolFeature: name is all uppercase
IsLeafboolFeature: has no children
PredictedRolestringClassification result (Client, Matter, etc.)
ConfidencefloatClassification confidence 0.0-1.0

AnomalyFeedback

User feedback on detected anomalies. Trains severity and auto-dismiss models. FK → TrainingSubmissions.

ColumnTypeDescription
Idint (PK)Auto-increment
SubmissionIdint (FK)Parent submission
AnomalyTypeintEnum: SpellingError=0, CaseInconsistency=1, ...
SeverityfloatSeverity score 0.0-1.0
IsValidatedboolWhether user confirmed the anomaly
Resolutionstring?User action: "Consolidate", "Dismissed", etc.
FolderDepthintContext feature for ML
FolderRoleintContext feature for ML
FolderConfidencefloatContext feature for ML
FolderNameLengthintContext feature for ML

EntityTypes

Extracted entities with features for entity-type classifier retraining. FK → TrainingSubmissions.

ColumnTypeDescription
Idint (PK)Auto-increment
SubmissionIdint (FK)Parent submission
NamePseudonymstringEntity pseudonym
DetectedTypeintEnum: Person, Corporation, Partnership, ...
ConfidencefloatDetection confidence
ContainsCommaboolFeature: "Last, First" pattern
ContainsCorpIdentifierboolFeature: Inc/Ltd/Corp
ContainsPersonNameboolFeature: person name pattern
ContainsTrustKeywordboolFeature: "Trust", "Estate"
ContainsPartnershipKeywordboolFeature: "LLP", "Partners"
ContainsGovernmentKeywordboolFeature: "Ministry", "Crown"
NameLength / WordCount / IsAllCapsfloat/boolAdditional features

DuplicatePairs

Semantic duplicate pairs with user validation. Trains the duplicate detector. FK → TrainingSubmissions.

ColumnTypeDescription
Idint (PK)Auto-increment
SubmissionIdint (FK)Parent submission
NamePseudonymA / BstringThe two folder pseudonyms in the pair
NameSimilarityfloatString similarity score
LengthDifferencefloatAbsolute length difference
SharePrefix / ShareSuffixboolWhether names share a common prefix/suffix
IsDuplicateboolUser-confirmed: are they actually duplicates?
Resolutionstring?User action taken

ModelBundles

Retrained ML model bundles stored as binary BLOBs. Only one is active at a time.

ColumnTypeDescription
Idint (PK)Auto-increment
VersionintSequential version number
CreatedAtDateTimeWhen the bundle was generated
AppVersionstringServer version that created it
FileNamestringOriginal ZIP filename
FileDatabyte[]Complete ZIP bundle (BLOB)
TrainingSubmissionCountintHow many submissions were used to train
ETagstringHTTP ETag for client caching
IsActiveboolWhether this is the current active bundle
Local SQLite Databases (Client-Side)

Stored on the user's machine under %LocalAppData%\DocTreeAnalyzer\{host}\. Each host (WPF, API, CLI) gets its own isolated copy.

pseudonyms.db

Maps real names to pseudonyms. Deterministic: same input always gets same pseudonym across sessions.

ColumnTypeDescription
idINTEGER (PK)Auto-increment
real_valueTEXTOriginal name (e.g. "FTI Professional Grade Inc")
pseudonymTEXT (UNIQUE)Generated pseudonym (e.g. "Folder_9991607")
typeTEXTCategory: "Folder", "Person", "Entity"
first_seenTEXTISO timestamp of first mapping
UNIQUE constraint
(real_value, type) — same name always maps to same pseudonym
Index: idx_pseudonym
ON pseudonyms(pseudonym) — fast reverse lookup for unmasking

submissions.db

Offline queue for training data. Persists submissions until successfully sent to the API.

ColumnTypeDescription
submission_idTEXT (PK)Unique submission GUID
payload_jsonTEXTFull serialized ObfuscatedTrainingSubmission
created_atTEXTWhen the submission was enqueued
retry_countINTEGERNumber of failed send attempts
Retry Policy
Max 10 retries, max age 30 days — then discarded
Drain Behavior
DrainAsync() sends queued items & deletes on success

Local File Storage

All local files are organized under %LocalAppData%\DocTreeAnalyzer\{host}\. Host isolation ensures the API and WPF never touch each other's files.

Directory Layout — Per-Host Isolation

Root: %LocalAppData%\DocTreeAnalyzer\ — each host gets its own subdirectory

Presentation\ (WPF)
SQLitepseudonyms.db
SQLitesubmissions.db
SQLiteanalysis.db
ZIPuniversal.dtamodel
Textmodel-etag.txt
JSONprojects.json
SQLiteprojects\default.db
TextLogs\log_YYYYMMDD.txt
API\ (ASP.NET)
SQLitepseudonyms.db
SQLitesubmissions.db
Textpython.log
TextLogs\log_YYYYMMDD.txt
Server\ (Worker)
TextLogs\log_YYYYMMDD.txt
CLI\ (Console)
SQLitepseudonyms.db
TextLogs\log_YYYYMMDD.txt
Shared file type across hosts
API-only file (python.log)
WPF-only files (model, projects)
File Details
FileFormatCreated ByDescription
pseudonyms.db SQLite SqlitePseudonymService Stores real_value ↔ pseudonym mappings. Deterministic: "ACME Corp" always becomes "Folder_9991607". Used for both masking (before sending to LLM/API) and unmasking (when displaying LLM responses). Each host has its own copy to prevent SQLite locking conflicts.
submissions.db SQLite SubmissionQueue Offline-first queue for training data submissions. When the API is unreachable, submissions persist here and retry on next DrainAsync(). Max 10 retries, max age 30 days. Automatically cleaned up after successful send.
analysis.db SQLite AnalysisRepository Stores complete analysis sessions for the history feature. Each session includes the full tree, classifications, anomalies, entities, and patterns. Users can reload any previous analysis from the history panel.
universal.dtamodel ZIP ModelUpdater Cached ML model bundle downloaded from the API. Contains 5 ML.NET model files: folder-classifier.zip, semantic-duplicate.zip, entity-type.zip, anomaly-severity.zip, anomaly-dismiss.zip, plus a manifest.json with version info. Updated via ETag-based conditional GET.
model-etag.txt Text FileModelUpdateSettings Single-line file storing the HTTP ETag of the last downloaded model bundle. ModelUpdater sends this in If-None-Match header — if the server returns 304, the cached bundle is still current and no download is needed.
projects.json JSON ProjectRepository Index of all named projects. Each project has an ID, name, source file path, and creation date. Projects organize analyses by client engagement or matter.
projects\default.db SQLite ProjectRepository Fallback project database used when no named project is selected. Stores analysis sessions that aren't assigned to a specific project.
Logs\log_YYYYMMDD.txt Text FileLogger Daily rotating log files. Format: [timestamp] [LEVEL] message. Exceptions include type, message, stack trace, and inner exception. Thread-safe via SemaphoreSlim. One file per day per host.
python.log Text chat_engine.py Python-side diagnostic log (API host only). Records LLM provider creation, model loading, chat calls, response lengths, and errors. DEBUG level to file, INFO to console. Silences noisy httpcore/httpx logs.
{name}.feedback.json JSON FeedbackStore Saved alongside the source tree file (not in AppData). Stores user's active-learning answers (role overrides, anomaly validations). Re-applied when the same file is re-analyzed to preserve user feedback.
Model Bundle Contents (universal.dtamodel)

A single ZIP archive containing all 5 retrained ML.NET models plus a manifest. Downloaded via conditional GET (ETag).

universal.dtamodel (ZIP archive)
JSON
manifest.json
Version, training submission count, app version, creation date
folder-classifier.zip
FolderRole classification
(Client, Matter, Category...)
semantic-duplicate.zip
Duplicate pair detection
(similarity threshold)
entity-type.zip
Entity classification
(Person / Corp / Partnership)
anomaly-severity.zip
Severity prediction
(0.0 – 1.0 score)
anomaly-dismiss.zip
Auto-dismiss probability
(skip noise anomalies)
ModelUpdater
If-None-Match: ETag
API
GET /api/models/latest
200 or 304?
200 = download new
304 = cache still valid

Tech Stack

Runtime

.NET 10 C# 13 Python 3.12

UI Framework

WPF CommunityToolkit.Mvvm MVVM Pattern

API

ASP.NET Minimal APIs Swagger/OpenAPI

Machine Learning

ML.NET Qwen2.5:3b Ollama pythonnet

Data Storage

SQLite EF Core SQL Server

Testing

MSTest Moq 196 Tests

Key Dependencies
PackageProjectPurpose
Microsoft.Extensions.DependencyInjection.AbstractionsCoreOnly NuGet in Core — DI registration
Microsoft.Data.SqliteSqlitePseudonym DB + Submission Queue
Microsoft.MLMLFolder classifier training/inference
pythonnetApiC# ↔ Python bridge for LLM
Microsoft.EntityFrameworkCore.SqliteDatabaseTraining data persistence
CommunityToolkit.MvvmWpfMVVM source generators
ollama (pip)PythonAiOllama client for LLM inference
DocTreeAnalyzer — Technical Presentation — June 2026