Add Scene 7 claim report workflow

This commit is contained in:
2026-08-22 17:02:30 +02:00
parent a7f99a2a39
commit cea0d56cb9
23 changed files with 638 additions and 82 deletions
+49 -9
View File
@@ -2,8 +2,9 @@ import { createHash, randomUUID } from 'node:crypto'
import { Readable } from 'node:stream'
import type { Pool, PoolClient } from 'pg'
import type { BoardView, BriefConcept, CaseDocument, CaseState, DocumentSemanticAnalysis, Evidence, EvidenceMatchRuleDefinition, EvidenceSemanticRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, LevelGoal, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
import { isClaimExhibit, isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
import { clearBoard, cloneBoard } from './boardClone.js'
import { loadCaseReport } from './caseReports.js'
import { evaluateEvidenceRules, type EvidenceMatchRule } from './evidenceMatching.js'
import { EvidenceJudgeError, type EvidenceJudge } from './evidenceJudge.js'
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
@@ -82,6 +83,7 @@ type ExhibitRow = {
id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | null
original_name: string | null; mime_type: string | null; byte_size: string | null
source_document_id: string | null; source_region_key: string | null
party_kind: PartyKind | null; organization_kind: OrganizationKind | null
@@ -309,9 +311,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
f.is_open, d.document_type_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
ev.occurred_at,claim.statement,
p.party_kind, op.organization_kind,
a.original_name, a.mime_type, a.byte_size,
s.source_document_exhibit_id AS source_document_id, sr.region_key AS source_region_key
@@ -322,6 +325,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
LEFT JOIN osint.event_exhibits ev ON ev.exhibit_id = e.id
LEFT JOIN osint.party_exhibits p ON p.exhibit_id = e.id
LEFT JOIN osint.organization_parties op ON op.exhibit_id = e.id
LEFT JOIN osint.claim_exhibits claim ON claim.exhibit_id = e.id
LEFT JOIN osint.exhibit_citations citation ON citation.board_id=e.board_id AND citation.exhibit_id=e.id
LEFT JOIN osint.assets a ON a.id = d.asset_id
LEFT JOIN osint.exhibit_sources s ON s.exhibit_id = e.id
LEFT JOIN osint.document_regions sr ON sr.id = s.source_region_id
@@ -391,7 +396,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
const type = row.document_type_id || 'file'
const publishedAt = row.published_at?.toISOString()
return { ...base(row), type: 'document', publishedAt, requiredFlags: requirements.get(row.id) || [],
return { ...base(row), type: 'document', publishedAt, capturedAt:row.captured_at?.toISOString(),sourceUri:row.source_uri || undefined,
sourceCitation:row.citation_text || undefined,displayNumber:row.display_number || undefined,requiredFlags: requirements.get(row.id) || [],
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
@@ -402,6 +408,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] })
else if (row.exhibit_type_id === 'claim') evidence.push({ ...base(row), type:'claim', title:row.title, statement:row.statement || row.title })
else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note' })
else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`)
}
@@ -413,13 +420,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
const goals = await levelGoalStates(pool, level, authorMode)
const report = await loadCaseReport(pool,level)
const fullState: CaseState = { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
views, revision: Number(level.revision),
brief: { body: briefResult.rows[0]?.body || '', concepts }, goals, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
brief: { body: briefResult.rows[0]?.body || '', concepts }, goals, report, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
sourceTemplateVersionId: level.source_template_version_id || undefined }
return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode)
}
@@ -461,9 +469,19 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [level.board_id])
for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'person_parties', 'organization_parties', 'party_exhibits', 'document_exhibits']) {
await client.query('DELETE FROM osint.document_flag_requirements WHERE board_id=$1',[level.board_id])
await client.query('DELETE FROM osint.document_content_blocks WHERE document_exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)',[level.board_id])
await client.query('DELETE FROM osint.document_regions WHERE document_exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)',[level.board_id])
for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'person_parties', 'organization_parties', 'party_exhibits', 'claim_exhibits']) {
await client.query(`DELETE FROM osint.${table} WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)`, [level.board_id])
}
if (documentIds.size) {
await client.query('DELETE FROM osint.document_exhibits WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1) AND NOT (exhibit_id=ANY($2::uuid[]))',[level.board_id,[...documentIds]])
await client.query('DELETE FROM osint.exhibit_citations WHERE board_id=$1 AND NOT (exhibit_id=ANY($2::uuid[]))',[level.board_id,[...documentIds]])
} else {
await client.query('DELETE FROM osint.document_exhibits WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)',[level.board_id])
await client.query('DELETE FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])
}
if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds])
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
@@ -473,10 +491,22 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`,
[exhibit.id, level.board_id, exhibit.type, exhibit.x, exhibit.y, exhibit.width, exhibit.height, exhibit.rotation, exhibit.zIndex, exhibit.hidden])
}
let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum)
for (const document of documents) {
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [document.id, documentType(document), document.assetId || null, document.title,
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (exhibit_id) DO UPDATE SET
document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`, [document.id, documentType(document), document.assetId || null, document.title,
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || ''])
const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id])
if (!existingCitation.rows[0]) {
const requested = Number(document.displayNumber)
const displayNumber = Number.isInteger(requested) && requested > 0
&& !(await client.query('SELECT 1 FROM osint.exhibit_citations WHERE board_id=$1 AND display_number=$2',[level.board_id,requested])).rowCount
? requested : ++nextCitation
await client.query('INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number) VALUES ($1,$2,$3)',[level.board_id,document.id,displayNumber])
nextCitation=Math.max(nextCitation,displayNumber)
}
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
for (const flag of new Set((document.requiredFlags || []).map(value => value.trim()).filter(Boolean).map(requireFlagKey))) await client.query(
'INSERT INTO osint.document_flag_requirements (board_id,document_exhibit_id,flag_key) VALUES ($1,$2,$3)', [level.board_id, document.id, flag])
@@ -504,6 +534,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
for (const [sortOrder, alias] of (exhibit.aliases || []).filter(Boolean).entries()) await client.query(
'INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)', [randomUUID(), exhibit.id, alias, sortOrder])
}
if (isClaimExhibit(exhibit)) await client.query(
'INSERT INTO osint.claim_exhibits (exhibit_id,statement) VALUES ($1,$2)', [exhibit.id,exhibit.statement.trim()])
}
for (const relation of state.relations) {
@@ -574,6 +606,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
(id,board_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[concept.id, level.board_id, concept.label, concept.context, sortOrder, expected, concept.resolvedPartyExhibitId || null])
}
if (state.report) await client.query(`INSERT INTO osint.case_reports (board_id,title,required_for_completion)
VALUES ($1,$2,$3) ON CONFLICT (board_id) DO UPDATE SET title=EXCLUDED.title,
required_for_completion=EXCLUDED.required_for_completion,updated_at=NOW()`,
[level.board_id,state.report.title,state.report.requiredForCompletion])
}
return {
@@ -735,6 +771,9 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
VALUES ($1,$2,'document',$3,$4,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id, xpos, ypos])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
[exhibitId, fileType, assetId, file.originalname])
const citation = await client.query<{ display_number:number }>(`INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number)
SELECT $1,$2,COALESCE(MAX(display_number),0)+1 FROM osint.exhibit_citations WHERE board_id=$1 RETURNING display_number`,
[level.board_id,exhibitId])
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
if (extraction.status === 'succeeded' && extraction.text.trim()) await client.query(
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,0,$3)', [randomUUID(), exhibitId, extraction.text.trim()])
@@ -778,6 +817,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
await client.query('COMMIT')
return { id: exhibitId,type:'document',title:file.originalname,x:xpos,y:ypos,width:174,height:145,rotation:0,zIndex:0,hidden:false,
displayNumber:citation.rows[0].display_number,
fileType,metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)], goals } }