Implement Scene 7 evidence goal flow
This commit is contained in:
+351
-12
@@ -1,10 +1,11 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { Readable } from 'node:stream'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
|
||||
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 { clearBoard, cloneBoard } from './boardClone.js'
|
||||
import { evaluateEvidenceRules, type EvidenceMatchRule } from './evidenceMatching.js'
|
||||
import { EvidenceJudgeError, type EvidenceJudge } from './evidenceJudge.js'
|
||||
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
|
||||
import type { ObjectStorage } from './objectStorage.js'
|
||||
import type { TextExtractionResult } from './ocr.js'
|
||||
@@ -15,11 +16,33 @@ export type AssetResponse = { originalName: string; mimeType: string; byteSize:
|
||||
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
|
||||
export type EvidenceMatchRuleInput = {
|
||||
name: string
|
||||
sourceLabel?: string
|
||||
sourceUri?: string
|
||||
flagKey: string
|
||||
minimumAnchorMatches?: number
|
||||
enabled?: boolean
|
||||
anchors: { phrase: string; minimumSimilarity?: number }[]
|
||||
}
|
||||
export type LevelGoalInput = {
|
||||
key: string
|
||||
title: string
|
||||
instructions?: string
|
||||
completionMessage?: string
|
||||
enabled?: boolean
|
||||
requiredFlags: string[]
|
||||
}
|
||||
export type EvidenceSemanticRuleInput = {
|
||||
goalId: string
|
||||
name: string
|
||||
targetSubject: string
|
||||
relatedSubject?: string
|
||||
assertion: string
|
||||
successFlagKey: string
|
||||
relatedFlagKey?: string
|
||||
minimumConfidence?: number
|
||||
evaluatorVersion?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface LevelRepository {
|
||||
listLevels(): Promise<unknown[]>
|
||||
@@ -39,6 +62,15 @@ export interface LevelRepository {
|
||||
createEvidenceMatchRule(levelId: string, input: EvidenceMatchRuleInput): Promise<EvidenceMatchRuleDefinition | null>
|
||||
updateEvidenceMatchRule(levelId: string, ruleId: string, input: EvidenceMatchRuleInput): Promise<EvidenceMatchRuleDefinition | null>
|
||||
deleteEvidenceMatchRule(levelId: string, ruleId: string): Promise<boolean | null>
|
||||
listGoals(levelId: string): Promise<LevelGoal[] | null>
|
||||
createGoal(levelId: string, input: LevelGoalInput): Promise<LevelGoal | null>
|
||||
updateGoal(levelId: string, goalId: string, input: LevelGoalInput): Promise<LevelGoal | null>
|
||||
deleteGoal(levelId: string, goalId: string): Promise<boolean | null>
|
||||
listEvidenceSemanticRules(levelId: string): Promise<EvidenceSemanticRuleDefinition[] | null>
|
||||
createEvidenceSemanticRule(levelId: string, input: EvidenceSemanticRuleInput): Promise<EvidenceSemanticRuleDefinition | null>
|
||||
updateEvidenceSemanticRule(levelId: string, ruleId: string, input: EvidenceSemanticRuleInput): Promise<EvidenceSemanticRuleDefinition | null>
|
||||
deleteEvidenceSemanticRule(levelId: string, ruleId: string): Promise<boolean | null>
|
||||
judgeDocument(levelId: string, documentId: string): Promise<DocumentSemanticAnalysis | null>
|
||||
}
|
||||
|
||||
type LevelRow = {
|
||||
@@ -74,6 +106,11 @@ function requireRuleInput(input: EvidenceMatchRuleInput) {
|
||||
const name = String(input.name || '').trim()
|
||||
if (!name || name.length > 160) throw new Error('Evidence match rule names must be between 1 and 160 characters')
|
||||
const flagKey = requireFlagKey(String(input.flagKey || '').trim())
|
||||
const sourceLabel = String(input.sourceLabel || '').trim()
|
||||
const sourceUri = String(input.sourceUri || '').trim()
|
||||
if (sourceLabel.length > 300) throw new Error('Evidence source labels cannot exceed 300 characters')
|
||||
if (sourceUri.length > 2_000) throw new Error('Evidence source URIs cannot exceed 2000 characters')
|
||||
if (sourceUri) { try { new URL(sourceUri) } catch { throw new Error('Evidence source URI must be an absolute URL') } }
|
||||
if (!Array.isArray(input.anchors) || !input.anchors.length || input.anchors.length > 20) throw new Error('Evidence match rules require between 1 and 20 anchors')
|
||||
const anchors = input.anchors.map(anchor => {
|
||||
const phrase = String(anchor.phrase || '').trim()
|
||||
@@ -84,13 +121,45 @@ function requireRuleInput(input: EvidenceMatchRuleInput) {
|
||||
})
|
||||
const minimumAnchorMatches = input.minimumAnchorMatches === undefined ? 1 : Number(input.minimumAnchorMatches)
|
||||
if (!Number.isInteger(minimumAnchorMatches) || minimumAnchorMatches < 1 || minimumAnchorMatches > anchors.length) throw new Error('Required anchor matches must be between 1 and the number of anchors')
|
||||
return { name, flagKey, minimumAnchorMatches, enabled: input.enabled !== false, anchors }
|
||||
return { name, sourceLabel:sourceLabel || undefined, sourceUri:sourceUri || undefined, flagKey, minimumAnchorMatches, enabled: input.enabled !== false, anchors }
|
||||
}
|
||||
function requireGoalInput(input: LevelGoalInput) {
|
||||
const key = requireFlagKey(String(input.key || '').trim())
|
||||
const title = String(input.title || '').trim()
|
||||
const instructions = String(input.instructions || '').trim()
|
||||
const completionMessage = String(input.completionMessage || '').trim()
|
||||
if (!title || title.length > 200) throw new Error('Level goal titles must be between 1 and 200 characters')
|
||||
if (instructions.length > 10_000) throw new Error('Level goal instructions cannot exceed 10000 characters')
|
||||
if (completionMessage.length > 2_000) throw new Error('Level goal completion messages cannot exceed 2000 characters')
|
||||
if (!Array.isArray(input.requiredFlags)) throw new Error('Level goal requiredFlags must be an array')
|
||||
const requiredFlags = [...new Set(input.requiredFlags.map(value => requireFlagKey(String(value || '').trim())))]
|
||||
if (!requiredFlags.length || requiredFlags.length > 20) throw new Error('Level goals require between 1 and 20 flags')
|
||||
return { key, title, instructions, completionMessage, enabled: input.enabled !== false, requiredFlags }
|
||||
}
|
||||
function requireSemanticRuleInput(input: EvidenceSemanticRuleInput) {
|
||||
const goalId = requireUuid(String(input.goalId || '').trim(), 'Goal id')
|
||||
const name = String(input.name || '').trim()
|
||||
const targetSubject = String(input.targetSubject || '').trim()
|
||||
const relatedSubject = String(input.relatedSubject || '').trim()
|
||||
const assertion = String(input.assertion || '').trim()
|
||||
const successFlagKey = requireFlagKey(String(input.successFlagKey || '').trim())
|
||||
const relatedFlagKey = relatedSubject && input.relatedFlagKey ? requireFlagKey(String(input.relatedFlagKey).trim()) : undefined
|
||||
const minimumConfidence = input.minimumConfidence === undefined ? .85 : Number(input.minimumConfidence)
|
||||
const evaluatorVersion = String(input.evaluatorVersion || 'evidence_claim_v1').trim()
|
||||
if (!name || name.length > 160) throw new Error('Semantic rule names must be between 1 and 160 characters')
|
||||
if (!targetSubject || targetSubject.length > 300) throw new Error('Target subjects must be between 1 and 300 characters')
|
||||
if (relatedSubject.length > 300) throw new Error('Related subjects cannot exceed 300 characters')
|
||||
if (!assertion || assertion.length > 2_000) throw new Error('Assertions must be between 1 and 2000 characters')
|
||||
if (!Number.isFinite(minimumConfidence) || minimumConfidence < .5 || minimumConfidence > 1) throw new Error('Semantic confidence must be between 0.5 and 1')
|
||||
if (!evaluatorVersion || evaluatorVersion.length > 100) throw new Error('Evaluator versions must be between 1 and 100 characters')
|
||||
return { goalId,name,targetSubject,relatedSubject:relatedSubject || undefined,assertion,successFlagKey,relatedFlagKey,
|
||||
minimumConfidence,evaluatorVersion,enabled:input.enabled !== false }
|
||||
}
|
||||
function documentType(document: CaseDocument): SourceFileType {
|
||||
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
|
||||
return allowed.includes(document.fileType) ? document.fileType : 'file'
|
||||
}
|
||||
export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage): LevelRepository {
|
||||
export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage, evidenceJudge: EvidenceJudge): LevelRepository {
|
||||
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
|
||||
const result = await client.query<LevelRow>(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
|
||||
l.viewport_x,l.viewport_y,l.viewport_zoom,l.updated_at,l.source_template_version_id,b.revision::text
|
||||
@@ -107,9 +176,9 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
|
||||
async function evidenceMatchRules(client: Pool | PoolClient, boardId: string, includeDisabled = false): Promise<EvidenceMatchRuleDefinition[]> {
|
||||
const result = await client.query<{
|
||||
rule_id: string; name: string; flag_key: string; matcher_version: 'char_trigram_v1'; minimum_anchor_matches: number; enabled: boolean
|
||||
rule_id: string; name: string; source_label:string | null; source_uri:string | null; flag_key: string; matcher_version: 'char_trigram_v1'; minimum_anchor_matches: number; enabled: boolean
|
||||
anchor_id: string; phrase_text: string; minimum_similarity: string; sort_order: number
|
||||
}>(`SELECT r.id AS rule_id,r.name,r.flag_key,r.matcher_version,r.minimum_anchor_matches,r.enabled,
|
||||
}>(`SELECT r.id AS rule_id,r.name,r.source_label,r.source_uri,r.flag_key,r.matcher_version,r.minimum_anchor_matches,r.enabled,
|
||||
a.id AS anchor_id,a.phrase_text,a.minimum_similarity::text,a.sort_order
|
||||
FROM osint.evidence_match_rules r
|
||||
JOIN osint.evidence_match_anchors a ON a.rule_id=r.id
|
||||
@@ -117,7 +186,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
ORDER BY r.created_at,r.id,a.sort_order,a.id`, [boardId])
|
||||
const rules = new Map<string, EvidenceMatchRuleDefinition>()
|
||||
for (const row of result.rows) {
|
||||
const rule = rules.get(row.rule_id) || { id: row.rule_id, name: row.name, flagKey: row.flag_key,
|
||||
const rule = rules.get(row.rule_id) || { id: row.rule_id, name: row.name, sourceLabel:row.source_label || undefined,sourceUri:row.source_uri || undefined,flagKey: row.flag_key,
|
||||
matcherVersion: row.matcher_version, minimumAnchorMatches: row.minimum_anchor_matches, enabled: row.enabled, anchors: [] }
|
||||
rule.anchors.push({ id: row.anchor_id, phrase: row.phrase_text, minimumSimilarity: Number(row.minimum_similarity), sortOrder: row.sort_order })
|
||||
rules.set(row.rule_id, rule)
|
||||
@@ -128,13 +197,13 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
async function writeEvidenceMatchRule(client: PoolClient, level: LevelRow, ruleId: string, rawInput: EvidenceMatchRuleInput, update: boolean) {
|
||||
const input = requireRuleInput(rawInput)
|
||||
if (update) {
|
||||
const changed = await client.query(`UPDATE osint.evidence_match_rules SET name=$3,flag_key=$4,minimum_anchor_matches=$5,enabled=$6,updated_at=NOW()
|
||||
WHERE id=$1 AND board_id=$2`, [ruleId, level.board_id, input.name, input.flagKey, input.minimumAnchorMatches, input.enabled])
|
||||
const changed = await client.query(`UPDATE osint.evidence_match_rules SET name=$3,source_label=$4,source_uri=$5,flag_key=$6,minimum_anchor_matches=$7,enabled=$8,updated_at=NOW()
|
||||
WHERE id=$1 AND board_id=$2`, [ruleId,level.board_id,input.name,input.sourceLabel || null,input.sourceUri || null,input.flagKey,input.minimumAnchorMatches,input.enabled])
|
||||
if (!changed.rowCount) return null
|
||||
await client.query('DELETE FROM osint.evidence_match_anchors WHERE rule_id=$1', [ruleId])
|
||||
} else {
|
||||
await client.query(`INSERT INTO osint.evidence_match_rules (id,board_id,name,flag_key,minimum_anchor_matches,enabled)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`, [ruleId, level.board_id, input.name, input.flagKey, input.minimumAnchorMatches, input.enabled])
|
||||
await client.query(`INSERT INTO osint.evidence_match_rules (id,board_id,name,source_label,source_uri,flag_key,minimum_anchor_matches,enabled)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, [ruleId,level.board_id,input.name,input.sourceLabel || null,input.sourceUri || null,input.flagKey,input.minimumAnchorMatches,input.enabled])
|
||||
}
|
||||
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)`,
|
||||
@@ -143,6 +212,97 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
return (await evidenceMatchRules(client, level.board_id, true)).find(rule => rule.id === ruleId) || null
|
||||
}
|
||||
|
||||
async function levelGoalStates(
|
||||
client: Pool | PoolClient,
|
||||
level: Pick<LevelRow, 'id' | 'board_id'>,
|
||||
authorMode = false,
|
||||
newlyCompletedKeys: ReadonlySet<string> = new Set(),
|
||||
): Promise<LevelGoal[]> {
|
||||
const result = await client.query<{
|
||||
id: string; goal_key: string; title: string; instructions: string; completion_message: string; enabled: boolean
|
||||
required_flags: string[]; requirement_count: number; earned_count: number; completed_at: Date | null
|
||||
}>(`SELECT goal.id,goal.goal_key,goal.title,goal.instructions,goal.completion_message,goal.enabled,
|
||||
COALESCE(array_agg(requirement.flag_key ORDER BY requirement.flag_key)
|
||||
FILTER (WHERE requirement.flag_key IS NOT NULL),'{}'::text[]) AS required_flags,
|
||||
COUNT(requirement.flag_key)::int AS requirement_count,
|
||||
COUNT(flag.flag_key)::int AS earned_count,
|
||||
MAX(flag.earned_at) AS completed_at
|
||||
FROM osint.level_goals goal
|
||||
LEFT JOIN osint.level_goal_flag_requirements requirement ON requirement.goal_id=goal.id
|
||||
LEFT JOIN osint.level_flags flag ON flag.level_id=$1 AND flag.flag_key=requirement.flag_key
|
||||
WHERE goal.board_id=$2 ${authorMode ? '' : 'AND goal.enabled'}
|
||||
GROUP BY goal.id
|
||||
ORDER BY goal.created_at,goal.id`, [level.id, level.board_id])
|
||||
return result.rows.map(row => {
|
||||
const complete = row.requirement_count > 0 && row.earned_count === row.requirement_count
|
||||
return {
|
||||
...(authorMode ? { id: row.id, enabled: row.enabled, requiredFlags: row.required_flags } : {}),
|
||||
key: row.goal_key,
|
||||
title: row.title,
|
||||
instructions: row.instructions,
|
||||
completionMessage: row.completion_message,
|
||||
status: complete ? 'complete' as const : 'pending' as const,
|
||||
...(complete && row.completed_at ? { completedAt: row.completed_at.toISOString() } : {}),
|
||||
newlyCompleted: complete && newlyCompletedKeys.has(row.goal_key),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function writeGoal(client: PoolClient, level: LevelRow, goalId: string, rawInput: LevelGoalInput, update: boolean) {
|
||||
const input = requireGoalInput(rawInput)
|
||||
if (update) {
|
||||
const changed = await client.query(`UPDATE osint.level_goals SET
|
||||
goal_key=$3,title=$4,instructions=$5,completion_message=$6,enabled=$7,updated_at=NOW()
|
||||
WHERE id=$1 AND board_id=$2`, [goalId,level.board_id,input.key,input.title,input.instructions,input.completionMessage,input.enabled])
|
||||
if (!changed.rowCount) return null
|
||||
await client.query('DELETE FROM osint.level_goal_flag_requirements WHERE goal_id=$1', [goalId])
|
||||
} else await client.query(`INSERT INTO osint.level_goals
|
||||
(id,board_id,goal_key,title,instructions,completion_message,enabled) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[goalId,level.board_id,input.key,input.title,input.instructions,input.completionMessage,input.enabled])
|
||||
for (const flag of input.requiredFlags) await client.query(
|
||||
'INSERT INTO osint.level_goal_flag_requirements (board_id,goal_id,flag_key) VALUES ($1,$2,$3)',
|
||||
[level.board_id,goalId,flag])
|
||||
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||
return (await levelGoalStates(client, level, true)).find(goal => goal.id === goalId) || null
|
||||
}
|
||||
|
||||
async function evidenceSemanticRules(client: Pool | PoolClient, boardId: string, includeDisabled = false): Promise<EvidenceSemanticRuleDefinition[]> {
|
||||
const result = await client.query<{
|
||||
id:string; goal_id:string; goal_key:string; name:string; target_subject:string; related_subject:string | null; assertion_text:string
|
||||
success_flag_key:string; related_flag_key:string | null; minimum_confidence:string; evaluator_version:string; enabled:boolean
|
||||
}>(`SELECT rule.id,rule.goal_id,goal.goal_key,rule.name,rule.target_subject,rule.related_subject,rule.assertion_text,
|
||||
rule.success_flag_key,rule.related_flag_key,rule.minimum_confidence::text,rule.evaluator_version,rule.enabled
|
||||
FROM osint.evidence_semantic_rules rule JOIN osint.level_goals goal ON goal.id=rule.goal_id
|
||||
WHERE rule.board_id=$1 ${includeDisabled ? '' : 'AND rule.enabled AND goal.enabled'}
|
||||
ORDER BY rule.created_at,rule.id`, [boardId])
|
||||
return result.rows.map(row => ({
|
||||
id:row.id,goalId:row.goal_id,goalKey:row.goal_key,name:row.name,targetSubject:row.target_subject,
|
||||
relatedSubject:row.related_subject || undefined,assertion:row.assertion_text,successFlagKey:row.success_flag_key,
|
||||
relatedFlagKey:row.related_flag_key || undefined,minimumConfidence:Number(row.minimum_confidence),
|
||||
evaluatorVersion:row.evaluator_version,enabled:row.enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
async function writeEvidenceSemanticRule(client: PoolClient, level: LevelRow, ruleId: string, rawInput: EvidenceSemanticRuleInput, update: boolean) {
|
||||
const input = requireSemanticRuleInput(rawInput)
|
||||
if (update) {
|
||||
const changed = await client.query(`UPDATE osint.evidence_semantic_rules SET goal_id=$3,name=$4,target_subject=$5,
|
||||
related_subject=$6,assertion_text=$7,success_flag_key=$8,related_flag_key=$9,minimum_confidence=$10,
|
||||
evaluator_version=$11,enabled=$12,updated_at=NOW() WHERE id=$1 AND board_id=$2`,
|
||||
[ruleId,level.board_id,input.goalId,input.name,input.targetSubject,input.relatedSubject || null,input.assertion,input.successFlagKey,
|
||||
input.relatedFlagKey || null,input.minimumConfidence,input.evaluatorVersion,input.enabled])
|
||||
if (!changed.rowCount) return null
|
||||
} else await client.query(`INSERT INTO osint.evidence_semantic_rules
|
||||
(id,board_id,goal_id,name,target_subject,related_subject,assertion_text,success_flag_key,related_flag_key,
|
||||
minimum_confidence,evaluator_version,enabled) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||
[ruleId,level.board_id,input.goalId,input.name,input.targetSubject,input.relatedSubject || null,input.assertion,input.successFlagKey,
|
||||
input.relatedFlagKey || null,input.minimumConfidence,input.evaluatorVersion,input.enabled])
|
||||
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||
return (await evidenceSemanticRules(client, level.board_id, true)).find(rule => rule.id === ruleId) || null
|
||||
}
|
||||
|
||||
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
|
||||
const level = await findLevel(pool, slug)
|
||||
if (!level) return null
|
||||
@@ -252,13 +412,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined }))
|
||||
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 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 }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
||||
brief: { body: briefResult.rows[0]?.body || '', concepts }, goals, 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)
|
||||
}
|
||||
@@ -523,6 +684,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
'SELECT board_id,title,subtitle FROM osint.level_template_versions WHERE id=$1 FOR SHARE', [level.source_template_version_id])
|
||||
const source = version.rows[0]
|
||||
if (!source) throw new Error('Source template version not found')
|
||||
await client.query('DELETE FROM osint.level_flags WHERE level_id=$1', [level.id])
|
||||
await client.query('DELETE FROM osint.level_seen_documents WHERE level_id=$1', [level.id])
|
||||
await clearBoard(client, level.board_id)
|
||||
await cloneBoard(client, source.board_id, level.board_id)
|
||||
await client.query(`UPDATE osint.levels SET title=$2,subtitle=$3,viewport_x=0,viewport_y=28,viewport_zoom=0.7,updated_at=NOW()
|
||||
@@ -551,6 +714,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, levelId, true)
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
const goalsBeforeUpload = await levelGoalStates(client, level)
|
||||
const candidateAssetId = randomUUID(); const exhibitId = randomUUID()
|
||||
const checksum = createHash('sha256').update(file.buffer).digest('hex')
|
||||
let assetId = (await client.query<{ id: string }>('SELECT id FROM osint.assets WHERE checksum_sha256=$1 AND byte_size=$2 FOR SHARE', [checksum,file.size])).rows[0]?.id
|
||||
@@ -605,13 +769,18 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
[level.id, level.board_id, evaluation.flagKey, evaluationId])
|
||||
if (awarded.rowCount) awardedFlags.push(evaluation.flagKey)
|
||||
}
|
||||
const priorGoalStatus = new Map(goalsBeforeUpload.map(goal => [goal.key, goal.status]))
|
||||
const goals = (await levelGoalStates(client, level)).map(goal => ({
|
||||
...goal,
|
||||
newlyCompleted: goal.status === 'complete' && priorGoalStatus.get(goal.key) !== 'complete',
|
||||
}))
|
||||
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||
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,
|
||||
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)] } }
|
||||
analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)], goals } }
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
async listFlags(levelId) {
|
||||
@@ -624,6 +793,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
SELECT flag_key FROM osint.document_flag_requirements WHERE board_id=$2
|
||||
UNION
|
||||
SELECT flag_key FROM osint.evidence_match_rules WHERE board_id=$2
|
||||
UNION
|
||||
SELECT flag_key FROM osint.level_goal_flag_requirements WHERE board_id=$2
|
||||
)
|
||||
SELECT keys.flag_key,flags.earned_at,COUNT(requirements.document_exhibit_id)::int AS gated_document_count
|
||||
FROM keys
|
||||
@@ -692,6 +863,174 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
return Boolean(removed.rowCount)
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
async listGoals(levelId) {
|
||||
const level = await findLevel(pool, levelId)
|
||||
return level ? levelGoalStates(pool, level, true) : null
|
||||
},
|
||||
async createGoal(levelId, input) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, levelId, true)
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
const goal = await writeGoal(client, level, randomUUID(), input, false)
|
||||
await client.query('COMMIT')
|
||||
return goal
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
async updateGoal(levelId, goalId, input) {
|
||||
if (!uuidPattern.test(goalId)) return null
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, levelId, true)
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
const goal = await writeGoal(client, level, goalId, input, true)
|
||||
await client.query('COMMIT')
|
||||
return goal
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
async deleteGoal(levelId, goalId) {
|
||||
if (!uuidPattern.test(goalId)) return false
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, levelId, true)
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
const removed = await client.query('DELETE FROM osint.level_goals WHERE id=$1 AND board_id=$2', [goalId,level.board_id])
|
||||
if (removed.rowCount) {
|
||||
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
return Boolean(removed.rowCount)
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
async listEvidenceSemanticRules(levelId) {
|
||||
const level = await findLevel(pool, levelId)
|
||||
return level ? evidenceSemanticRules(pool, level.board_id, true) : null
|
||||
},
|
||||
async createEvidenceSemanticRule(levelId, input) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, levelId, true)
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
const rule = await writeEvidenceSemanticRule(client, level, randomUUID(), input, false)
|
||||
await client.query('COMMIT')
|
||||
return rule
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
async updateEvidenceSemanticRule(levelId, ruleId, input) {
|
||||
if (!uuidPattern.test(ruleId)) return null
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, levelId, true)
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
const rule = await writeEvidenceSemanticRule(client, level, ruleId, input, true)
|
||||
await client.query('COMMIT')
|
||||
return rule
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
async deleteEvidenceSemanticRule(levelId, ruleId) {
|
||||
if (!uuidPattern.test(ruleId)) return false
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, levelId, true)
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
const removed = await client.query('DELETE FROM osint.evidence_semantic_rules WHERE id=$1 AND board_id=$2', [ruleId,level.board_id])
|
||||
if (removed.rowCount) {
|
||||
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
return Boolean(removed.rowCount)
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
async judgeDocument(levelId, documentId) {
|
||||
if (!uuidPattern.test(documentId)) return null
|
||||
const level = await findLevel(pool, levelId)
|
||||
if (!level) return null
|
||||
const document = (await pool.query<{ extraction_id:string; extracted_text:string }>(`SELECT extraction.id AS extraction_id,extraction.extracted_text
|
||||
FROM osint.document_exhibits document
|
||||
JOIN osint.exhibits exhibit ON exhibit.id=document.exhibit_id
|
||||
JOIN osint.asset_text_extractions extraction ON extraction.asset_id=document.asset_id AND extraction.status='succeeded'
|
||||
WHERE document.exhibit_id=$1 AND exhibit.board_id=$2
|
||||
ORDER BY extraction.updated_at DESC LIMIT 1`, [documentId,level.board_id])).rows[0]
|
||||
const goalsBefore = await levelGoalStates(pool, level)
|
||||
if (!document?.extracted_text.trim()) return { status:'failed',retryable:false,awardedFlags:[],goals:goalsBefore }
|
||||
const pendingGoalKeys = new Set(goalsBefore.filter(goal => goal.status === 'pending').map(goal => goal.key))
|
||||
const rules = (await evidenceSemanticRules(pool, level.board_id)).filter(rule => pendingGoalKeys.has(rule.goalKey)).slice(0, 5)
|
||||
if (!rules.length) return { status:'not_needed',retryable:false,awardedFlags:[],goals:goalsBefore }
|
||||
if (!evidenceJudge.enabled) return { status:'unavailable',retryable:true,awardedFlags:[],goals:goalsBefore }
|
||||
|
||||
type EvaluationRow = { id:string; status:'pending'|'succeeded'|'failed'; subject:'target'|'related'|'ambiguous'|'neither'|null
|
||||
supports_claim:boolean|null; evidence_excerpt:string; confidence:string|null }
|
||||
const awardedFlags: string[] = []
|
||||
let resultStatus: DocumentSemanticAnalysis['status'] = 'failed'
|
||||
let lastVerdict: Pick<DocumentSemanticAnalysis,'subject'|'supportsClaim'|'evidenceExcerpt'|'confidence'> = {}
|
||||
for (const rule of rules) {
|
||||
const evaluatorVersion = `${rule.evaluatorVersion}:${evidenceJudge.evaluatorVersion}`.slice(0, 100)
|
||||
const evaluationId = randomUUID()
|
||||
const claimed = await pool.query<{ id:string }>(`INSERT INTO osint.evidence_semantic_evaluations
|
||||
(id,level_id,board_id,document_exhibit_id,extraction_id,rule_id,evaluator_version,provider,model,status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending')
|
||||
ON CONFLICT (level_id,document_exhibit_id,rule_id,evaluator_version) DO UPDATE SET
|
||||
provider=EXCLUDED.provider,model=EXCLUDED.model,status='pending',subject=NULL,supports_claim=NULL,
|
||||
evidence_excerpt='',confidence=NULL,failure_code=NULL,attempt_count=osint.evidence_semantic_evaluations.attempt_count+1,updated_at=NOW()
|
||||
WHERE osint.evidence_semantic_evaluations.status='failed'
|
||||
OR (osint.evidence_semantic_evaluations.status='pending' AND osint.evidence_semantic_evaluations.updated_at < NOW()-INTERVAL '2 minutes')
|
||||
RETURNING id`, [evaluationId,level.id,level.board_id,documentId,document.extraction_id,rule.id,evaluatorVersion,evidenceJudge.provider,evidenceJudge.model])
|
||||
const activeId = claimed.rows[0]?.id
|
||||
if (!activeId) {
|
||||
const existing = (await pool.query<EvaluationRow>(`SELECT id,status,subject,supports_claim,evidence_excerpt,confidence::text
|
||||
FROM osint.evidence_semantic_evaluations WHERE level_id=$1 AND document_exhibit_id=$2 AND rule_id=$3 AND evaluator_version=$4`,
|
||||
[level.id,documentId,rule.id,evaluatorVersion])).rows[0]
|
||||
if (existing?.status === 'pending') { if (resultStatus !== 'succeeded') resultStatus = 'pending'; continue }
|
||||
if (existing?.status === 'succeeded') {
|
||||
resultStatus = 'succeeded'
|
||||
lastVerdict = { subject:existing.subject || undefined,supportsClaim:existing.supports_claim ?? undefined,
|
||||
evidenceExcerpt:existing.evidence_excerpt,confidence:existing.confidence === null ? undefined : Number(existing.confidence) }
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const verdict = await evidenceJudge.judge({ targetSubject:rule.targetSubject,relatedSubject:rule.relatedSubject,
|
||||
assertion:rule.assertion,evidenceText:document.extracted_text })
|
||||
const flagKey = verdict.supportsClaim && verdict.confidence >= rule.minimumConfidence
|
||||
? verdict.subject === 'target' ? rule.successFlagKey : verdict.subject === 'related' ? rule.relatedFlagKey : undefined
|
||||
: undefined
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`UPDATE osint.evidence_semantic_evaluations SET status='succeeded',subject=$2,supports_claim=$3,
|
||||
evidence_excerpt=$4,confidence=$5,failure_code=NULL,evaluated_at=NOW(),updated_at=NOW() WHERE id=$1`,
|
||||
[activeId,verdict.subject,verdict.supportsClaim,verdict.evidenceExcerpt,verdict.confidence])
|
||||
if (flagKey) {
|
||||
const awarded = await client.query(`INSERT INTO osint.level_flags
|
||||
(level_id,board_id,flag_key,awarded_by_semantic_evaluation_id) VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (level_id,flag_key) DO NOTHING RETURNING flag_key`, [level.id,level.board_id,flagKey,activeId])
|
||||
if (awarded.rowCount) awardedFlags.push(flagKey)
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
resultStatus = 'succeeded'
|
||||
lastVerdict = { subject:verdict.subject,supportsClaim:verdict.supportsClaim,evidenceExcerpt:verdict.evidenceExcerpt,confidence:verdict.confidence }
|
||||
if (flagKey === rule.successFlagKey) break
|
||||
} catch (error) {
|
||||
const code = (error instanceof EvidenceJudgeError ? error.code : 'provider_error').slice(0, 100)
|
||||
await pool.query(`UPDATE osint.evidence_semantic_evaluations SET status='failed',failure_code=$2,
|
||||
evaluated_at=NOW(),updated_at=NOW() WHERE id=$1`, [activeId,code])
|
||||
}
|
||||
}
|
||||
const priorGoalStatus = new Map(goalsBefore.map(goal => [goal.key,goal.status]))
|
||||
const goals = (await levelGoalStates(pool, level)).map(goal => ({ ...goal,
|
||||
newlyCompleted:goal.status === 'complete' && priorGoalStatus.get(goal.key) !== 'complete' }))
|
||||
return { status:resultStatus,retryable:resultStatus === 'failed' || resultStatus === 'pending',awardedFlags,goals,...lastVerdict }
|
||||
},
|
||||
async acknowledgeRevealedDocuments(levelId, rawDocumentIds) {
|
||||
const documentIds = [...new Set(rawDocumentIds.filter(id => uuidPattern.test(id)))]
|
||||
const level = await findLevel(pool, levelId)
|
||||
|
||||
Reference in New Issue
Block a user