Senses grow from confirmations instead of a word list, so a later local pipeline can decide homonyms on a short passage. Default stays semantic. Local detect waits longer, and the reverse-proxy timeout is documented so Dev does not 504 first. Co-authored-by: Cursor <cursoragent@cursor.com>
200 lines
6.5 KiB
JavaScript
200 lines
6.5 KiB
JavaScript
import { useEffect, useState } from 'react'
|
|
import { api } from '../api.js'
|
|
import { useAuth } from '../context/AuthContext.jsx'
|
|
|
|
const emptyForm = { canonical_label: '', entity_type: 'PERSON', aliases: '' }
|
|
|
|
export default function AdminIdentitiesPage() {
|
|
const { session } = useAuth()
|
|
const [data, setData] = useState(null)
|
|
const [form, setForm] = useState(emptyForm)
|
|
const [error, setError] = useState('')
|
|
const [notice, setNotice] = useState('')
|
|
|
|
const load = () =>
|
|
api('/api/admin/identities', { token: session.token })
|
|
.then(setData)
|
|
.catch((e) => setError(e.message))
|
|
|
|
useEffect(() => { load() }, [session.token])
|
|
|
|
const aliasesOf = (value) =>
|
|
value
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
|
|
const create = async (e) => {
|
|
e.preventDefault()
|
|
setError('')
|
|
setNotice('')
|
|
try {
|
|
await api('/api/admin/identities', {
|
|
token: session.token,
|
|
method: 'POST',
|
|
body: {
|
|
canonical_label: form.canonical_label,
|
|
entity_type: form.entity_type,
|
|
aliases: aliasesOf(form.aliases)
|
|
}
|
|
})
|
|
setForm(emptyForm)
|
|
setNotice('Eintrag bestätigt und lokal gespeichert. Kein externer Aufruf.')
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
}
|
|
}
|
|
|
|
const patch = async (id, body) => {
|
|
setError('')
|
|
try {
|
|
await api(`/api/admin/identities/${id}`, {
|
|
token: session.token,
|
|
method: 'PATCH',
|
|
body
|
|
})
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
}
|
|
}
|
|
|
|
const remove = async (id) => {
|
|
setError('')
|
|
try {
|
|
await api(`/api/admin/identities/${id}`, { token: session.token, method: 'DELETE' })
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
}
|
|
}
|
|
|
|
const confirmProposal = async (id) => {
|
|
setError('')
|
|
try {
|
|
await api(`/api/admin/identities/proposals/${id}/confirm`, {
|
|
token: session.token,
|
|
method: 'POST',
|
|
body: {}
|
|
})
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
}
|
|
}
|
|
|
|
const dismissProposal = async (id) => {
|
|
setError('')
|
|
try {
|
|
await api(`/api/admin/identities/proposals/${id}/dismiss`, {
|
|
token: session.token,
|
|
method: 'POST',
|
|
body: {}
|
|
})
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="card">
|
|
<h1>Identitäten</h1>
|
|
<p className="muted">
|
|
Nur lokale bestätigte Registry. Detect-Treffer werden nicht automatisch aktiv.
|
|
Unbestätigte Vorschläge gelten nicht als bekannte Identität. Kein externer Egress.
|
|
Mehrdeutigkeit entsteht aus Dialog-Bestätigungen, nicht aus einer Wortlisten-Seite.
|
|
</p>
|
|
{error && <p className="error">{error}</p>}
|
|
{notice && <p>{notice}</p>}
|
|
{data?.backup && <p className="muted">{data.backup}</p>}
|
|
<div className="table-scroll">
|
|
<table className="plain">
|
|
<thead>
|
|
<tr>
|
|
<th>Bezeichnung</th>
|
|
<th>Typ</th>
|
|
<th>Status</th>
|
|
<th>Aliase</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(data?.registry || []).map((item) => {
|
|
const sense = (data?.senses || []).find(
|
|
(entry) => (entry.normalized_label || '').toLowerCase() === (item.canonical_label || '').toLowerCase()
|
|
)
|
|
return (
|
|
<tr key={item.id}>
|
|
<td>
|
|
{item.canonical_label}
|
|
{sense?.ambiguous ? <span className="muted"> · mehrdeutig</span> : null}
|
|
</td>
|
|
<td>
|
|
<select
|
|
value={item.entity_type}
|
|
onChange={(e) => patch(item.id, { entity_type: e.target.value })}
|
|
>
|
|
<option value="PERSON">PERSON</option>
|
|
<option value="PLACE">PLACE</option>
|
|
<option value="ORG">ORG</option>
|
|
<option value="PROJECT">PROJECT</option>
|
|
</select>
|
|
</td>
|
|
<td>{item.status}</td>
|
|
<td>{(item.aliases || []).join(', ')}</td>
|
|
<td>
|
|
{item.status !== 'confirmed' && (
|
|
<button type="button" className="ghost" onClick={() => patch(item.id, { status: 'confirmed' })}>
|
|
Bestätigen
|
|
</button>
|
|
)}
|
|
{item.status !== 'inactive' && (
|
|
<button type="button" className="ghost" onClick={() => patch(item.id, { status: 'inactive' })}>
|
|
Deaktivieren
|
|
</button>
|
|
)}
|
|
<button type="button" className="ghost" onClick={() => remove(item.id)}>Entfernen</button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<form className="stack" onSubmit={create}>
|
|
<h2>Identität bestätigen</h2>
|
|
<label>Kanonische Schreibweise <input value={form.canonical_label} onChange={(e) => setForm({ ...form, canonical_label: e.target.value })} required /></label>
|
|
<label>
|
|
Typ
|
|
<select value={form.entity_type} onChange={(e) => setForm({ ...form, entity_type: e.target.value })}>
|
|
<option value="PERSON">PERSON</option>
|
|
<option value="PLACE">PLACE</option>
|
|
<option value="ORG">ORG</option>
|
|
<option value="PROJECT">PROJECT</option>
|
|
</select>
|
|
</label>
|
|
<label>Bestätigte Aliase, kommagetrennt <input value={form.aliases} onChange={(e) => setForm({ ...form, aliases: e.target.value })} /></label>
|
|
<button type="submit">Lokal speichern</button>
|
|
</form>
|
|
<h2>Unbestätigte Detect-Vorschläge</h2>
|
|
<p className="muted">Wirken nicht auf spätere Requests und ersetzen keine vollständige Detection.</p>
|
|
<ul className="stack">
|
|
{(data?.proposals || []).map((item) => (
|
|
<li key={item.id}>
|
|
{item.observed_label} · {item.entity_type} · {item.status}
|
|
{item.status === 'unconfirmed' && (
|
|
<>
|
|
{' '}
|
|
<button type="button" className="ghost" onClick={() => confirmProposal(item.id)}>Bestätigen</button>
|
|
<button type="button" className="ghost" onClick={() => dismissProposal(item.id)}>Verwerfen</button>
|
|
</>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)
|
|
}
|