feat: add brief concept party classification

This commit is contained in:
2026-08-14 14:44:58 +02:00
parent e333d3b634
commit d46a425401
17 changed files with 431 additions and 34 deletions
+68 -10
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from 'node:crypto'
import type { Pool, PoolClient } from 'pg'
import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from '../src/types.js'
import type { BriefConcept, CaseDocument, CaseState, Evidence, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from '../src/types.js'
import { clearBoard, cloneBoard } from './boardClone.js'
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
@@ -26,11 +26,12 @@ type LevelRow = {
source_template_version_id: string | null
}
type ExhibitRow = {
id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event'; xpos: number; ypos: number; width: number; hidden: boolean
id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event' | 'party'; xpos: number; ypos: number; width: 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
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
}
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
@@ -59,14 +60,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
return result.rows[0] || null
}
async function assembleLevel(slug: string): Promise<CaseState | null> {
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
const level = await findLevel(pool, slug)
if (!level) return null
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult] = await Promise.all([
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
COALESCE(f.title, d.title, n.title, ev.title, '') AS title,
COALESCE(f.label_text, n.note_text, ev.narrative_text, '') AS content,
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') 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,
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
FROM osint.exhibits e
@@ -74,6 +77,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
LEFT JOIN osint.document_exhibits d ON d.exhibit_id = e.id
LEFT JOIN osint.note_exhibits n ON n.exhibit_id = e.id
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.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
@@ -96,6 +101,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string }>(
`SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1
ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
pool.query<{ party_exhibit_id: string; alias: string }>(
`SELECT a.party_exhibit_id,a.alias FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id
WHERE e.board_id=$1 ORDER BY a.party_exhibit_id,a.sort_order,a.id`, [level.board_id]),
pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string }>(
`SELECT party_exhibit_id,evidence_exhibit_id FROM osint.party_evidence WHERE board_id=$1
ORDER BY party_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
pool.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [level.board_id]),
pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>(
`SELECT id,label,context_text,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts
WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]),
])
const blocks = new Map<string, string[]>()
@@ -109,6 +124,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
const contained = new Map<string, string[]>()
const eventEvidence = new Map<string, string[]>()
for (const row of eventEvidenceResult.rows) eventEvidence.set(row.event_exhibit_id, [...(eventEvidence.get(row.event_exhibit_id) || []), row.evidence_exhibit_id])
const aliases = new Map<string, string[]>()
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
const partyEvidence = new Map<string, string[]>()
for (const row of partyEvidenceResult.rows) partyEvidence.set(row.party_exhibit_id, [...(partyEvidence.get(row.party_exhibit_id) || []), row.evidence_exhibit_id])
const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id])
return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id,
@@ -127,12 +146,17 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined,
eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width,
supportingEvidenceIds: eventEvidence.get(row.id) || [],
partyKind: row.party_kind || undefined, organizationKind: row.organization_kind || undefined,
aliases: aliases.get(row.id) || [], relatedEvidenceIds: partyEvidence.get(row.id) || [],
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [],
}))
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 }))
return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id })),
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
levelStatus: level.status, editingAllowed: editingEnabled, sourceTemplateVersionId: level.source_template_version_id || undefined }
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled,
sourceTemplateVersionId: level.source_template_version_id || undefined }
}
async function templateSummary(slug: string): Promise<TemplateSummary | null> {
@@ -163,6 +187,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
}
const existing = await client.query<{ id: string; xpos: number; ypos: number }>('SELECT id, xpos, ypos FROM osint.exhibits WHERE board_id = $1', [level.board_id])
for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos })
const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>(
'SELECT id,expected_party_kind FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])).rows.map(row => [row.id, row.expected_party_kind]))
await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6,
updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
@@ -170,9 +196,13 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
await client.query('DELETE FROM osint.exhibit_connections WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.folder_memberships WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.event_evidence WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.party_evidence WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.party_relationships WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])
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', 'document_exhibits']) {
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.${table} WHERE exhibit_id IN (SELECT id FROM osint.exhibits 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])
@@ -195,7 +225,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
}
for (const [index, exhibit] of state.evidence.entries()) {
const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type
const canonicalType = type === 'folder' || type === 'note' || type === 'event' ? type : 'note'
const canonicalType = type === 'folder' || type === 'note' || type === 'event' || type === 'party' ? type : 'note'
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
VALUES ($1,$2,$3,$4,$5,$6,160,$7,FALSE)
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=160,z_index=$7,hidden=FALSE,updated_at=NOW()`,
@@ -207,6 +237,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
if (canonicalType === 'event') await client.query(
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
[exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate) || new Date().toISOString()])
if (canonicalType === 'party') {
const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person'
await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
[exhibit.id, partyKind, exhibit.title, exhibit.content])
if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id])
else await client.query('INSERT INTO osint.organization_parties (exhibit_id,organization_kind) VALUES ($1,$2)',
[exhibit.id, exhibit.organizationKind || 'business'])
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])
}
}
for (const relation of relationList.filter(item => item.type === 'contains')) {
@@ -222,6 +262,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
[level.board_id, event.id, evidenceId, sortOrder])
}
}
for (const party of state.evidence.filter(item => item.type === 'party')) {
for (const [sortOrder, evidenceId] of (party.relatedEvidenceIds || []).entries()) {
if (evidenceId === party.id || !allIds.includes(evidenceId)) throw new Error('Party evidence references an unknown or identical exhibit')
await client.query(`INSERT INTO osint.party_evidence
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
[level.board_id, party.id, evidenceId, sortOrder])
}
}
for (const connection of state.connections) {
requireUuid(connection.id, 'Connection id')
if (!evidenceIds.has(connection.fromEvidenceId) || !evidenceIds.has(connection.toEvidenceId)) throw new Error('Connection references an unknown exhibit')
@@ -248,6 +296,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
await client.query('INSERT INTO osint.exhibit_metadata_text_values (exhibit_id,field_id,value) VALUES ($1,$2,$3)',
[document.id, fields.get(key), document.metadata[key]])
}
const brief = state.brief || { body: '', concepts: [] }
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
for (const [sortOrder, concept] of brief.concepts.entries()) {
requireUuid(concept.id, 'Brief concept id')
if (concept.resolvedPartyExhibitId && !evidenceIds.has(concept.resolvedPartyExhibitId)) throw new Error('Concept resolution references an unknown party')
const expected = concept.expectedPartyKind || expectedConceptKinds.get(concept.id) || null
await client.query(`INSERT INTO osint.brief_concepts
(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])
}
}
return {
@@ -329,7 +387,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return templateSummary(input.slug)
},
getLevel(levelId) { return assembleLevel(levelId) },
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
async saveLevel(state) {
const client = await pool.connect()
try {