Spaces:
Sleeping
Sleeping
| import { useEffect, useState } from 'react' | |
| import { | |
| anonymizeText, | |
| deidentifyText, | |
| detectText, | |
| redactText, | |
| type DeidStrategy, | |
| type PseudonymMapping, | |
| type TextPIISpan, | |
| } from '../lib/api' | |
| import { l1Rgb, l1Tint } from '../lib/colors' | |
| import Legend from './Legend' | |
| import MappingTable from './MappingTable' | |
| type Action = 'detect' | 'redact' | 'deidentify' | 'anonymize' | |
| const ACTION_BUTTON_LABELS: Record<Action, string> = { | |
| detect: 'Detect PII', | |
| redact: 'Redact', | |
| deidentify: 'De-identify', | |
| anonymize: 'Anonymize', | |
| } | |
| const ACTION_HINTS: Record<Action, string> = { | |
| detect: 'Finds PII spans and highlights them in place — nothing is changed.', | |
| redact: 'Replaces each span with a [category] token. One-way, nothing kept.', | |
| deidentify: | |
| 'Replaces each entity with a consistent pseudonym (Person_1) and returns the mapping for authorized re-linking.', | |
| anonymize: | |
| 'One-way: generalizes ages/dates/geography, collapses names and IDs to unnumbered tokens. No mapping exists.', | |
| } | |
| const EXAMPLE_TEXT = `DISCHARGE SUMMARY | |
| Patient: Mr. John Doe (Male, 45 yrs), DOB 12-03-1979, Blood group O+. | |
| Address: 14 MG Road, Indiranagar, Bangalore, Karnataka 560038. | |
| Aadhaar: 1234 5678 9012 | PAN: ABCDE1234F | ABHA: 14-1234-5678-9012. | |
| MRN/UHID: UH00219834. Insurance policy: TPA-IND-99213. | |
| Contact: +91 98765 43210, email john.doe@example.com. | |
| Treating physician: Dr. Asha Menon (NMC Reg. 2011/04/1123). | |
| Payment received to UPI johndoe@okhdfcbank, A/C 50100123456789.` | |
| function renderHighlighted(text: string, spans: TextPIISpan[]) { | |
| const sorted = [...spans].sort((a, b) => a.start - b.start) | |
| const parts: React.ReactNode[] = [] | |
| let prev = 0 | |
| for (const [i, sp] of sorted.entries()) { | |
| if (sp.start < prev) continue | |
| parts.push(text.slice(prev, sp.start)) | |
| parts.push( | |
| <mark | |
| key={i} | |
| className="pii-mark" | |
| style={{ background: l1Tint(sp.l1), borderColor: l1Rgb(sp.l1) }} | |
| title={`${sp.category} · ${sp.score != null ? sp.score.toFixed(3) : '?'}`} | |
| > | |
| {text.slice(sp.start, sp.end)} | |
| <sup className="pii-tag" style={{ color: l1Rgb(sp.l1) }}> | |
| {sp.category} | |
| </sup> | |
| </mark>, | |
| ) | |
| prev = sp.end | |
| } | |
| parts.push(text.slice(prev)) | |
| return parts | |
| } | |
| export default function TextTab({ entities, ready }: { entities: string[] | null; ready: boolean }) { | |
| const [text, setText] = useState(EXAMPLE_TEXT) | |
| const [action, setAction] = useState<Action>('detect') | |
| const [strategy, setStrategy] = useState<DeidStrategy>('counter') | |
| const [selected, setSelected] = useState<Set<string>>(new Set()) | |
| const [spans, setSpans] = useState<TextPIISpan[]>([]) | |
| const [outputText, setOutputText] = useState<string | null>(null) | |
| const [outputTitle, setOutputTitle] = useState('') | |
| const [mapping, setMapping] = useState<PseudonymMapping | null>(null) | |
| const [loading, setLoading] = useState(false) | |
| const [error, setError] = useState<string | null>(null) | |
| const [ranOnce, setRanOnce] = useState(false) | |
| // All categories selected by default, once the taxonomy arrives. | |
| useEffect(() => { | |
| if (entities) setSelected(new Set(entities)) | |
| }, [entities]) | |
| function toggle(value: string) { | |
| const next = new Set(selected) | |
| if (next.has(value)) next.delete(value) | |
| else next.add(value) | |
| setSelected(next) | |
| } | |
| const allSelected = entities != null && selected.size === entities.length | |
| // Omit the param entirely when everything is selected (= server default). | |
| const categories = allSelected ? undefined : [...selected] | |
| async function run() { | |
| if (selected.size === 0) return | |
| setLoading(true) | |
| setError(null) | |
| setMapping(null) | |
| setOutputText(null) | |
| try { | |
| if (action === 'detect') { | |
| setSpans(await detectText(text, categories)) | |
| } else { | |
| setSpans([]) | |
| if (action === 'redact') { | |
| setOutputText(await redactText(text, categories)) | |
| setOutputTitle('Redacted') | |
| } else if (action === 'deidentify') { | |
| const result = await deidentifyText(text, categories, strategy) | |
| setOutputText(result.text) | |
| setMapping(result.mapping) | |
| setOutputTitle('De-identified') | |
| } else { | |
| setOutputText(await anonymizeText(text, categories)) | |
| setOutputTitle('Anonymized') | |
| } | |
| } | |
| setRanOnce(true) | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : String(err)) | |
| } finally { | |
| setLoading(false) | |
| } | |
| } | |
| const groups = [...new Set(spans.map((s) => s.l1))].sort() | |
| return ( | |
| <div className="tab-panel"> | |
| <p className="tab-caption">Detects PII in raw text and highlights each span, color-coded by group.</p> | |
| <section className="card"> | |
| <h2 className="card-title">Input</h2> | |
| {entities && ( | |
| <details className="exclude-panel"> | |
| <summary> | |
| Categories to detect ({selected.size}/{entities.length} selected) | |
| </summary> | |
| <div className="select-buttons"> | |
| <button | |
| type="button" | |
| className="btn-secondary btn-small" | |
| onClick={() => setSelected(new Set(entities))} | |
| > | |
| Select all | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-secondary btn-small" | |
| onClick={() => setSelected(new Set())} | |
| > | |
| Deselect all | |
| </button> | |
| </div> | |
| <div className="exclude-columns"> | |
| <div> | |
| {entities.map((c) => ( | |
| <label key={c} className="checkbox-row"> | |
| <input type="checkbox" checked={selected.has(c)} onChange={() => toggle(c)} /> | |
| {c} | |
| </label> | |
| ))} | |
| </div> | |
| </div> | |
| </details> | |
| )} | |
| <textarea | |
| className="text-input" | |
| value={text} | |
| onChange={(e) => setText(e.target.value)} | |
| rows={10} | |
| /> | |
| <div className="controls-row"> | |
| <label className="field"> | |
| <span className="field-label">Action</span> | |
| <select value={action} onChange={(e) => setAction(e.target.value as Action)}> | |
| <option value="detect">Detect (highlight)</option> | |
| <option value="redact">Redact</option> | |
| <option value="anonymize">Anonymize</option> | |
| <option value="deidentify">De-identify</option> | |
| </select> | |
| </label> | |
| {action === 'deidentify' && ( | |
| <label className="field"> | |
| <span className="field-label">Token style</span> | |
| <select | |
| value={strategy} | |
| onChange={(e) => setStrategy(e.target.value as DeidStrategy)} | |
| > | |
| <option value="counter">Sequential (Person_1)</option> | |
| <option value="hash">Hash — global (Person_a3f9c1)</option> | |
| </select> | |
| </label> | |
| )} | |
| <button | |
| type="button" | |
| className="primary-btn" | |
| onClick={run} | |
| disabled={!text.trim() || !ready || loading || selected.size === 0} | |
| > | |
| {loading ? 'Working…' : ACTION_BUTTON_LABELS[action]} | |
| </button> | |
| </div> | |
| <p className="tab-caption action-hint">{ACTION_HINTS[action]}</p> | |
| </section> | |
| {error && <div className="error-banner">{error}</div>} | |
| {outputText != null && ( | |
| <section className="card"> | |
| <h2 className="card-title">{outputTitle}</h2> | |
| <div className="output-text">{outputText}</div> | |
| </section> | |
| )} | |
| {mapping && <MappingTable mapping={mapping} />} | |
| {ranOnce && action === 'detect' && ( | |
| <> | |
| {spans.length > 0 ? ( | |
| <> | |
| <section className="card"> | |
| <h2 className="card-title">Highlighted</h2> | |
| <Legend groups={groups} /> | |
| <div className="highlighted-text">{renderHighlighted(text, spans)}</div> | |
| </section> | |
| <section className="card"> | |
| <h2 className="card-title">Detected spans</h2> | |
| <div className="table-scroll"> | |
| <table className="entity-table"> | |
| <thead> | |
| <tr> | |
| <th>category</th> | |
| <th>l1</th> | |
| <th>text</th> | |
| <th>start</th> | |
| <th>end</th> | |
| <th>score</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {spans.map((s, i) => ( | |
| <tr key={i}> | |
| <td>{s.category}</td> | |
| <td> | |
| <span className="l1-dot" style={{ background: l1Rgb(s.l1) }} /> | |
| {s.l1} | |
| </td> | |
| <td>{s.text}</td> | |
| <td>{s.start}</td> | |
| <td>{s.end}</td> | |
| <td>{s.score != null ? s.score.toFixed(3) : ''}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </section> | |
| </> | |
| ) : ( | |
| <p className="tab-caption">No PII detected.</p> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| ) | |
| } | |