SprintFour Hackathon · Problem Statement 1: Trust and Explainability Built by Amritha S Nidhi (CB.SC.U4CSE23404)
Marcus has a sensitive document he wants to process through an AI tool. He has heard stories of files marked "redacted" where the sensitive content was still recoverable underneath. He will not adopt a tool he cannot interrogate. Every redaction needs a reason. Every non-redaction needs a reason too. Silence reads as a miss.
The standard approach to this problem produces a document with black bars and no further explanation. The user is expected to trust the bars. Marcus does not.
Most redaction tools explain what they hid. Nivora treats kept-visible content with equal seriousness.
When a location like "Houston" remains in the document, a reviewer clicking it sees: LOCATION type, heuristic-judged at 55% confidence, kept visible because city-level location alone is a low-entropy identifier — and a direct acknowledgment that this was a judgment call, not a certainty. When "HIPAA §164.506" is left in, the explanation is different: rule-matched at 97%, confidently cleared because it is a legal reference, not personal data.
Silence is not trust. Explaining the kept-visible decisions is as important as explaining the redacted ones.
The document renders with three distinct visual states:
[PERSON_1], [EMAIL_1]Clicking any of the three opens a panel showing: PII type, detection method (rule-matched / heuristic / Presidio NER / GLiNER), confidence score on a visual bar, treatment mode, and a one-sentence plain-English reason. Borderline confidence (below 0.50) renders in amber rather than blue — so a 38% marital status match looks visually different from a 99% SSN match.
A confidence threshold slider re-evaluates all spans client-side in real time. Dragging it changes what is redacted and what is not, live, and the explanation panel surfaces the override: "Nivora originally chose redacted. At your current threshold, this span is kept visible."
An AI Preview toggle shows exactly what a downstream AI model will receive — redacted spans become blank, anonymized spans become their token, kept-visible spans remain unchanged.
A Risk Dashboard aggregates all detections into a document-level risk level (LOW → CRITICAL) with combination-risk notes when name, location, and date appear together.
Trust is not binary. The slider makes it tunable.

The system is organised into four swim lanes:
| Lane | Components |
|---|---|
| User | Uploads .pdf, .docx, or .txt; selects a document profile |
| Frontend | Upload Page → Review Page → Audit Page; AI Assistant; Vite dev server proxying /api/* to port 8000 |
| Backend API | FastAPI on port 8000; text extraction; in-memory session; calibration store |
| Detection Pipeline | 4 stages run sequentially; spans are merged and deduplicated before returning to the client |
| Layer | Technology | Notes |
|---|---|---|
| Frontend | React 18, Vite 5, Tailwind CSS 3 | SPA, no SSR |
| WebGL | Three.js, @react-three/fiber | Dither wave on upload page |
| Backend | FastAPI 0.115, Uvicorn 0.30 | Single-process, no database |
| Text parsing | pdfminer.six (PDF), python-docx (DOCX) | Multi-encoding fallback for TXT |
| NLP — Stage 3 | Microsoft Presidio + spaCy en_core_web_lg | Optional; no-ops if not installed |
| NLP — Stage 4 | GLiNER knowledgator/gliner-pii-base-v1.0 | Optional; fine-tuned checkpoint included |
| Validation | Pydantic v2 |
No database, no authentication, no live LLM call. All detection runs in Python on the uploaded document. The only persistent state is an in-memory session dictionary and a calibration.json file for reviewer feedback.
Detection runs in four sequential stages followed by a merge step. The multi-stage design separates high-confidence deterministic detection from progressively softer heuristic and model-based detection, keeping confidence scores honest. A 0.99 on an email regex and a 0.60 on a name heuristic are genuinely different claims — they should look different to the reviewer.
detection/rule_layer.py)Deterministic regex detectors. Confidence 0.92–1.00. All matches tagged rule_matched.
Direct identifiers:
| Detector | Pattern | Confidence | Treatment |
|---|---|---|---|
Standard [email protected] + partial @provider without TLD (PDF artifact) | 0.97–0.99 | redact | |
| SSN | XXX-XX-XXXX with lookaheads filtering invalid area codes (000, 666, 9xx) | 1.00 | redact |
| PHONE_NUMBER | US (XXX) XXX-XXXX and Indian +91 XXXXX XXXXX | 0.95 | redact |
| CREDIT_CARD | 16-digit XXXX-XXXX-XXXX-XXXX grouping | 0.99 | redact |
| MEDICAL_RECORD_NUMBER | MRN prefix + 5–8 digits | 0.99 | redact |
| NPI_NUMBER | NPI prefix + 10 digits | 0.99 | redact |
| CREDENTIAL_ID | Cert ID / License # / Badge No + alphanumeric identifier | 0.95 | redact |
Financial identifiers:
| Detector | Pattern | Confidence | Treatment |
|---|---|---|---|
| TAX_ID (PAN) | Indian AAAAA9999A format | 0.95 | redact |
| TAX_ID (EIN/TIN) | EIN / TIN / FEIN XX-XXXXXXX | 0.97 | redact |
| UPI_ID | handle@okaxis, @ybl, @paytm, 30+ UPI providers | 0.97 | redact |
| IBAN | CC##AAAA####### international bank account number | 0.97 | redact |
| CLIENT_ID | Client / Folio / Demat / Broker ID / Number + alphanumeric | 0.93 | redact |
| CARD_FRAGMENT | ending in XXXX last-4 digits | 0.92 | redact |
| CARD_EXPIRY | Exp / Valid Through MM/YY | 0.93 | redact |
| POLICY_NUMBER | AA-NNNNN[-XX] policy/account number format | 0.97 | redact |
Other:
| Detector | Pattern | Confidence | Treatment |
|---|---|---|---|
| DATE | ISO dates, written dates | 0.85 | anonymize |
| IP_ADDRESS | IPv4 X.X.X.X | 0.95 | anonymize |
| PROFILE_URL | github.com/<handle>, linkedin.com/in/<handle> | 0.98 | redact |
| SOCIAL_HANDLE | Kebab slug with 3+ hyphens + digit; username after PDF CID artifact | 0.85 | redact |
| REGULATORY_CITATION | HIPAA / GDPR / CCPA / PCI DSS / SOC 2 / ISO 27001 references | 0.97 | kept_visible |
Ordering matters: MRN, NPI, and HIPAA patterns run before the generic POLICY_NUMBER pattern. Without this, MRN-008473 would be labelled as POLICY_NUMBER.
detection/judgment_layer.py)Heuristic detectors for unstructured PII. Confidence 0.35–0.90. All matches tagged heuristic or heuristic_judged. The softer confidence range is a design choice — it accurately represents that these are educated guesses, not certifications.
Name detection:
| Sub-detector | Strategy | Confidence |
|---|---|---|
| All-caps header | AMRITHA S NIDHI alone on its own line | 0.83 |
| Title prefix | Dr., Mr., Prof. + title-case name words | 0.80–0.90 |
| Label-answer | Patient Name:, Manager:, Reporting To:, Reference: | 0.82 |
| Standalone line | Title-case name alone on its own line | 0.78 |
| Generic heuristic | Consecutive title-case words, scored against ~200-term _NON_NAME exclusion set | 0.40–0.95 |
| Salutation | Dear …, Regards, …, Signed by … | 0.60 |
_SECTION_WORDS (200+ terms) prevents document section headers — HOLDINGS BREAKDOWN, CURRICULUM VITAE, ACCOUNT SUMMARY — from matching as person names.
Name scoring detail. Base score 0.60. +0.20 if a title cue precedes the phrase within 40 characters. −0.25 for each word matching _NON_NAME. Score < 0.40 → dropped. Score 0.40–0.61 → kept_visible. Score ≥ 0.62 → redacted with anonymize mode.
Context detectors:
| Detector | Strategy | Confidence |
|---|---|---|
| ORGANIZATION | Lines ending in a month-year date (resume format); words ending in University/College/Institute | 0.83–0.88 |
| LOCATION | Gazetteer — ~90 entries covering US cities, countries, Indian states/cities | 0.55 |
| ADDRESS | Street number + name + type on a single line | 0.70–0.90 |
| HEALTHCARE_FACILITY | Capitalized phrase ending in Hospital/Clinic/Medical Center | 0.70–0.75 |
| HEALTH_INFO | Medical specialty terms and health condition keyword list | 0.65–0.80 |
| SALARY | ₹, Rs., INR, LPA, lakhs + amount; USD/GBP/EUR salary formats | 0.88 |
| NATIONALITY | Nationality: Indian, Citizenship: … labelled fields | 0.88 |
| FINANCIAL_AMOUNT | Portfolio Value, Net Worth, Tax Liability, Capital Gains (financial profile only) | 0.82 |
detection/presidio_layer.py)Microsoft Presidio backed by spaCy en_core_web_lg. Adds NLP named-entity recognition and Presidio's built-in regex recognizers. No-ops gracefully if Presidio or spaCy is not installed.
| Presidio entity | Mapped type | Mode |
|---|---|---|
| PERSON | PERSON_NAME | anonymize |
| LOCATION | LOCATION | anonymize |
| ORGANIZATION | ORGANIZATION | anonymize |
| US_DRIVER_LICENSE | DRIVER_LICENSE | redact |
| US_PASSPORT | PASSPORT | redact |
| US_ITIN | TAX_ID | redact |
| IBAN_CODE | IBAN | redact |
detection/gliner_layer.py)GLiNER enables zero-shot NER against arbitrary label lists, selected per document profile. A fine-tuned checkpoint (trained_pii_model/checkpoint-1000) is available. Custom entity labels can be added at runtime via POST /api/custom-rule. No-ops gracefully if GLiNER is not installed.
| Profile | Example entities targeted |
|---|---|
| medical | patient name, diagnosis, medication, treating physician, insurance ID, lab test result |
| legal | counterparty name, signatory, case number, attorney name, settlement amount |
| financial | account holder name, account number, tax ID, beneficiary name, portfolio value |
| hr | employee name, salary, manager name, employee ID, termination reason |
| general | person, organization, location, date |
detection/merge.py)Spans from all four stages are merged into a single deduplicated list:
SEVERITY_MAPspan_1, span_2, …compute_risk_score() sums severity scores and adds combination risk when name + location, name + date, or name + medical info appear togetherA second-pass system surfaces issues the main pipeline may have missed or gotten wrong. It runs automatically after document load and populates a correction queue for human review.
/api/corrections)| Category | Description |
|---|---|
potential_misses | High-risk PII types detected but left visible + fresh scanner finds |
suspicious_redactions | Heuristic redactions with confidence < 0.55 — potential false positives |
fresh_scan_spans | Patterns the main pipeline missed entirely |
Correction scanner patterns (detection/correction_scanner.py):
| Pattern | Notes |
|---|---|
| Bare 10-digit number | Possible phone; lookbehind (?<![A-Z\d]) excludes ISINs |
NXX-NXX-XXXX | Dot/dash-separated US phone format |
| Salutation names | Names after Dear, Regards, Signed by, Warm regards |
| From/To names | Names on From:, To:, Prepared by:, Submitted by: lines |
R (redact/restore), K (keep), → (skip)When a reviewer acts through the queue, a userForced: true flag is set on the span, bypassing the confidence threshold slider for that span permanently.
| Decision | Mode | Visual treatment | Guarantee |
|---|---|---|---|
redacted | redact | Solid dark block with 🔒 | Permanently removed, not recoverable |
redacted | anonymize | Monospace token [PERSON_1] | Recoverable via token map |
kept_visible | null | Original text, coloured underline | Scanned and deliberately left in |
Token consistency. Identical text + type combinations always get the same token regardless of how many times they appear. [PERSON_1] always refers to the same individual throughout the document.
| Profile | Activates |
|---|---|
general | All stages with general entity labels |
medical | GLiNER medical entities; HEALTHCARE_FACILITY, HEALTH_INFO heuristics |
financial | GLiNER financial entities; FINANCIAL_AMOUNT heuristic; IBAN / CLIENT_ID / TAX_ID / UPI_ID rules |
legal | GLiNER legal entities |
hr | GLiNER HR entities; SALARY, NATIONALITY heuristics |
conseal-ps1/
├── backend/
│ ├── main.py FastAPI app — 5 API routes, session management
│ ├── models.py Pydantic v2: PIISpan, ProcessResponse, FeedbackRequest
│ ├── extraction.py Text extraction — .txt / .docx / .pdf
│ ├── calibration.json Feedback persistence (type:method → correct/incorrect)
│ ├── sample_doc.txt Fallback sample document
│ ├── sample_docs/
│ │ └── sample_medical.txt Primary medical sample document
│ ├── requirements.txt Python dependencies
│ ├── detection/
│ │ ├── rule_layer.py Stage 1: 20+ regex detectors
│ │ ├── judgment_layer.py Stage 2: heuristic detectors + SEVERITY_MAP
│ │ ├── presidio_layer.py Stage 3: Presidio NER (optional)
│ │ ├── gliner_layer.py Stage 4: GLiNER zero-shot NER (optional)
│ │ ├── merge.py Overlap resolution, severity, risk score
│ │ └── correction_scanner.py PS3 second-pass scanner
│ └── training/
│ ├── synthetic_generator.py PII-annotated training data generator
│ └── train.py GLiNER fine-tuning script
│
├── frontend/
│ ├── vite.config.js Dev server :5173, proxies /api/* → :8000
│ ├── tailwind.config.js Design tokens: ink, primary, teal, surface, border
│ └── src/
│ ├── App.jsx Router: UploadPage / ReviewPage / AuditPage
│ ├── index.css Tailwind base + span classes
│ ├── lib/spans.js buildSegments(), applyThreshold(), tokenFor()
│ ├── pages/
│ │ ├── UploadPage.jsx Landing — file upload + profile selector
│ │ ├── ReviewPage.jsx Main review workspace
│ │ └── AuditPage.jsx Compliance audit log — filter, sort, export CSV
│ └── components/
│ ├── DocumentViewer.jsx Span highlighting, token map, AI preview
│ ├── TrustPanel.jsx Span explanation (method, confidence, reason, feedback)
│ ├── RiskDashboard.jsx Document-level risk badge + combination notes
│ ├── ConfidenceSlider.jsx Global threshold (real-time, client-side)
│ ├── CustomRulesInput.jsx Add custom entity labels → /api/custom-rule
│ ├── AIPreviewToggle.jsx Toggle AI-visible vs annotated view
│ ├── AIAssistant.jsx Span-aware Q&A assistant
│ ├── KeyboardShortcuts.jsx Shortcut reference modal (?)
│ ├── CorrectionBanner.jsx PS3 alert banner
│ ├── CorrectionQueue.jsx PS3 one-at-a-time review queue
│ └── Dither.jsx / .css Three.js WebGL dither wave (upload page)
│
├── Nivora Architecture.png System architecture diagram
├── architecture.svg Architecture diagram source
├── README.md
└── WRITEUP.md
Optional (for full NLP coverage):
presidio-analyzer + spacy + en_core_web_lg — enables Stage 3gliner — enables Stage 4Nivora runs without Stage 3 and 4 installed — those layers no-op and the rule + heuristic layers handle detection alone.
Terminal 1 — Backend
cd conseal-ps1/backend
pip install -r requirements.txt
python -m uvicorn main:app --host 127.0.0.1 --port 8000 --reload
FastAPI interactive docs: http://127.0.0.1:8000/docs
Terminal 2 — Frontend
cd conseal-ps1/frontend
npm install --legacy-peer-deps
npm run dev
Open http://localhost:5173. Vite proxies all /api/* to :8000 automatically.
Optional — full NLP stack
pip install presidio-analyzer presidio-anonymizer spacy
python -m spacy download en_core_web_lg
pip install gliner
Supported file types: .txt, .docx, .pdf — maximum 5 MB.
--legacy-peer-depsis required because@react-three/fiberv8 (React 18 compatible) has a peer dependency declaration that conflicts with npm 9's strict resolution. The installed versions are fully compatible at runtime.
All routes are prefixed /api. Errors follow FastAPI's {"detail": "..."} shape.
Upload a document and run the full 4-stage pipeline.
Request: multipart/form-data
file — .txt, .docx, or .pdfprofile — general / medical / financial / legal / hr (default: general)custom_entities — JSON array of extra entity labels (default: [])Response:
{
"document_text": "...",
"spans": [
{
"id": "span_1",
"text": "[email protected]",
"start": 42,
"end": 62,
"type": "EMAIL",
"decision": "redacted",
"mode": "redact",
"detection_method": "rule_matched",
"confidence": 0.99,
"severity": "HIGH",
"stage": 1,
"reason": "Matched standard email address pattern. Permanently removed.",
"evidence": "regex: email pattern ([email protected])"
}
],
"risk_score": {
"level": "HIGH",
"score": 14,
"critical_count": 0,
"high_count": 3,
"combination_notes": ["Name + Location together enable geographic re-identification"]
}
}
Error codes: 413 (file too large) · 415 (unsupported type) · 422 (extraction failed)
Process the built-in sample medical document without uploading. Accepts ?profile=medical. Returns the same shape as /api/process. Use this during demos.
Return PS3 correction review data for the most recently processed document.
{
"potential_misses": [...],
"suspicious_redactions": [...],
"fresh_scan_spans": [...],
"total_issues": 5
}
Re-run detection with an additional custom entity label for GLiNER.
{ "entity_label": "employee ID", "profile": "hr" }
Record reviewer feedback to calibrate future confidence estimates.
{ "span_id": "span_3", "type": "PERSON_NAME", "detection_method": "heuristic", "correct": false }
Export all spans as a CSV file (falls back to client-side generation if pandas is unavailable).
Returns {"status": "ok"}.
The following two-minute sequence demonstrates the full trust experience:
Landing page. The upload page opens with a Bayer-dithered wave animation. Select a document profile and drag in a file — or click "Try a sample document."
Processing. A pipeline progress animation plays (extracting text → rule layer → heuristics → Presidio → GLiNER → merging). The Risk Dashboard populates as the review screen appears.
First impression. The document renders with highlights across all four detection methods: black bars for SSN/IBAN/TAX_ID; coloured tokens for names and email; dotted underlines for location, marital status, and the HIPAA citation.
Click a redacted span. Click the SSN black bar. The panel shows: SSN · Rule-matched · 100% confidence · Redact — permanently removed.
Click a kept-visible span. Click the dotted-underlined city name. Same panel format, different content: LOCATION · Heuristic · 55% · kept visible — "a judgment call, not a certainty."
Click a confidently cleared span. Click the HIPAA citation. Rule-matched · 97% · kept visible — "a legal reference, not personal data." The panel makes the distinction between uncertain clearance and confident clearance visible.
PS3 Correction Review. The orange banner appears: "N potential misses, M uncertain redactions." Click Review → to enter the correction queue. Work through each issue one at a time with keyboard shortcuts R / K / →.
Adjust the threshold. Drag the slider down to 40. Low-confidence spans flip in real time. The panel surfaces overrides: "Nivora originally chose kept visible. At your current threshold, this span is redacted."
AI Preview. Toggle AI Preview to see exactly what a downstream AI would receive — clean text with redacted gaps and anonymization tokens. No inference, no API key, no guessing.
Audit Log. Navigate to Audit Log. Filter by severity, sort by confidence, export CSV as a GDPR/HIPAA compliance artifact.
The point: trust is not binary. The slider makes it tunable, and Nivora tells you exactly what changed and why.
Symmetric treatment of kept-visible content. This is the core product decision. A span that was "considered and cleared" is explained with the same panel, the same confidence bar, and the same level of detail as a span that was redacted. Content that was never flagged at all does not receive this treatment — it is just plain text. The visual distinction matters: coloured underline vs. unstyled text communicates "this was seen" vs. "this was not looked at."
Four stages with honest confidence ranges. Regex patterns for structured PII (email, SSN, IBAN) are essentially perfect detectors — 0.97–1.00 is accurate. Heuristics for names are genuinely uncertain — 0.60–0.85 is also accurate. Presidio adds NLP-backed NER for entities that resist regex. GLiNER adds domain-specific zero-shot detection tuned per profile. Combining all four gives coverage without inflating confidence scores.
_SECTION_WORDS frozenset. Document section headers (CURRICULUM VITAE, HOLDINGS BREAKDOWN, ACCOUNT SUMMARY) are all-caps sequences that match the standalone-name detector pattern. A 200+ term frozenset lookup at O(1) blocks them cleanly across resume, financial, medical, and legal document types.
PS3 correction queue — one issue at a time. Bulk review encourages rubber-stamping. Forcing reviewers to see one issue at a time with full context and a binary choice (redact / keep) produces more deliberate decisions and a cleaner audit trail. A userForced flag on acted-upon spans bypasses the confidence slider permanently.
ISINs excluded from the correction scanner. International Securities Identification Numbers are 12-character codes starting with two uppercase letters followed by 10 digits. The lookbehind (?<![A-Z\d]) ensures the bare-10-digit scanner never fires on the digit portion of an ISIN.
Client-side threshold re-evaluation. Re-running the backend pipeline on every threshold change would add 200–500ms latency for a display-layer operation. All span data is in the browser after the initial upload; applyThreshold() re-evaluates synchronously in under one millisecond. The explanation panel surfaces threshold overrides explicitly — silence never reads as a miss.
Phone and email are permanently redacted, not anonymized. A recoverable token ([EMAIL_1]) is still a re-identification risk if the token map is exposed. These values have no utility for a downstream AI evaluating document structure. They are pure contact data and should be removed entirely.
REGULATORY_CITATION is kept_visible. References to HIPAA, GDPR, CCPA, PCI DSS are legal context, not personal data. Redacting them would degrade the meaning of compliance documents without protecting any individual's privacy.
No live LLM. Removing API-key setup, rate limits, and network failure risk from the demo path was the correct trade-off. The judgment layer is exactly where a fine-tuned NER model or LLM-based extractor would slot in a production system; the heuristics are transparent, explainable stand-ins.
SprintFour Hackathon · Problem Statement 1: Trust and Explainability Built by Amritha S Nidhi (CB.SC.U4CSE23404)
Marcus has a sensitive document he wants to process through an AI tool. He has heard stories of files marked "redacted" where the sensitive content was still recoverable underneath. He will not adopt a tool he cannot interrogate. Every redaction needs a reason. Every non-redaction needs a reason too. Silence reads as a miss.
The standard approach to this problem produces a document with black bars and no further explanation. The user is expected to trust the bars. Marcus does not.
Most redaction tools explain what they hid. Nivora treats kept-visible content with equal seriousness.
When a location like "Houston" remains in the document, a reviewer clicking it sees: LOCATION type, heuristic-judged at 55% confidence, kept visible because city-level location alone is a low-entropy identifier — and a direct acknowledgment that this was a judgment call, not a certainty. When "HIPAA §164.506" is left in, the explanation is different: rule-matched at 97%, confidently cleared because it is a legal reference, not personal data.
Silence is not trust. Explaining the kept-visible decisions is as important as explaining the redacted ones.
The document renders with three distinct visual states:
[PERSON_1], [EMAIL_1]Clicking any of the three opens a panel showing: PII type, detection method (rule-matched / heuristic / Presidio NER / GLiNER), confidence score on a visual bar, treatment mode, and a one-sentence plain-English reason. Borderline confidence (below 0.50) renders in amber rather than blue — so a 38% marital status match looks visually different from a 99% SSN match.
A confidence threshold slider re-evaluates all spans client-side in real time. Dragging it changes what is redacted and what is not, live, and the explanation panel surfaces the override: "Nivora originally chose redacted. At your current threshold, this span is kept visible."
An AI Preview toggle shows exactly what a downstream AI model will receive — redacted spans become blank, anonymized spans become their token, kept-visible spans remain unchanged.
A Risk Dashboard aggregates all detections into a document-level risk level (LOW → CRITICAL) with combination-risk notes when name, location, and date appear together.
Trust is not binary. The slider makes it tunable.

The system is organised into four swim lanes:
| Lane | Components |
|---|---|
| User | Uploads .pdf, .docx, or .txt; selects a document profile |
| Frontend | Upload Page → Review Page → Audit Page; AI Assistant; Vite dev server proxying /api/* to port 8000 |
| Backend API | FastAPI on port 8000; text extraction; in-memory session; calibration store |
| Detection Pipeline | 4 stages run sequentially; spans are merged and deduplicated before returning to the client |
| Layer | Technology | Notes |
|---|---|---|
| Frontend | React 18, Vite 5, Tailwind CSS 3 | SPA, no SSR |
| WebGL | Three.js, @react-three/fiber | Dither wave on upload page |
| Backend | FastAPI 0.115, Uvicorn 0.30 | Single-process, no database |
| Text parsing | pdfminer.six (PDF), python-docx (DOCX) | Multi-encoding fallback for TXT |
| NLP — Stage 3 | Microsoft Presidio + spaCy en_core_web_lg | Optional; no-ops if not installed |
| NLP — Stage 4 | GLiNER knowledgator/gliner-pii-base-v1.0 | Optional; fine-tuned checkpoint included |
| Validation | Pydantic v2 |
No database, no authentication, no live LLM call. All detection runs in Python on the uploaded document. The only persistent state is an in-memory session dictionary and a calibration.json file for reviewer feedback.
Detection runs in four sequential stages followed by a merge step. The multi-stage design separates high-confidence deterministic detection from progressively softer heuristic and model-based detection, keeping confidence scores honest. A 0.99 on an email regex and a 0.60 on a name heuristic are genuinely different claims — they should look different to the reviewer.
detection/rule_layer.py)Deterministic regex detectors. Confidence 0.92–1.00. All matches tagged rule_matched.
Direct identifiers:
| Detector | Pattern | Confidence | Treatment |
|---|---|---|---|
Standard [email protected] + partial @provider without TLD (PDF artifact) | 0.97–0.99 | redact | |
| SSN | XXX-XX-XXXX with lookaheads filtering invalid area codes (000, 666, 9xx) | 1.00 | redact |
| PHONE_NUMBER | US (XXX) XXX-XXXX and Indian +91 XXXXX XXXXX | 0.95 | redact |
| CREDIT_CARD | 16-digit XXXX-XXXX-XXXX-XXXX grouping | 0.99 | redact |
| MEDICAL_RECORD_NUMBER | MRN prefix + 5–8 digits | 0.99 | redact |
| NPI_NUMBER | NPI prefix + 10 digits | 0.99 | redact |
| CREDENTIAL_ID | Cert ID / License # / Badge No + alphanumeric identifier | 0.95 | redact |
Financial identifiers:
| Detector | Pattern | Confidence | Treatment |
|---|---|---|---|
| TAX_ID (PAN) | Indian AAAAA9999A format | 0.95 | redact |
| TAX_ID (EIN/TIN) | EIN / TIN / FEIN XX-XXXXXXX | 0.97 | redact |
| UPI_ID | handle@okaxis, @ybl, @paytm, 30+ UPI providers | 0.97 | redact |
| IBAN | CC##AAAA####### international bank account number | 0.97 | redact |
| CLIENT_ID | Client / Folio / Demat / Broker ID / Number + alphanumeric | 0.93 | redact |
| CARD_FRAGMENT | ending in XXXX last-4 digits | 0.92 | redact |
| CARD_EXPIRY | Exp / Valid Through MM/YY | 0.93 | redact |
| POLICY_NUMBER | AA-NNNNN[-XX] policy/account number format | 0.97 | redact |
Other:
| Detector | Pattern | Confidence | Treatment |
|---|---|---|---|
| DATE | ISO dates, written dates | 0.85 | anonymize |
| IP_ADDRESS | IPv4 X.X.X.X | 0.95 | anonymize |
| PROFILE_URL | github.com/<handle>, linkedin.com/in/<handle> | 0.98 | redact |
| SOCIAL_HANDLE | Kebab slug with 3+ hyphens + digit; username after PDF CID artifact | 0.85 | redact |
| REGULATORY_CITATION | HIPAA / GDPR / CCPA / PCI DSS / SOC 2 / ISO 27001 references | 0.97 | kept_visible |
Ordering matters: MRN, NPI, and HIPAA patterns run before the generic POLICY_NUMBER pattern. Without this, MRN-008473 would be labelled as POLICY_NUMBER.
detection/judgment_layer.py)Heuristic detectors for unstructured PII. Confidence 0.35–0.90. All matches tagged heuristic or heuristic_judged. The softer confidence range is a design choice — it accurately represents that these are educated guesses, not certifications.
Name detection:
| Sub-detector | Strategy | Confidence |
|---|---|---|
| All-caps header | AMRITHA S NIDHI alone on its own line | 0.83 |
| Title prefix | Dr., Mr., Prof. + title-case name words | 0.80–0.90 |
| Label-answer | Patient Name:, Manager:, Reporting To:, Reference: | 0.82 |
| Standalone line | Title-case name alone on its own line | 0.78 |
| Generic heuristic | Consecutive title-case words, scored against ~200-term _NON_NAME exclusion set | 0.40–0.95 |
| Salutation | Dear …, Regards, …, Signed by … | 0.60 |
_SECTION_WORDS (200+ terms) prevents document section headers — HOLDINGS BREAKDOWN, CURRICULUM VITAE, ACCOUNT SUMMARY — from matching as person names.
Name scoring detail. Base score 0.60. +0.20 if a title cue precedes the phrase within 40 characters. −0.25 for each word matching _NON_NAME. Score < 0.40 → dropped. Score 0.40–0.61 → kept_visible. Score ≥ 0.62 → redacted with anonymize mode.
Context detectors:
| Detector | Strategy | Confidence |
|---|---|---|
| ORGANIZATION | Lines ending in a month-year date (resume format); words ending in University/College/Institute | 0.83–0.88 |
| LOCATION | Gazetteer — ~90 entries covering US cities, countries, Indian states/cities | 0.55 |
| ADDRESS | Street number + name + type on a single line | 0.70–0.90 |
| HEALTHCARE_FACILITY | Capitalized phrase ending in Hospital/Clinic/Medical Center | 0.70–0.75 |
| HEALTH_INFO | Medical specialty terms and health condition keyword list | 0.65–0.80 |
| SALARY | ₹, Rs., INR, LPA, lakhs + amount; USD/GBP/EUR salary formats | 0.88 |
| NATIONALITY | Nationality: Indian, Citizenship: … labelled fields | 0.88 |
| FINANCIAL_AMOUNT | Portfolio Value, Net Worth, Tax Liability, Capital Gains (financial profile only) | 0.82 |
detection/presidio_layer.py)Microsoft Presidio backed by spaCy en_core_web_lg. Adds NLP named-entity recognition and Presidio's built-in regex recognizers. No-ops gracefully if Presidio or spaCy is not installed.
| Presidio entity | Mapped type | Mode |
|---|---|---|
| PERSON | PERSON_NAME | anonymize |
| LOCATION | LOCATION | anonymize |
| ORGANIZATION | ORGANIZATION | anonymize |
| US_DRIVER_LICENSE | DRIVER_LICENSE | redact |
| US_PASSPORT | PASSPORT | redact |
| US_ITIN | TAX_ID | redact |
| IBAN_CODE | IBAN | redact |
detection/gliner_layer.py)GLiNER enables zero-shot NER against arbitrary label lists, selected per document profile. A fine-tuned checkpoint (trained_pii_model/checkpoint-1000) is available. Custom entity labels can be added at runtime via POST /api/custom-rule. No-ops gracefully if GLiNER is not installed.
| Profile | Example entities targeted |
|---|---|
| medical | patient name, diagnosis, medication, treating physician, insurance ID, lab test result |
| legal | counterparty name, signatory, case number, attorney name, settlement amount |
| financial | account holder name, account number, tax ID, beneficiary name, portfolio value |
| hr | employee name, salary, manager name, employee ID, termination reason |
| general | person, organization, location, date |
detection/merge.py)Spans from all four stages are merged into a single deduplicated list:
SEVERITY_MAPspan_1, span_2, …compute_risk_score() sums severity scores and adds combination risk when name + location, name + date, or name + medical info appear togetherA second-pass system surfaces issues the main pipeline may have missed or gotten wrong. It runs automatically after document load and populates a correction queue for human review.
/api/corrections)| Category | Description |
|---|---|
potential_misses | High-risk PII types detected but left visible + fresh scanner finds |
suspicious_redactions | Heuristic redactions with confidence < 0.55 — potential false positives |
fresh_scan_spans | Patterns the main pipeline missed entirely |
Correction scanner patterns (detection/correction_scanner.py):
| Pattern | Notes |
|---|---|
| Bare 10-digit number | Possible phone; lookbehind (?<![A-Z\d]) excludes ISINs |
NXX-NXX-XXXX | Dot/dash-separated US phone format |
| Salutation names | Names after Dear, Regards, Signed by, Warm regards |
| From/To names | Names on From:, To:, Prepared by:, Submitted by: lines |
R (redact/restore), K (keep), → (skip)When a reviewer acts through the queue, a userForced: true flag is set on the span, bypassing the confidence threshold slider for that span permanently.
| Decision | Mode | Visual treatment | Guarantee |
|---|---|---|---|
redacted | redact | Solid dark block with 🔒 | Permanently removed, not recoverable |
redacted | anonymize | Monospace token [PERSON_1] | Recoverable via token map |
kept_visible | null | Original text, coloured underline | Scanned and deliberately left in |
Token consistency. Identical text + type combinations always get the same token regardless of how many times they appear. [PERSON_1] always refers to the same individual throughout the document.
| Profile | Activates |
|---|---|
general | All stages with general entity labels |
medical | GLiNER medical entities; HEALTHCARE_FACILITY, HEALTH_INFO heuristics |
financial | GLiNER financial entities; FINANCIAL_AMOUNT heuristic; IBAN / CLIENT_ID / TAX_ID / UPI_ID rules |
legal | GLiNER legal entities |
hr | GLiNER HR entities; SALARY, NATIONALITY heuristics |
conseal-ps1/
├── backend/
│ ├── main.py FastAPI app — 5 API routes, session management
│ ├── models.py Pydantic v2: PIISpan, ProcessResponse, FeedbackRequest
│ ├── extraction.py Text extraction — .txt / .docx / .pdf
│ ├── calibration.json Feedback persistence (type:method → correct/incorrect)
│ ├── sample_doc.txt Fallback sample document
│ ├── sample_docs/
│ │ └── sample_medical.txt Primary medical sample document
│ ├── requirements.txt Python dependencies
│ ├── detection/
│ │ ├── rule_layer.py Stage 1: 20+ regex detectors
│ │ ├── judgment_layer.py Stage 2: heuristic detectors + SEVERITY_MAP
│ │ ├── presidio_layer.py Stage 3: Presidio NER (optional)
│ │ ├── gliner_layer.py Stage 4: GLiNER zero-shot NER (optional)
│ │ ├── merge.py Overlap resolution, severity, risk score
│ │ └── correction_scanner.py PS3 second-pass scanner
│ └── training/
│ ├── synthetic_generator.py PII-annotated training data generator
│ └── train.py GLiNER fine-tuning script
│
├── frontend/
│ ├── vite.config.js Dev server :5173, proxies /api/* → :8000
│ ├── tailwind.config.js Design tokens: ink, primary, teal, surface, border
│ └── src/
│ ├── App.jsx Router: UploadPage / ReviewPage / AuditPage
│ ├── index.css Tailwind base + span classes
│ ├── lib/spans.js buildSegments(), applyThreshold(), tokenFor()
│ ├── pages/
│ │ ├── UploadPage.jsx Landing — file upload + profile selector
│ │ ├── ReviewPage.jsx Main review workspace
│ │ └── AuditPage.jsx Compliance audit log — filter, sort, export CSV
│ └── components/
│ ├── DocumentViewer.jsx Span highlighting, token map, AI preview
│ ├── TrustPanel.jsx Span explanation (method, confidence, reason, feedback)
│ ├── RiskDashboard.jsx Document-level risk badge + combination notes
│ ├── ConfidenceSlider.jsx Global threshold (real-time, client-side)
│ ├── CustomRulesInput.jsx Add custom entity labels → /api/custom-rule
│ ├── AIPreviewToggle.jsx Toggle AI-visible vs annotated view
│ ├── AIAssistant.jsx Span-aware Q&A assistant
│ ├── KeyboardShortcuts.jsx Shortcut reference modal (?)
│ ├── CorrectionBanner.jsx PS3 alert banner
│ ├── CorrectionQueue.jsx PS3 one-at-a-time review queue
│ └── Dither.jsx / .css Three.js WebGL dither wave (upload page)
│
├── Nivora Architecture.png System architecture diagram
├── architecture.svg Architecture diagram source
├── README.md
└── WRITEUP.md
Optional (for full NLP coverage):
presidio-analyzer + spacy + en_core_web_lg — enables Stage 3gliner — enables Stage 4Nivora runs without Stage 3 and 4 installed — those layers no-op and the rule + heuristic layers handle detection alone.
Terminal 1 — Backend
cd conseal-ps1/backend
pip install -r requirements.txt
python -m uvicorn main:app --host 127.0.0.1 --port 8000 --reload
FastAPI interactive docs: http://127.0.0.1:8000/docs
Terminal 2 — Frontend
cd conseal-ps1/frontend
npm install --legacy-peer-deps
npm run dev
Open http://localhost:5173. Vite proxies all /api/* to :8000 automatically.
Optional — full NLP stack
pip install presidio-analyzer presidio-anonymizer spacy
python -m spacy download en_core_web_lg
pip install gliner
Supported file types: .txt, .docx, .pdf — maximum 5 MB.
--legacy-peer-depsis required because@react-three/fiberv8 (React 18 compatible) has a peer dependency declaration that conflicts with npm 9's strict resolution. The installed versions are fully compatible at runtime.
All routes are prefixed /api. Errors follow FastAPI's {"detail": "..."} shape.
Upload a document and run the full 4-stage pipeline.
Request: multipart/form-data
file — .txt, .docx, or .pdfprofile — general / medical / financial / legal / hr (default: general)custom_entities — JSON array of extra entity labels (default: [])Response:
{
"document_text": "...",
"spans": [
{
"id": "span_1",
"text": "[email protected]",
"start": 42,
"end": 62,
"type": "EMAIL",
"decision": "redacted",
"mode": "redact",
"detection_method": "rule_matched",
"confidence": 0.99,
"severity": "HIGH",
"stage": 1,
"reason": "Matched standard email address pattern. Permanently removed.",
"evidence": "regex: email pattern ([email protected])"
}
],
"risk_score": {
"level": "HIGH",
"score": 14,
"critical_count": 0,
"high_count": 3,
"combination_notes": ["Name + Location together enable geographic re-identification"]
}
}
Error codes: 413 (file too large) · 415 (unsupported type) · 422 (extraction failed)
Process the built-in sample medical document without uploading. Accepts ?profile=medical. Returns the same shape as /api/process. Use this during demos.
Return PS3 correction review data for the most recently processed document.
{
"potential_misses": [...],
"suspicious_redactions": [...],
"fresh_scan_spans": [...],
"total_issues": 5
}
Re-run detection with an additional custom entity label for GLiNER.
{ "entity_label": "employee ID", "profile": "hr" }
Record reviewer feedback to calibrate future confidence estimates.
{ "span_id": "span_3", "type": "PERSON_NAME", "detection_method": "heuristic", "correct": false }
Export all spans as a CSV file (falls back to client-side generation if pandas is unavailable).
Returns {"status": "ok"}.
The following two-minute sequence demonstrates the full trust experience:
Landing page. The upload page opens with a Bayer-dithered wave animation. Select a document profile and drag in a file — or click "Try a sample document."
Processing. A pipeline progress animation plays (extracting text → rule layer → heuristics → Presidio → GLiNER → merging). The Risk Dashboard populates as the review screen appears.
First impression. The document renders with highlights across all four detection methods: black bars for SSN/IBAN/TAX_ID; coloured tokens for names and email; dotted underlines for location, marital status, and the HIPAA citation.
Click a redacted span. Click the SSN black bar. The panel shows: SSN · Rule-matched · 100% confidence · Redact — permanently removed.
Click a kept-visible span. Click the dotted-underlined city name. Same panel format, different content: LOCATION · Heuristic · 55% · kept visible — "a judgment call, not a certainty."
Click a confidently cleared span. Click the HIPAA citation. Rule-matched · 97% · kept visible — "a legal reference, not personal data." The panel makes the distinction between uncertain clearance and confident clearance visible.
PS3 Correction Review. The orange banner appears: "N potential misses, M uncertain redactions." Click Review → to enter the correction queue. Work through each issue one at a time with keyboard shortcuts R / K / →.
Adjust the threshold. Drag the slider down to 40. Low-confidence spans flip in real time. The panel surfaces overrides: "Nivora originally chose kept visible. At your current threshold, this span is redacted."
AI Preview. Toggle AI Preview to see exactly what a downstream AI would receive — clean text with redacted gaps and anonymization tokens. No inference, no API key, no guessing.
Audit Log. Navigate to Audit Log. Filter by severity, sort by confidence, export CSV as a GDPR/HIPAA compliance artifact.
The point: trust is not binary. The slider makes it tunable, and Nivora tells you exactly what changed and why.
Symmetric treatment of kept-visible content. This is the core product decision. A span that was "considered and cleared" is explained with the same panel, the same confidence bar, and the same level of detail as a span that was redacted. Content that was never flagged at all does not receive this treatment — it is just plain text. The visual distinction matters: coloured underline vs. unstyled text communicates "this was seen" vs. "this was not looked at."
Four stages with honest confidence ranges. Regex patterns for structured PII (email, SSN, IBAN) are essentially perfect detectors — 0.97–1.00 is accurate. Heuristics for names are genuinely uncertain — 0.60–0.85 is also accurate. Presidio adds NLP-backed NER for entities that resist regex. GLiNER adds domain-specific zero-shot detection tuned per profile. Combining all four gives coverage without inflating confidence scores.
_SECTION_WORDS frozenset. Document section headers (CURRICULUM VITAE, HOLDINGS BREAKDOWN, ACCOUNT SUMMARY) are all-caps sequences that match the standalone-name detector pattern. A 200+ term frozenset lookup at O(1) blocks them cleanly across resume, financial, medical, and legal document types.
PS3 correction queue — one issue at a time. Bulk review encourages rubber-stamping. Forcing reviewers to see one issue at a time with full context and a binary choice (redact / keep) produces more deliberate decisions and a cleaner audit trail. A userForced flag on acted-upon spans bypasses the confidence slider permanently.
ISINs excluded from the correction scanner. International Securities Identification Numbers are 12-character codes starting with two uppercase letters followed by 10 digits. The lookbehind (?<![A-Z\d]) ensures the bare-10-digit scanner never fires on the digit portion of an ISIN.
Client-side threshold re-evaluation. Re-running the backend pipeline on every threshold change would add 200–500ms latency for a display-layer operation. All span data is in the browser after the initial upload; applyThreshold() re-evaluates synchronously in under one millisecond. The explanation panel surfaces threshold overrides explicitly — silence never reads as a miss.
Phone and email are permanently redacted, not anonymized. A recoverable token ([EMAIL_1]) is still a re-identification risk if the token map is exposed. These values have no utility for a downstream AI evaluating document structure. They are pure contact data and should be removed entirely.
REGULATORY_CITATION is kept_visible. References to HIPAA, GDPR, CCPA, PCI DSS are legal context, not personal data. Redacting them would degrade the meaning of compliance documents without protecting any individual's privacy.
No live LLM. Removing API-key setup, rate limits, and network failure risk from the demo path was the correct trade-off. The judgment layer is exactly where a fine-tuned NER model or LLM-based extractor would slot in a production system; the heuristics are transparent, explainable stand-ins.