Skip to content

Commit 32d5dbc

Browse files
committed
feat(ui): NER-centric PII editor; drop the regex pattern UI
React UI side of the PII redesign. - New EntityActionListEditor (entity group → mask|block|allow map editor for a detector model's pii_detection.entity_actions, with a datalist of common categories) and ModelMultiSelect (capability-filtered detector picker for a consumer's pii.detectors). - ConfigFieldRenderer: dispatch `entity-action-list` + `model-multi-select`; map `models:token_classify` → FLAG_TOKEN_CLASSIFY; drop `pii-pattern-list`. - capabilities.js: CAP_TOKEN_CLASSIFY. - Middleware page: remove the pattern catalogue, the per-pattern action editor, and the "Save to disk" persist flow; the per-model table now shows the NER detectors each config references. Removed the dead pattern-mutation state/handlers. - modelTemplates: MITM template seeds pii.detectors instead of pii.patterns. - Deleted PIIPatternListEditor. - e2e/middleware-page.spec: fixture + tests updated for the detector model; removed the PUT /api/pii/patterns test. Assisted-by: claude-code:claude-opus-4-8 [Claude Code]
1 parent ca6a565 commit 32d5dbc

8 files changed

Lines changed: 202 additions & 314 deletions

File tree

core/http/react-ui/e2e/middleware-page.spec.js

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,17 @@
11
import { test, expect } from '@playwright/test'
22

3-
// Mocked fixture covering the three things the page renders:
4-
// - PII pattern catalogue (action badges, action-change buttons)
5-
// - Per-model resolved PII state (one with default off, one with proxy default on, one with explicit YAML)
3+
// Mocked fixture covering the things the page renders:
4+
// - Per-model resolved PII state + the NER detectors each references
5+
// (one with default off, one with proxy default on, one explicit YAML)
66
// - Recent events feed (the page must NEVER show the redacted content)
77
const MOCK_STATUS = {
88
pii: {
99
enabled_globally: true,
1010
default_enabled_for_backends: ['cloud-proxy'],
11-
patterns: [
12-
{ id: 'email', description: 'Email addresses', action: 'mask', max_match_length: 254 },
13-
{ id: 'ssn', description: 'US Social Security Numbers', action: 'mask', max_match_length: 11 },
14-
{ id: 'api_key_prefix', description: 'API key prefixes', action: 'block', max_match_length: 200 },
15-
],
1611
models: [
17-
{ name: 'qwen-7b', backend: 'llama-cpp', enabled: false, explicit: false, default_for_backend: false, overrides: null },
18-
{ name: 'claude-sonnet', backend: 'cloud-proxy', enabled: true, explicit: false, default_for_backend: true, overrides: null },
19-
{ name: 'claude-strict', backend: 'cloud-proxy', enabled: true, explicit: true, default_for_backend: true, overrides: { ssn: 'block' } },
12+
{ name: 'qwen-7b', backend: 'llama-cpp', enabled: false, explicit: false, default_for_backend: false, detectors: null },
13+
{ name: 'claude-sonnet', backend: 'cloud-proxy', enabled: true, explicit: false, default_for_backend: true, detectors: null },
14+
{ name: 'claude-strict', backend: 'cloud-proxy', enabled: true, explicit: true, default_for_backend: true, detectors: ['privacy-filter-multilingual'] },
2015
],
2116
recent_event_count: 2,
2217
},
@@ -116,17 +111,16 @@ test.describe('Middleware page — admin in no-auth mode', () => {
116111
)
117112
})
118113

119-
test('Filtering tab renders pattern catalogue and per-model state', async ({ page }) => {
114+
test('Filtering tab renders per-model state and referenced detectors', async ({ page }) => {
120115
await page.goto('/app/middleware')
121116

122-
// Pattern table — at least one pattern id visible.
123-
await expect(page.getByText('email').first()).toBeVisible()
124-
await expect(page.getByText('api_key_prefix').first()).toBeVisible()
125-
126117
// Per-model state — each model's name is visible.
127118
await expect(page.getByText('qwen-7b').first()).toBeVisible()
128119
await expect(page.getByText('claude-strict').first()).toBeVisible()
129120

121+
// The detector a model references is shown in its row.
122+
await expect(page.getByText('privacy-filter-multilingual').first()).toBeVisible()
123+
130124
// Default-policy banner names the backends with PII on by default.
131125
await expect(page.getByText(/cloud-proxy/).first()).toBeVisible()
132126
})
@@ -265,25 +259,6 @@ test.describe('Middleware page — admin in no-auth mode', () => {
265259
await expect(page.getByText(/^proxy traffic$/i).first()).toBeVisible()
266260
})
267261

268-
test('PUT /api/pii/patterns/:id fires when an action button is clicked', async ({ page }) => {
269-
let putHit = null
270-
await page.route('**/api/pii/patterns/email', (route) => {
271-
if (route.request().method() === 'PUT') {
272-
putHit = JSON.parse(route.request().postData() || '{}')
273-
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ id: 'email', action: putHit.action, persisted: false }) })
274-
} else {
275-
route.continue()
276-
}
277-
})
278-
279-
await page.goto('/app/middleware')
280-
// Click the email row's "block" button (currently mask, so block is
281-
// enabled). Use a precise locator that matches the inner button.
282-
const emailRow = page.locator('tr').filter({ hasText: 'email' }).first()
283-
await emailRow.getByRole('button', { name: 'block' }).click()
284-
285-
await expect.poll(() => putHit).toEqual({ action: 'block' })
286-
})
287262
})
288263

289264
test.describe('Middleware page — non-admin under auth-on', () => {

core/http/react-ui/src/components/ConfigFieldRenderer.jsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import SearchableModelSelect from './SearchableModelSelect'
66
import AutocompleteInput from './AutocompleteInput'
77
import CodeEditor from './CodeEditor'
88
import StructuredCodeEditor from './StructuredCodeEditor'
9-
import PIIPatternListEditor from './PIIPatternListEditor'
9+
import EntityActionListEditor from './EntityActionListEditor'
10+
import ModelMultiSelect from './ModelMultiSelect'
1011
import RouterCandidatesEditor from './RouterCandidatesEditor'
1112
import RouterPoliciesEditor from './RouterPoliciesEditor'
1213

@@ -17,6 +18,7 @@ const PROVIDER_TO_CAPABILITY = {
1718
'models:transcript': 'FLAG_TRANSCRIPT',
1819
'models:vad': 'FLAG_VAD',
1920
'models:score': 'FLAG_SCORE',
21+
'models:token_classify': 'FLAG_TOKEN_CLASSIFY',
2022
}
2123

2224
function coerceValue(raw, uiType) {
@@ -395,10 +397,26 @@ export default function ConfigFieldRenderer({ field, value, onChange, onRemove,
395397
)
396398
}
397399

398-
// PII pattern list — per-model action overrides for named patterns.
399-
// The pattern catalog is loaded from /api/pii/patterns at render time
400-
// so new built-in patterns surface automatically.
401-
if (component === 'pii-pattern-list') {
400+
// PII detectors — a capability-filtered multi-select of token_classify
401+
// models (the consuming model's pii.detectors list).
402+
if (component === 'model-multi-select') {
403+
const cap = PROVIDER_TO_CAPABILITY[field.autocomplete_provider] || undefined
404+
return (
405+
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
406+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
407+
<div>
408+
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
409+
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
410+
</div>
411+
</div>
412+
<ModelMultiSelect value={value} onChange={handleChange} capability={cap} placeholder={field.placeholder} />
413+
</div>
414+
)
415+
}
416+
417+
// PII detection entity-action map — a detector model's
418+
// pii_detection.entity_actions (entity group -> mask|block|allow).
419+
if (component === 'entity-action-list') {
402420
return (
403421
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
404422
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
@@ -407,7 +425,7 @@ export default function ConfigFieldRenderer({ field, value, onChange, onRemove,
407425
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
408426
</div>
409427
</div>
410-
<PIIPatternListEditor value={value} onChange={handleChange} />
428+
<EntityActionListEditor value={value} onChange={handleChange} />
411429
</div>
412430
)
413431
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { useMemo } from 'react'
2+
import SearchableSelect from './SearchableSelect'
3+
4+
// Editor for a detector model's pii_detection.entity_actions map:
5+
// entity-group name -> action. The value is an object {GROUP: action};
6+
// this component renders one row per entry and emits a fresh object on
7+
// every change. Entity-group names are model-defined (the privacy-filter
8+
// family emits uppercase names with no separators), so the group field is
9+
// free text with a datalist of common high-value categories for
10+
// convenience — any string the model emits is valid.
11+
12+
const ACTION_OPTIONS = [
13+
{ value: 'mask', label: 'mask — replace with [REDACTED:ner:GROUP]' },
14+
{ value: 'block', label: 'block — reject the request (HTTP 400)' },
15+
{ value: 'allow', label: 'allow — detect & log, leave text unchanged' },
16+
]
17+
18+
// Common categories surfaced as datalist hints. Not exhaustive and not
19+
// authoritative — the model's own label set is the source of truth.
20+
const COMMON_GROUPS = [
21+
'PASSWORD', 'PIN', 'CVV', 'CREDITCARD', 'IBAN', 'BIC', 'BANKACCOUNT', 'SSN',
22+
'BITCOINADDRESS', 'ETHEREUMADDRESS', 'LITECOINADDRESS',
23+
'EMAIL', 'PHONE', 'URL', 'IPADDRESS', 'MACADDRESS',
24+
'FIRSTNAME', 'LASTNAME', 'MIDDLENAME', 'USERNAME', 'DATEOFBIRTH',
25+
'STREET', 'CITY', 'STATE', 'ZIPCODE', 'GPSCOORDINATES',
26+
]
27+
28+
export default function EntityActionListEditor({ value, onChange }) {
29+
// value is an object map; preserve insertion order via Object.entries.
30+
const entries = useMemo(
31+
() => (value && typeof value === 'object' && !Array.isArray(value) ? Object.entries(value) : []),
32+
[value]
33+
)
34+
35+
const datalistId = 'pii-entity-groups'
36+
37+
const update = (index, key, action) => {
38+
const next = entries.map((e, i) => (i === index ? [key, action] : e))
39+
onChange(Object.fromEntries(next.filter(([k]) => k !== '')))
40+
}
41+
42+
const remove = (index) => {
43+
onChange(Object.fromEntries(entries.filter((_, i) => i !== index)))
44+
}
45+
46+
const add = () => {
47+
// New rows default to mask; an empty key is tolerated transiently and
48+
// filtered out on the next edit / when serialised.
49+
onChange(Object.fromEntries([...entries, ['', 'mask']]))
50+
}
51+
52+
return (
53+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
54+
<datalist id={datalistId}>
55+
{COMMON_GROUPS.map(g => <option key={g} value={g} />)}
56+
</datalist>
57+
58+
{entries.length === 0 && (
59+
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
60+
No per-entity actions — every detected group uses the default action. Add a row to
61+
block or allow-log a specific entity group (e.g. <code>PASSWORD</code> → block).
62+
</div>
63+
)}
64+
65+
{entries.map(([group, action], i) => (
66+
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
67+
<input
68+
className="input"
69+
list={datalistId}
70+
value={group}
71+
placeholder="Entity group (e.g. PASSWORD)"
72+
onChange={e => update(i, e.target.value, action)}
73+
style={{ flex: '1 1 220px', minWidth: 180, fontSize: '0.8125rem' }}
74+
aria-label="Entity group"
75+
/>
76+
<SearchableSelect
77+
value={action || 'mask'}
78+
onChange={v => update(i, group, v)}
79+
options={ACTION_OPTIONS}
80+
placeholder="Action..."
81+
style={{ flex: '1 1 240px', minWidth: 220 }}
82+
/>
83+
<button type="button" className="btn btn-secondary btn-sm"
84+
onClick={() => remove(i)}
85+
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
86+
aria-label="Remove entity action">
87+
<i className="fas fa-times" />
88+
</button>
89+
</div>
90+
))}
91+
92+
<button type="button" className="btn btn-secondary btn-sm" onClick={add}
93+
style={{ alignSelf: 'flex-start', fontSize: '0.75rem' }}>
94+
<i className="fas fa-plus" /> Add entity action
95+
</button>
96+
</div>
97+
)
98+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import SearchableModelSelect from './SearchableModelSelect'
2+
3+
// Editor for a list of model names (value is []string), each chosen via a
4+
// capability-filtered SearchableModelSelect. Used for pii.detectors, where
5+
// every entry must be a token_classify model. Already-selected models are
6+
// excluded from the add picker so each appears at most once.
7+
export default function ModelMultiSelect({ value, onChange, capability, placeholder }) {
8+
const items = Array.isArray(value) ? value : []
9+
10+
const update = (index, v) => {
11+
if (!v) return
12+
onChange(items.map((it, i) => (i === index ? v : it)))
13+
}
14+
const remove = (index) => onChange(items.filter((_, i) => i !== index))
15+
const add = (v) => {
16+
if (!v || items.includes(v)) return
17+
onChange([...items, v])
18+
}
19+
20+
return (
21+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
22+
{items.length === 0 && (
23+
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
24+
No detectors — PII is enabled but nothing scans requests. Add a token-classification
25+
(NER) model below; its <code>pii_detection</code> block supplies the policy.
26+
</div>
27+
)}
28+
29+
{items.map((name, i) => (
30+
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
31+
<SearchableModelSelect
32+
value={name || ''}
33+
onChange={v => update(i, v)}
34+
capability={capability}
35+
placeholder={placeholder || 'Select detector model...'}
36+
style={{ flex: '1 1 260px', minWidth: 220 }}
37+
/>
38+
<button type="button" className="btn btn-secondary btn-sm"
39+
onClick={() => remove(i)}
40+
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
41+
aria-label="Remove detector">
42+
<i className="fas fa-times" />
43+
</button>
44+
</div>
45+
))}
46+
47+
<SearchableModelSelect
48+
value=""
49+
onChange={v => add(v)}
50+
capability={capability}
51+
placeholder="+ Add detector model..."
52+
style={{ flex: '1 1 260px', minWidth: 220 }}
53+
/>
54+
</div>
55+
)
56+
}

0 commit comments

Comments
 (0)