Implement Scene 7 evidence goal flow

This commit is contained in:
2026-08-22 16:02:42 +02:00
parent 34aa23237e
commit a7f99a2a39
31 changed files with 1620 additions and 52 deletions
+88 -14
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import type { BriefConcept, CaseDocument, CaseState, Connection, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { AdminPanel } from './admin'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
import type { PlaythroughState } from './narrative'
const BOARD_W = 2400
const BOARD_H = 1500
@@ -69,6 +70,9 @@ export function App() {
const [flagsOpen, setFlagsOpen] = useState(false)
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
const [completedGoal, setCompletedGoal] = useState<LevelGoal | null>(null)
const [advancing, setAdvancing] = useState(false)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -101,7 +105,15 @@ export function App() {
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
const openLevel = async (slug: string) => {
const data = await loadLevelBySlug(slug, editQuery)
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
if ((data.goals.some(goal => goal.status === 'pending') || data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId))
&& !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
try {
const playthroughResponse = await fetch('/api/playthroughs/current')
if (playthroughResponse.ok && playthroughResponse.status !== 204) {
const playthrough = await playthroughResponse.json() as PlaythroughState
setActivePlaythroughId(playthrough.node?.kind === 'level' && playthrough.node.levelSlug === data.id ? playthrough.playthrough.id : null)
} else setActivePlaythroughId(null)
} catch { setActivePlaythroughId(null) }
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
}
const boot = async () => {
@@ -360,6 +372,7 @@ export function App() {
form.append('x', String(position.x))
form.append('y', String(position.y))
try {
setStatus(source === 'clipboard' ? 'PASTING SCREENSHOT · READING SOURCE…' : `IMPORTING ${file.name.toUpperCase()} · READING SOURCE…`)
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents`, { method: 'POST', body: form })
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
const uploaded: UploadedCaseDocument = await response.json()
@@ -372,14 +385,36 @@ export function App() {
void fetch(`/api/levels/${encodeURIComponent(caseState.id)}/reveals/seen`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
})
const deterministicCompletion = analysis.goals.find(goal => goal.newlyCompleted)
if (deterministicCompletion) setCompletedGoal(deterministicCompletion)
if (analysis.awardedFlags.length) {
window.clearTimeout(saveTimer.current)
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
await loadLevelBySlug(caseState.id, editQuery)
setSelected(document.id)
setStatus(`EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
setStatus(deterministicCompletion ? 'SOURCE VERIFIED · OBJECTIVE COMPLETE' : `EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
else if (analysis.extractionStatus === 'succeeded' && analysis.goals.some(goal => goal.status === 'pending')) {
setStatus('TEXT EXTRACTED · CHECKING SOURCE CLAIM…')
const judgeResponse = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents/${encodeURIComponent(document.id)}/judge`, { method: 'POST' })
if (judgeResponse.ok) {
const semantic = await judgeResponse.json() as DocumentSemanticAnalysis
const semanticCompletion = semantic.goals.find(goal => goal.newlyCompleted)
if (semanticCompletion || semantic.awardedFlags.length) {
if (semanticCompletion) setCompletedGoal(semanticCompletion)
window.clearTimeout(saveTimer.current)
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
await loadLevelBySlug(caseState.id, editQuery)
setSelected(document.id)
}
if (semanticCompletion) setStatus('SOURCE VERIFIED · OBJECTIVE COMPLETE')
else if (semantic.subject === 'related' && semantic.supportsClaim) setStatus('RELATED DISCOVERY FOUND · STILL NEED PROOF ABOUT NILS')
else if (semantic.status === 'unavailable') setStatus('SCREENSHOT SAVED · AUTOMATIC CLAIM REVIEW UNAVAILABLE')
else if (semantic.status === 'pending') setStatus('SCREENSHOT SAVED · CLAIM REVIEW STILL RUNNING')
else setStatus('SCREENSHOT SAVED · SOURCE DOES NOT YET PROVE THE OBJECTIVE')
} else if (source === 'clipboard') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
else setStatus(`IMPORTED · ${file.name.toUpperCase()} · TEXT ANALYZED`)
} else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
} catch (error) {
@@ -388,6 +423,20 @@ export function App() {
}
}, [caseState, loadLevelBySlug, requestedEditMode, update])
const continueAfterGoal = async () => {
if (!activePlaythroughId) { setCompletedGoal(null); return }
setAdvancing(true)
try {
const response = await fetch(`/api/playthroughs/${encodeURIComponent(activePlaythroughId)}/advance`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
})
if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
const next = await response.json() as PlaythroughState
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
else window.location.assign('/')
} finally { setAdvancing(false) }
}
useEffect(() => {
if (!caseState) return
const handlePaste = (event: ClipboardEvent) => {
@@ -413,6 +462,9 @@ export function App() {
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => {
const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined
@@ -431,7 +483,7 @@ export function App() {
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav>
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
<button onClick={() => setHelpOpen(true)}>HELP</button>
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
@@ -467,10 +519,11 @@ export function App() {
</aside>
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p>{activeGoal && <button className={`active-goal ${activeGoal.status}`} onClick={() => setBriefOpen(true)}><span>{activeGoal.status === 'complete' ? 'OBJECTIVE VERIFIED' : 'CURRENT OBJECTIVE'}</span><b>{activeGoal.title}</b></button>}</div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel
brief={caseState.brief}
goals={caseState.goals}
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
canEdit={canAuthor}
@@ -609,6 +662,7 @@ export function App() {
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
{completedGoal && <GoalComplete goal={completedGoal} hasNext={Boolean(activePlaythroughId)} busy={advancing} onContinue={() => void continueAfterGoal()}/>}
</main>
}
@@ -968,19 +1022,35 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
</form></div>
}
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
function BriefPanel({ brief, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
const [minimized, setMinimized] = useState(false)
const partyById = new Map(parties.map(party => [party.id, party]))
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {unresolved} UNRESOLVED</small><b>CONCEPT CLASSIFICATION</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
const pending = goals.filter(goal => goal.status === 'pending').length
const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {pending ? `${pending} OBJECTIVE${pending === 1 ? '' : 'S'} OPEN` : unresolved ? `${unresolved} UNRESOLVED` : 'READY'}</small><b>{heading}</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
<p>{brief.body || 'No brief has been authored yet.'}</p>
<div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button>
{goals.length > 0 && <div className="brief-goals">{goals.map((goal, index) => <section className={goal.status} key={goal.key}><i>{goal.status === 'complete' ? '✓' : index + 1}</i><div><b>{goal.title}</b><span>{goal.instructions}</span></div><em>{goal.status === 'complete' ? 'VERIFIED' : 'OPEN'}</em></section>)}</div>}
{brief.concepts.length > 0 && <><div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button></>}
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
<button className="dismiss-brief" onClick={onClose}>{unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
<button className="dismiss-brief" onClick={onClose}>{pending || unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
</aside>
}
function GoalComplete({ goal, hasNext, busy, onContinue }: { goal: LevelGoal; hasNext: boolean; busy: boolean; onContinue: () => void }) {
return <div className="goal-complete-shade" role="dialog" aria-modal="true" aria-labelledby="goal-complete-title">
<section className="goal-complete-card">
<div className="goal-complete-signal"><i/><span>GU-NET SOURCE AUTHENTICATION</span><i/></div>
<small>OBJECTIVE COMPLETE</small>
<h2 id="goal-complete-title">{goal.title}</h2>
<p>{goal.completionMessage || 'The submitted evidence satisfies this objective.'}</p>
<div className="goal-complete-stamp">SOURCE<br/><b>VERIFIED</b></div>
<button type="button" disabled={busy} onClick={onContinue}>{busy ? 'OPENING NEXT FILE…' : hasNext ? 'CONTINUE' : 'RETURN TO BOARD'} <span></span></button>
</section>
</div>
}
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
const [body, setBody] = useState(brief.body)
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
@@ -1149,12 +1219,14 @@ function LevelFlagsEditor({ levelId, onClose, onChanged }: { levelId: string; on
type MatchRuleDraft = {
id?: string
name: string
sourceLabel: string
sourceUri: string
flagKey: string
minimumAnchorMatches: number
enabled: boolean
anchors: { id: string; phrase: string; minimumSimilarity: number }[]
}
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', flagKey: '', minimumAnchorMatches: 1, enabled: true,
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', sourceLabel:'', sourceUri:'', flagKey: '', minimumAnchorMatches: 1, enabled: true,
anchors: [{ id: uid('anchor'), phrase: '', minimumSimilarity: 0.72 }] })
function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClose: () => void }) {
@@ -1168,7 +1240,7 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
setRules(await response.json())
}, [levelId])
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load rules')) }, [load])
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,flagKey:rule.flagKey,
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,sourceLabel:rule.sourceLabel || '',sourceUri:rule.sourceUri || '',flagKey:rule.flagKey,
minimumAnchorMatches:rule.minimumAnchorMatches,enabled:rule.enabled,
anchors:rule.anchors.map(anchor => ({ id:anchor.id,phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) })
const submit = async (event: React.FormEvent) => {
@@ -1176,7 +1248,7 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
try {
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules${draft.id ? `/${encodeURIComponent(draft.id)}` : ''}`, {
method: draft.id ? 'PUT' : 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name:draft.name,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
body: JSON.stringify({ name:draft.name,sourceLabel:draft.sourceLabel,sourceUri:draft.sourceUri,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
enabled:draft.enabled,anchors:draft.anchors.map(anchor => ({ phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) }),
})
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not save evidence rule') }
@@ -1206,6 +1278,8 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
<form className="match-rule-form" onSubmit={event => void submit(event)}>
<div className="match-rule-form-heading"><b>{draft.id ? 'EDIT RULE' : 'NEW RULE'}</b>{draft.id && <button type="button" onClick={() => setDraft(emptyMatchRule())}>NEW</button>}</div>
<label className="field"><span>RULE NAME</span><input value={draft.name} maxLength={160} onChange={event => setDraft(value => ({ ...value,name:event.target.value }))} placeholder="Contemporary fire report"/></label>
<div className="match-rule-fields"><label className="field"><span>SOURCE LABEL · OPTIONAL</span><input value={draft.sourceLabel} maxLength={300} onChange={event => setDraft(value => ({ ...value,sourceLabel:event.target.value }))} placeholder="Google Patents · GB695913A"/></label>
<label className="field"><span>CANONICAL URL · OPTIONAL</span><input type="url" value={draft.sourceUri} maxLength={2000} onChange={event => setDraft(value => ({ ...value,sourceUri:event.target.value }))} placeholder="https://patents.google.com/…"/></label></div>
<div className="match-rule-fields"><label className="field"><span>AWARD FLAG</span><input value={draft.flagKey} pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setDraft(value => ({ ...value,flagKey:event.target.value.toLowerCase() }))} placeholder="source.fire-report"/></label>
<label className="field"><span>REQUIRED HITS</span><input type="number" min="1" max={draft.anchors.length} value={draft.minimumAnchorMatches} onChange={event => setDraft(value => ({ ...value,minimumAnchorMatches:Number(event.target.value) }))}/></label></div>
<label className="match-rule-enabled"><input type="checkbox" checked={draft.enabled} onChange={event => setDraft(value => ({ ...value,enabled:event.target.checked }))}/> ENABLE THIS RULE</label>