From 489da14c9e9d747ee1dc1a0cb5d900d2093d2c45 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 22 Aug 2026 21:52:58 +0200 Subject: [PATCH] Reevaluate existing evidence when rules change --- server/api.integration.test.ts | 31 ++++++++++++++++++++++++++++++ server/levelRepository.ts | 35 +++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index 1978d11..659667b 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -250,6 +250,37 @@ suite('normalized level persistence API', () => { ]) }) + it('evaluates existing documents when an evidence fingerprint is added later', async () => { + const created = await adminFetch(`${baseUrl}/api/levels`, { + method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ id:'late-evidence-rule',title:'Late evidence rule' }), + }) + expect(created.status).toBe(201) + const level = await created.json() as CaseState + const upload = new FormData() + upload.append('file',new Blob(['Archive patent 93585 names Niels Aall Baricelli and describes a Koffert-kommode.'],{ type:'text/plain' }),'patent.txt') + const uploaded = await (await fetch(`${baseUrl}/api/levels/${level.id}/documents`,{ method:'POST',body:upload })).json() as DocumentExhibit & { + analysis:{ matchedFlags:string[] } + } + expect(uploaded.analysis.matchedFlags).toEqual([]) + + const rule = await adminFetch(`${baseUrl}/api/levels/${level.id}/evidence-match-rules`,{ + method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ + name:'Late National Library fingerprint',flagKey:'late.patent-recognized',minimumAnchorMatches:2, + anchors:[ + { phrase:'Archive patent 93585 names Niels Aall Baricelli',minimumSimilarity:.7 }, + { phrase:'describes a Koffert-kommode',minimumSimilarity:.7 }, + ], + }), + }) + expect(rule.status).toBe(201) + expect(await (await adminFetch(`${baseUrl}/api/levels/${level.id}/flags`)).json()).toEqual([ + expect.objectContaining({ key:'late.patent-recognized',earnedAt:expect.any(String) }), + ]) + const evaluation = await appPool.query<{ matched:boolean;matched_anchor_count:number }>( + 'SELECT matched,matched_anchor_count FROM osint.evidence_match_evaluations WHERE document_exhibit_id=$1',[uploaded.id]) + expect(evaluation.rows).toEqual([{ matched:true,matched_anchor_count:2 }]) + }) + it('imports the data-defined Scene 7 template with its private recognition rules', async () => { const { importMysteryTemplate } = await import('../scripts/importMysteryTemplate.js') const manifestPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'barricelli-scene-7', 'mystery.json') diff --git a/server/levelRepository.ts b/server/levelRepository.ts index 223bb5b..54ba2ce 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -215,8 +215,41 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec for (const [sortOrder, anchor] of input.anchors.entries()) await client.query(`INSERT INTO osint.evidence_match_anchors (id,rule_id,phrase_text,minimum_similarity,sort_order) VALUES ($1,$2,$3,$4,$5)`, [randomUUID(), ruleId, anchor.phrase, anchor.minimumSimilarity, sortOrder]) + const rule = (await evidenceMatchRules(client, level.board_id, true)).find(candidate => candidate.id === ruleId) || null + if (rule) await evaluateRuleForExistingDocuments(client, level, rule) await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id]) - return (await evidenceMatchRules(client, level.board_id, true)).find(rule => rule.id === ruleId) || null + return rule + } + + async function evaluateRuleForExistingDocuments(client: PoolClient, level: LevelRow, rule: EvidenceMatchRuleDefinition) { + if (!rule.enabled) return + const documents = await client.query<{ document_exhibit_id:string;extraction_id:string;extracted_text:string }>(` + SELECT DISTINCT ON (document.exhibit_id) document.exhibit_id AS document_exhibit_id, + extraction.id AS extraction_id,extraction.extracted_text + FROM osint.document_exhibits document + JOIN osint.exhibits exhibit ON exhibit.id=document.exhibit_id AND exhibit.board_id=$1 + JOIN osint.asset_text_extractions extraction ON extraction.asset_id=document.asset_id + AND extraction.status='succeeded' AND BTRIM(extraction.extracted_text)<>'' + ORDER BY document.exhibit_id,extraction.updated_at DESC,extraction.id DESC`, [level.board_id]) + for (const document of documents.rows) { + const evaluation = evaluateEvidenceRules(document.extracted_text, [rule as EvidenceMatchRule])[0] + const persisted = await client.query<{ id:string }>(`INSERT INTO osint.evidence_match_evaluations + (id,level_id,board_id,document_exhibit_id,extraction_id,rule_id,matched,matched_anchor_count,score) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT (level_id,document_exhibit_id,rule_id) DO UPDATE SET + extraction_id=EXCLUDED.extraction_id,matched=EXCLUDED.matched, + matched_anchor_count=EXCLUDED.matched_anchor_count,score=EXCLUDED.score,evaluated_at=NOW() + RETURNING id`, [randomUUID(),level.id,level.board_id,document.document_exhibit_id,document.extraction_id, + rule.id,evaluation.matched,evaluation.matchedAnchorCount,evaluation.score]) + const evaluationId = persisted.rows[0].id + await client.query('DELETE FROM osint.evidence_match_anchor_evaluations WHERE evaluation_id=$1', [evaluationId]) + for (const anchor of evaluation.anchors) await client.query(`INSERT INTO osint.evidence_match_anchor_evaluations + (evaluation_id,anchor_id,similarity,matched,matched_text) VALUES ($1,$2,$3,$4,$5)`, + [evaluationId,anchor.anchorId,anchor.similarity,anchor.matched,anchor.matchedText]) + if (evaluation.matched) await client.query(`INSERT INTO osint.level_flags + (level_id,board_id,flag_key,awarded_by_evidence_match_id) VALUES ($1,$2,$3,$4) + ON CONFLICT (level_id,flag_key) DO NOTHING`, [level.id,level.board_id,evaluation.flagKey,evaluationId]) + } } async function levelGoalStates(