From 6a4c642e456ec0ccbfe4d93a1c6c151cee31a0f0 Mon Sep 17 00:00:00 2001 From: Kareem Naous <62202089+KareemN1936@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:52:15 +0300 Subject: [PATCH 1/2] feat(api): add canon consistency and search --- apps/api/package.json | 1 + .../migration.sql | 8 + .../check-upgrade-prisma-migrations.mjs | 5 + apps/api/src/app.ts | 2 + apps/api/src/lib/proposal-consistency.test.ts | 118 ++++++++ apps/api/src/lib/proposal-consistency.ts | 171 ++++++++++++ apps/api/src/lib/search-index.test.ts | 186 +++++++++++++ apps/api/src/lib/search-index.ts | 259 ++++++++++++++++++ apps/api/src/lib/worlds.ts | 6 + apps/api/src/routes/lore.test.ts | 43 +++ apps/api/src/routes/lore.ts | 20 +- apps/api/src/routes/proposals.ts | 50 ++++ apps/api/src/routes/search.ts | 45 +++ apps/api/src/routes/worlds.test.ts | 39 +++ apps/api/src/scripts/reindex-search.ts | 10 + .../[proposalId]/canon-vote-screen.tsx | 37 +++ apps/web/src/lib/api/proposals.ts | 14 + 17 files changed, 1013 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20260902124000_add_search_vector_index/migration.sql create mode 100644 apps/api/src/lib/proposal-consistency.test.ts create mode 100644 apps/api/src/lib/proposal-consistency.ts create mode 100644 apps/api/src/lib/search-index.test.ts create mode 100644 apps/api/src/lib/search-index.ts create mode 100644 apps/api/src/routes/search.ts create mode 100644 apps/api/src/scripts/reindex-search.ts diff --git a/apps/api/package.json b/apps/api/package.json index 8e2b194..755d79a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,6 +7,7 @@ "predev": "prisma generate", "dev": "tsx watch src/server.ts", "indexer:run": "tsx src/indexer/runner.ts", + "search:reindex": "tsx src/scripts/reindex-search.ts", "smoke:hive-mainnet": "tsx src/scripts/hive-mainnet-smoke.ts", "prisma:generate": "prisma generate", "prisma:validate": "prisma validate", diff --git a/apps/api/prisma/migrations/20260902124000_add_search_vector_index/migration.sql b/apps/api/prisma/migrations/20260902124000_add_search_vector_index/migration.sql new file mode 100644 index 0000000..df52a8f --- /dev/null +++ b/apps/api/prisma/migrations/20260902124000_add_search_vector_index/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "SearchIndex" +ADD COLUMN "searchVector" tsvector +GENERATED ALWAYS AS ( + setweight(to_tsvector('english', coalesce("title", '')), 'A') || + setweight(to_tsvector('english', coalesce("searchableContent", '')), 'B') +) STORED; + +CREATE INDEX "SearchIndex_searchVector_idx" ON "SearchIndex" USING GIN ("searchVector"); diff --git a/apps/api/scripts/check-upgrade-prisma-migrations.mjs b/apps/api/scripts/check-upgrade-prisma-migrations.mjs index f3da2d5..0722f53 100644 --- a/apps/api/scripts/check-upgrade-prisma-migrations.mjs +++ b/apps/api/scripts/check-upgrade-prisma-migrations.mjs @@ -155,6 +155,11 @@ try { cwd: baseApiWorkspaceDir, }, ); + + console.log('Re-running base branch migration history after resolve'); + runPrisma(['migrate', 'deploy', '--schema', baseSchema, '--config', baseConfig], { + cwd: baseApiWorkspaceDir, + }); } console.log('Deploying repaired migration history from this branch'); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 756fc61..9fac0f6 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -11,6 +11,7 @@ import { registerHealthRoute } from './routes/health.js'; import { registerLoreRoutes } from './routes/lore.js'; import { registerProposalRoutes } from './routes/proposals.js'; import { registerProfileRoutes } from './routes/profiles.js'; +import { registerSearchRoutes } from './routes/search.js'; import { registerWorldRoutes } from './routes/worlds.js'; export async function buildApp() { @@ -54,6 +55,7 @@ export async function buildApp() { await registerContributionRoutes(app); await registerProposalRoutes(app); await registerProfileRoutes(app); + await registerSearchRoutes(app); return app; } diff --git a/apps/api/src/lib/proposal-consistency.test.ts b/apps/api/src/lib/proposal-consistency.test.ts new file mode 100644 index 0000000..9a77250 --- /dev/null +++ b/apps/api/src/lib/proposal-consistency.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { AIReportStatus } from '../generated/prisma/enums.js'; +import { runProposalConsistencyCheck } from './proposal-consistency.js'; + +function createDatabase() { + const reports: unknown[] = []; + const proposal = { + id: 'proposal-1', + proposedContent: { + body: 'The moon gate is open.', + }, + title: 'Moon Gate Reopens', + worldId: 'world-1', + }; + const bibleVersion = { + content: { + rule: 'The moon gate is not open.', + }, + id: 'bible-1', + versionNumber: 2, + }; + const loreEntries = [ + { + content: { + body: 'A published canon note.', + }, + id: 'lore-1', + title: 'Canon Note', + }, + ]; + const database = { + aIReport: { + async create(args: { data: Record }) { + const report = { + createdAt: new Date('2026-09-02T12:00:00.000Z'), + errorCode: null, + errorMessage: null, + id: `report-${reports.length + 1}`, + updatedAt: new Date('2026-09-02T12:00:00.000Z'), + ...args.data, + }; + reports.push(report); + return report; + }, + }, + loreEntry: { + async findMany() { + return loreEntries; + }, + }, + proposal: { + async findFirst(args: { where: { id: string; worldId: string } }) { + if (args.where.id !== proposal.id || args.where.worldId !== proposal.worldId) { + return null; + } + + return proposal; + }, + }, + worldBibleVersion: { + async findFirst() { + return bibleVersion; + }, + }, + $transaction(callback: (transaction: unknown) => Promise) { + return callback(database); + }, + }; + + return { + database, + reports, + }; +} + +describe('proposal consistency checks', () => { + test('creates a completed AI report with a visible major warning when proposed canon conflicts', async () => { + const state = createDatabase(); + + const result = await runProposalConsistencyCheck(state.database as never, { + proposalId: 'proposal-1', + worldId: 'world-1', + }); + + assert.equal(result.warningCount, 1); + assert.equal(result.aiReport.status, AIReportStatus.COMPLETED); + assert.equal(result.aiReport.provider, 'hivelore'); + assert.equal( + ( + (state.reports[0] as { findings: { findings: Array<{ severity: string }> } }).findings + .findings[0] as { severity: string } + ).severity, + 'high', + ); + }); + + test('stores a no-warning report when the proposal does not contradict canon', async () => { + const state = createDatabase(); + (state.database.proposal.findFirst as unknown as () => Promise) = async () => ({ + id: 'proposal-1', + proposedContent: { + body: 'The archive remains closed.', + }, + title: 'Archive Remains Closed', + worldId: 'world-1', + }); + + const result = await runProposalConsistencyCheck(state.database as never, { + proposalId: 'proposal-1', + worldId: 'world-1', + }); + + assert.equal(result.warningCount, 0); + assert.equal(result.aiReport.summary, 'No major canon consistency conflicts found.'); + }); +}); diff --git a/apps/api/src/lib/proposal-consistency.ts b/apps/api/src/lib/proposal-consistency.ts new file mode 100644 index 0000000..cfc36c4 --- /dev/null +++ b/apps/api/src/lib/proposal-consistency.ts @@ -0,0 +1,171 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { AIReportStatus, LoreStatus } from '../generated/prisma/enums.js'; +import { flattenSearchText } from './search-index.js'; + +export class ProposalConsistencyError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string, + ) { + super(message); + } +} + +export type ProposalConsistencyDatabase = Pick< + PrismaClient, + '$transaction' | 'aIReport' | 'loreEntry' | 'proposal' | 'worldBibleVersion' +>; + +const NEGATION_PATTERNS = [ + /\b(?:no|not|never|cannot|can't|wont|won't|without)\b\s+(?:be\s+|have\s+|has\s+|is\s+|are\s+)?([a-z][a-z0-9 -]{2,80})/gi, + /\b([a-z][a-z0-9 -]{2,80})\s+(?:is|are|was|were|has|have)\s+(?:no|not|never)\b/gi, +]; + +function normalizeFragment(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9 ]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function collectNegatedClaims(text: string) { + const claims = new Set(); + + for (const pattern of NEGATION_PATTERNS) { + for (const match of text.matchAll(pattern)) { + const claim = normalizeFragment(match[1] ?? ''); + + if (claim.length >= 3) { + claims.add(claim); + } + } + } + + return claims; +} + +function detectContradictions(input: { canonText: string; proposedText: string }) { + const canonText = normalizeFragment(input.canonText); + const proposedText = normalizeFragment(input.proposedText); + const canonNegations = collectNegatedClaims(canonText); + const proposedNegations = collectNegatedClaims(proposedText); + const findings: Array> = []; + + for (const claim of canonNegations) { + if (proposedText.includes(claim) && !proposedNegations.has(claim)) { + findings.push({ + category: 'canon_consistency', + evidence: claim, + severity: 'high', + summary: `Proposed content appears to assert "${claim}" even though existing canon negates it.`, + }); + } + } + + for (const claim of proposedNegations) { + if (canonText.includes(claim) && !canonNegations.has(claim)) { + findings.push({ + category: 'canon_consistency', + evidence: claim, + severity: 'high', + summary: `Proposed content appears to negate "${claim}" even though existing canon asserts it.`, + }); + } + } + + return findings; +} + +export async function runProposalConsistencyCheck( + database: ProposalConsistencyDatabase, + input: { proposalId: string; worldId: string }, +) { + return database.$transaction(async (transaction) => { + const proposal = await transaction.proposal.findFirst({ + select: { + id: true, + proposedContent: true, + title: true, + worldId: true, + }, + where: { + id: input.proposalId, + worldId: input.worldId, + }, + }); + + if (!proposal) { + throw new ProposalConsistencyError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.'); + } + + const [currentBible, canonEntries] = await Promise.all([ + transaction.worldBibleVersion.findFirst({ + orderBy: { + versionNumber: 'desc', + }, + select: { + content: true, + id: true, + versionNumber: true, + }, + where: { + worldId: input.worldId, + }, + }), + transaction.loreEntry.findMany({ + select: { + content: true, + id: true, + title: true, + }, + take: 50, + where: { + status: LoreStatus.PUBLISHED_CANON, + worldId: input.worldId, + }, + }), + ]); + + const proposedText = [proposal.title, flattenSearchText(proposal.proposedContent)].join(' '); + const canonText = [ + flattenSearchText(currentBible?.content), + ...canonEntries.map((entry) => `${entry.title} ${flattenSearchText(entry.content)}`), + ].join(' '); + const findings = detectContradictions({ + canonText, + proposedText, + }); + const hasWarning = findings.length > 0; + const report = await transaction.aIReport.create({ + data: { + findings: { + checkedCanonEntries: canonEntries.length, + currentBibleVersionId: currentBible?.id ?? null, + currentBibleVersionNumber: currentBible?.versionNumber ?? null, + findings, + } satisfies Prisma.InputJsonObject, + model: 'deterministic-canon-consistency-v1', + proposalId: proposal.id, + provider: 'hivelore', + status: AIReportStatus.COMPLETED, + summary: hasWarning + ? `${findings.length} possible canon consistency conflict${findings.length === 1 ? '' : 's'} found.` + : 'No major canon consistency conflicts found.', + }, + }); + + return { + aiReport: { + findings: report.findings, + id: report.id, + model: report.model, + provider: report.provider, + status: report.status, + summary: report.summary, + }, + warningCount: findings.length, + }; + }); +} diff --git a/apps/api/src/lib/search-index.test.ts b/apps/api/src/lib/search-index.test.ts new file mode 100644 index 0000000..710c86f --- /dev/null +++ b/apps/api/src/lib/search-index.test.ts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { LoreStatus, LoreType, SearchEntityType } from '../generated/prisma/enums.js'; +import { + flattenSearchText, + rebuildSearchIndex, + searchWorldLore, + upsertLoreSearchIndex, + upsertWorldSearchIndex, +} from './search-index.js'; + +function createDatabase() { + const searchRows: Array> = []; + const worlds = [ + { + bibleVersions: [ + { + content: { + law: 'The obsidian tower remembers every oath.', + }, + versionNumber: 1, + }, + ], + description: 'A moonlit city of archives.', + id: 'world-1', + slug: 'moon-archive', + title: 'Moon Archive', + }, + ]; + const loreEntries = [ + { + content: { + body: 'Archivist Sera guards the obsidian tower.', + }, + id: 'lore-1', + loreType: LoreType.CHARACTER, + slug: 'sera', + status: LoreStatus.PUBLISHED_CANON, + title: 'Sera', + worldId: 'world-1', + }, + { + content: { + body: 'Private draft', + }, + id: 'draft-1', + loreType: LoreType.CHARACTER, + slug: 'draft', + status: LoreStatus.DRAFT, + title: 'Draft', + worldId: 'world-1', + }, + ]; + const database = { + async $queryRaw() { + return [ + { + entityId: 'lore-1', + entityType: SearchEntityType.LORE_ENTRY, + metadata: { loreType: LoreType.CHARACTER }, + rank: 0.4, + title: 'Sera', + worldId: 'world-1', + }, + ]; + }, + async $transaction(callback: (transaction: unknown) => Promise) { + return callback(database); + }, + loreEntry: { + async findMany(args?: { where?: { status?: LoreStatus } }) { + return args?.where?.status + ? loreEntries.filter((entry) => entry.status === args.where?.status) + : loreEntries; + }, + async findUnique(args: { where: { id: string } }) { + return loreEntries.find((entry) => entry.id === args.where.id) ?? null; + }, + }, + searchIndex: { + async deleteMany(args: { where?: { entityId?: string; entityType?: string } } = {}) { + const before = searchRows.length; + + for (let index = searchRows.length - 1; index >= 0; index -= 1) { + const row = searchRows[index]!; + if ( + (!args.where?.entityId || row.entityId === args.where.entityId) && + (!args.where?.entityType || row.entityType === args.where.entityType) + ) { + searchRows.splice(index, 1); + } + } + + return { count: before - searchRows.length }; + }, + async upsert(args: { + create: Record; + update: Record; + where: { entityType_entityId: { entityId: string; entityType: string } }; + }) { + const existing = searchRows.find( + (row) => + row.entityId === args.where.entityType_entityId.entityId && + row.entityType === args.where.entityType_entityId.entityType, + ); + + if (existing) { + Object.assign(existing, args.update); + return existing; + } + + searchRows.push(args.create); + return args.create; + }, + }, + world: { + async findMany() { + return worlds.map((world) => ({ id: world.id })); + }, + async findUnique(args: { where: { id: string } }) { + return worlds.find((world) => world.id === args.where.id) ?? null; + }, + }, + worldBibleVersion: {}, + }; + + return { + database, + searchRows, + }; +} + +describe('search index projection', () => { + test('flattens structured content into searchable text', () => { + assert.equal( + flattenSearchText({ body: ['Moon', { place: 'Archive' }], ok: true }), + 'Moon Archive true', + ); + }); + + test('upserts worlds and only published canon lore entries', async () => { + const state = createDatabase(); + + await upsertWorldSearchIndex(state.database as never, 'world-1'); + await upsertLoreSearchIndex(state.database as never, 'lore-1'); + await upsertLoreSearchIndex(state.database as never, 'draft-1'); + + assert.deepEqual( + state.searchRows.map((row) => [row.entityType, row.entityId]), + [ + [SearchEntityType.WORLD, 'world-1'], + [SearchEntityType.LORE_ENTRY, 'lore-1'], + ], + ); + assert.match(String(state.searchRows[0]?.searchableContent), /obsidian tower/); + }); + + test('rebuilds all searchable projections and returns counts', async () => { + const state = createDatabase(); + + const result = await rebuildSearchIndex(state.database as never); + + assert.deepEqual(result, { loreEntries: 1, worlds: 1 }); + assert.equal(state.searchRows.length, 2); + }); + + test('runs ranked search with pagination metadata', async () => { + const state = createDatabase(); + + const result = await searchWorldLore(state.database as never, { + page: 1, + pageSize: 10, + q: 'obsidian tower', + type: SearchEntityType.LORE_ENTRY, + worldId: 'world-1', + }); + + assert.equal(result.results[0]?.entityId, 'lore-1'); + assert.deepEqual(result.pageInfo, { + hasMore: false, + page: 1, + pageSize: 10, + }); + }); +}); diff --git a/apps/api/src/lib/search-index.ts b/apps/api/src/lib/search-index.ts new file mode 100644 index 0000000..7c2f696 --- /dev/null +++ b/apps/api/src/lib/search-index.ts @@ -0,0 +1,259 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { LoreStatus, SearchEntityType } from '../generated/prisma/enums.js'; + +const MAX_QUERY_LENGTH = 120; +const MAX_PAGE_SIZE = 50; + +type SearchDatabase = Pick< + PrismaClient, + '$queryRaw' | '$transaction' | 'loreEntry' | 'searchIndex' | 'world' | 'worldBibleVersion' +>; + +type SearchIndexWriter = Pick< + PrismaClient, + 'loreEntry' | 'searchIndex' | 'world' | 'worldBibleVersion' +>; + +export type SearchQueryInput = { + page?: number | undefined; + pageSize?: number | undefined; + q: string; + type?: SearchEntityType | undefined; + worldId?: string | undefined; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function flattenSearchText(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + if (Array.isArray(value)) { + return value.map(flattenSearchText).filter(Boolean).join(' '); + } + + if (isRecord(value)) { + return Object.values(value).map(flattenSearchText).filter(Boolean).join(' '); + } + + return ''; +} + +function cleanQuery(q: string) { + return q.trim().replace(/\s+/g, ' '); +} + +function cleanPageSize(pageSize = 20) { + return Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE); +} + +function cleanPage(page = 1) { + return Math.max(page, 1); +} + +export async function upsertWorldSearchIndex(database: SearchIndexWriter, worldId: string) { + const world = await database.world.findUnique({ + include: { + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + take: 1, + }, + }, + where: { + id: worldId, + }, + }); + + if (!world) { + await database.searchIndex.deleteMany({ + where: { + entityId: worldId, + entityType: SearchEntityType.WORLD, + }, + }); + return; + } + + const currentBible = world.bibleVersions[0]; + await database.searchIndex.upsert({ + create: { + entityId: world.id, + entityType: SearchEntityType.WORLD, + metadata: { + slug: world.slug, + }, + searchableContent: [world.description, flattenSearchText(currentBible?.content)] + .filter(Boolean) + .join(' '), + title: world.title, + worldId: world.id, + }, + update: { + metadata: { + slug: world.slug, + }, + searchableContent: [world.description, flattenSearchText(currentBible?.content)] + .filter(Boolean) + .join(' '), + title: world.title, + worldId: world.id, + }, + where: { + entityType_entityId: { + entityId: world.id, + entityType: SearchEntityType.WORLD, + }, + }, + }); +} + +export async function upsertLoreSearchIndex(database: SearchIndexWriter, loreEntryId: string) { + const entry = await database.loreEntry.findUnique({ + select: { + content: true, + id: true, + loreType: true, + slug: true, + status: true, + title: true, + worldId: true, + }, + where: { + id: loreEntryId, + }, + }); + + if (!entry || entry.status !== LoreStatus.PUBLISHED_CANON) { + await database.searchIndex.deleteMany({ + where: { + entityId: loreEntryId, + entityType: SearchEntityType.LORE_ENTRY, + }, + }); + return; + } + + await database.searchIndex.upsert({ + create: { + entityId: entry.id, + entityType: SearchEntityType.LORE_ENTRY, + metadata: { + loreType: entry.loreType, + slug: entry.slug, + }, + searchableContent: flattenSearchText(entry.content), + title: entry.title, + worldId: entry.worldId, + }, + update: { + metadata: { + loreType: entry.loreType, + slug: entry.slug, + }, + searchableContent: flattenSearchText(entry.content), + title: entry.title, + worldId: entry.worldId, + }, + where: { + entityType_entityId: { + entityId: entry.id, + entityType: SearchEntityType.LORE_ENTRY, + }, + }, + }); +} + +export async function rebuildSearchIndex(database: SearchDatabase) { + return database.$transaction(async (transaction) => { + await transaction.searchIndex.deleteMany({}); + + const worlds = await transaction.world.findMany({ + select: { + id: true, + }, + }); + const loreEntries = await transaction.loreEntry.findMany({ + select: { + id: true, + }, + where: { + status: LoreStatus.PUBLISHED_CANON, + }, + }); + + for (const world of worlds) { + await upsertWorldSearchIndex(transaction, world.id); + } + + for (const entry of loreEntries) { + await upsertLoreSearchIndex(transaction, entry.id); + } + + return { + loreEntries: loreEntries.length, + worlds: worlds.length, + }; + }); +} + +export async function searchWorldLore(database: SearchDatabase, input: SearchQueryInput) { + const q = cleanQuery(input.q); + + if (!q || q.length > MAX_QUERY_LENGTH) { + throw new Error('Search query must be between 1 and 120 characters.'); + } + + const page = cleanPage(input.page); + const pageSize = cleanPageSize(input.pageSize); + const offset = (page - 1) * pageSize; + const rows = await database.$queryRaw< + Array<{ + entityId: string; + entityType: SearchEntityType; + metadata: Prisma.JsonValue | null; + rank: number; + title: string; + worldId: string | null; + }> + >` + SELECT + "entityId", + "entityType", + "metadata", + ts_rank("searchVector", websearch_to_tsquery('english', ${q}))::float AS rank, + "title", + "worldId" + FROM "SearchIndex" + WHERE "searchVector" @@ websearch_to_tsquery('english', ${q}) + AND (${input.worldId ?? null}::text IS NULL OR "worldId" = ${input.worldId ?? null}) + AND (${input.type ?? null}::"SearchEntityType" IS NULL OR "entityType" = ${input.type ?? null}::"SearchEntityType") + ORDER BY rank DESC, "updatedAt" DESC, "id" DESC + LIMIT ${pageSize} + OFFSET ${offset} + `; + + return { + pageInfo: { + hasMore: rows.length === pageSize, + page, + pageSize, + }, + results: rows.map((row) => ({ + entityId: row.entityId, + entityType: row.entityType, + metadata: row.metadata, + rank: row.rank, + title: row.title, + worldId: row.worldId, + })), + }; +} diff --git a/apps/api/src/lib/worlds.ts b/apps/api/src/lib/worlds.ts index 685e758..77c8f9d 100644 --- a/apps/api/src/lib/worlds.ts +++ b/apps/api/src/lib/worlds.ts @@ -6,6 +6,7 @@ import { WorldRole, } from '../generated/prisma/enums.js'; import { mapWorldSlugConflict } from './prisma-conflicts.js'; +import { upsertWorldSearchIndex } from './search-index.js'; export type WorldSeedInput = { premise: string; @@ -46,6 +47,7 @@ export type WorldDatabase = Pick< | 'loreEntry' | 'proposal' | 'refreshSession' + | 'searchIndex' | 'world' | 'worldAuditLog' | 'worldBibleVersion' @@ -298,6 +300,8 @@ export async function createWorld(database: WorldDatabase, input: CreateWorldInp }, }); + await upsertWorldSearchIndex(transaction, createdWorld.id); + return transaction.world.findUniqueOrThrow({ include: worldInclude, where: { @@ -579,6 +583,8 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp }); } + await upsertWorldSearchIndex(transaction, input.worldId); + return transaction.world.findUniqueOrThrow({ include: worldInclude, where: { diff --git a/apps/api/src/routes/lore.test.ts b/apps/api/src/routes/lore.test.ts index 9158338..4180f1c 100644 --- a/apps/api/src/routes/lore.test.ts +++ b/apps/api/src/routes/lore.test.ts @@ -104,6 +104,7 @@ function createDatabase() { const users = [author, otherUser, curator]; const loreEntries: StoredLoreEntry[] = []; const loreRelationships: StoredLoreRelationship[] = []; + const searchIndexes: unknown[] = []; const worldAuditLogs: unknown[] = []; const memberships = [ { @@ -214,6 +215,10 @@ function createDatabase() { const entry = loreEntries.find((candidate) => matchesWhere(candidate, args.where)) ?? null; return entry ? includeLore(entry) : null; }, + async findUnique(args: { where: { id: string } }) { + const entry = loreEntries.find((candidate) => candidate.id === args.where.id) ?? null; + return entry ? includeLore(entry) : null; + }, async findMany(args: { skip: number; take: number; where: Record }) { return loreEntries .filter((entry) => matchesWhere(entry, args.where)) @@ -352,6 +357,44 @@ function createDatabase() { }; }, }, + searchIndex: { + async deleteMany(args: { where?: { entityId?: string; entityType?: string } } = {}) { + const before = searchIndexes.length; + + for (let index = searchIndexes.length - 1; index >= 0; index -= 1) { + const row = searchIndexes[index] as { entityId?: string; entityType?: string }; + const matchesEntity = !args.where?.entityId || row.entityId === args.where.entityId; + const matchesType = !args.where?.entityType || row.entityType === args.where.entityType; + + if (matchesEntity && matchesType) { + searchIndexes.splice(index, 1); + } + } + + return { count: before - searchIndexes.length }; + }, + async upsert(args: { + create: { entityId: string; entityType: string }; + update: Record; + where: { entityType_entityId: { entityId: string; entityType: string } }; + }) { + const existing = searchIndexes.find( + (row) => + (row as { entityId: string; entityType: string }).entityId === + args.where.entityType_entityId.entityId && + (row as { entityId: string; entityType: string }).entityType === + args.where.entityType_entityId.entityType, + ); + + if (existing) { + Object.assign(existing as Record, args.update); + return existing; + } + + searchIndexes.push(args.create); + return args.create; + }, + }, refreshSession: { async findUnique(args: { where: { id: string } }) { const userId = args.where.id.replace(/^session-/, ''); diff --git a/apps/api/src/routes/lore.ts b/apps/api/src/routes/lore.ts index 8163dba..2ba88d0 100644 --- a/apps/api/src/routes/lore.ts +++ b/apps/api/src/routes/lore.ts @@ -8,6 +8,7 @@ import { LoreStatus, LoreType, WorldAuditAction } from '../generated/prisma/enum import { authenticateRequest, requireSession } from '../lib/auth-middleware.js'; import type { SessionVerificationDatabase } from '../lib/auth-sessions.js'; import { prisma } from '../lib/prisma.js'; +import { upsertLoreSearchIndex } from '../lib/search-index.js'; import { authorizeWorldPermission, resolveWorldMembership, @@ -46,7 +47,13 @@ const CONTENT_ENTITY_TYPE_BY_LORE_TYPE: Record = { type LoreDatabase = Pick< PrismaClient, - '$transaction' | 'loreEntry' | 'loreRelationship' | 'world' | 'worldAuditLog' | 'worldMembership' + | '$transaction' + | 'loreEntry' + | 'loreRelationship' + | 'searchIndex' + | 'world' + | 'worldAuditLog' + | 'worldMembership' >; type RegisterLoreRoutesOptions = { @@ -1337,6 +1344,8 @@ export async function registerLoreRoutes( worldId: params.data.worldId, }); + await upsertLoreSearchIndex(transaction, created.id); + return created; }); @@ -1488,6 +1497,8 @@ export async function registerLoreRoutes( worldId: params.data.worldId, }); + await upsertLoreSearchIndex(transaction, updated.id); + return updated; }); @@ -1600,6 +1611,13 @@ export async function registerLoreRoutes( }, }); + await transaction.searchIndex.deleteMany({ + where: { + entityId: existing.id, + entityType: 'LORE_ENTRY', + }, + }); + await createLoreAuditLog(transaction, { action: WorldAuditAction.LORE_ENTRY_DELETED, actorId: request.user!.id, diff --git a/apps/api/src/routes/proposals.ts b/apps/api/src/routes/proposals.ts index 407651a..fdacb82 100644 --- a/apps/api/src/routes/proposals.ts +++ b/apps/api/src/routes/proposals.ts @@ -21,6 +21,10 @@ import { ProposalCommentError, PROPOSAL_COMMENT_MAX_LENGTH, } from '../lib/proposal-comments.js'; +import { + ProposalConsistencyError, + runProposalConsistencyCheck, +} from '../lib/proposal-consistency.js'; import { authorizeWorldPermission } from '../lib/world-authorization.js'; import type { WorldMembershipLookup } from '../lib/world-authorization.js'; import { roleHasWorldPermission, WORLD_PERMISSIONS } from '../lib/world-permissions.js'; @@ -122,6 +126,17 @@ function handleProposalCommentError(error: unknown, reply: FastifyReply) { throw error; } +function handleProposalConsistencyError(error: unknown, reply: FastifyReply) { + if (error instanceof ProposalConsistencyError) { + return reply.code(error.statusCode).send({ + code: error.code, + error: error.message, + }); + } + + throw error; +} + function proposalCommentRateLimitOptions(database: typeof prisma) { return { cache: env.PROPOSAL_COMMENT_WRITE_RATE_LIMIT_CACHE, @@ -251,6 +266,41 @@ export async function registerProposalRoutes( } }); + app.post( + '/worlds/:worldId/proposals/:proposalId/ai-consistency', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + code: 'INVALID_PROPOSAL_ROUTE', + error: 'Invalid proposal route.', + }); + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.VOTE_ON_PROPOSAL, + database, + ); + + if (!authorized) { + return; + } + + try { + return await runProposalConsistencyCheck(database, params.data); + } catch (error) { + return handleProposalConsistencyError(error, reply); + } + }, + ); + app.get('/worlds/:worldId/proposals/:proposalId/comments', async (request, reply) => { const params = paramsSchema.safeParse(request.params); const query = commentsQuerySchema.safeParse(request.query); diff --git a/apps/api/src/routes/search.ts b/apps/api/src/routes/search.ts new file mode 100644 index 0000000..6196174 --- /dev/null +++ b/apps/api/src/routes/search.ts @@ -0,0 +1,45 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { SearchEntityType } from '../generated/prisma/enums.js'; +import { prisma } from '../lib/prisma.js'; +import { searchWorldLore } from '../lib/search-index.js'; + +const searchQuerySchema = z.object({ + page: z.coerce.number().int().positive().optional(), + pageSize: z.coerce.number().int().positive().max(50).optional(), + q: z.string().trim().min(1).max(120), + type: z.enum([SearchEntityType.WORLD, SearchEntityType.LORE_ENTRY]).optional(), + worldId: z.string().trim().min(1).optional(), +}); + +type RegisterSearchRoutesOptions = { + database?: typeof prisma; +}; + +export async function registerSearchRoutes( + app: FastifyInstance, + options: RegisterSearchRoutesOptions = {}, +) { + const database = options.database ?? prisma; + + app.get('/search', async (request, reply) => { + const query = searchQuerySchema.safeParse(request.query); + + if (!query.success) { + return reply.code(400).send({ + code: 'INVALID_SEARCH_QUERY', + error: 'Invalid search query.', + }); + } + + try { + return await searchWorldLore(database, query.data); + } catch (error) { + return reply.code(400).send({ + code: 'INVALID_SEARCH_QUERY', + error: error instanceof Error ? error.message : 'Invalid search query.', + }); + } + }); +} diff --git a/apps/api/src/routes/worlds.test.ts b/apps/api/src/routes/worlds.test.ts index c2630b4..e28f5f6 100644 --- a/apps/api/src/routes/worlds.test.ts +++ b/apps/api/src/routes/worlds.test.ts @@ -115,6 +115,7 @@ function createDatabase() { const worlds: StoredWorld[] = []; const seeds: StoredWorldSeed[] = []; const bibleVersions: StoredBibleVersion[] = []; + const searchIndexes: unknown[] = []; const memberships: Array<{ id: string; worldId: string; @@ -286,6 +287,44 @@ function createDatabase() { }; }, }, + searchIndex: { + async deleteMany(args: { where?: { entityId?: string; entityType?: string } } = {}) { + const before = searchIndexes.length; + + for (let index = searchIndexes.length - 1; index >= 0; index -= 1) { + const row = searchIndexes[index] as { entityId?: string; entityType?: string }; + const matchesEntity = !args.where?.entityId || row.entityId === args.where.entityId; + const matchesType = !args.where?.entityType || row.entityType === args.where.entityType; + + if (matchesEntity && matchesType) { + searchIndexes.splice(index, 1); + } + } + + return { count: before - searchIndexes.length }; + }, + async upsert(args: { + create: { entityId: string; entityType: string }; + update: Record; + where: { entityType_entityId: { entityId: string; entityType: string } }; + }) { + const existing = searchIndexes.find( + (row) => + (row as { entityId: string; entityType: string }).entityId === + args.where.entityType_entityId.entityId && + (row as { entityId: string; entityType: string }).entityType === + args.where.entityType_entityId.entityType, + ); + + if (existing) { + Object.assign(existing as Record, args.update); + return existing; + } + + searchIndexes.push(args.create); + return args.create; + }, + }, world: { async count(args?: { where?: Parameters[1] }) { return worlds.filter((world) => matchesWorldWhere(world, args?.where)).length; diff --git a/apps/api/src/scripts/reindex-search.ts b/apps/api/src/scripts/reindex-search.ts new file mode 100644 index 0000000..7c4a09d --- /dev/null +++ b/apps/api/src/scripts/reindex-search.ts @@ -0,0 +1,10 @@ +import { prisma } from '../lib/prisma.js'; +import { rebuildSearchIndex } from '../lib/search-index.js'; + +const result = await rebuildSearchIndex(prisma); + +console.log( + `Rebuilt search index for ${result.worlds} worlds and ${result.loreEntries} canon lore entries.`, +); + +await prisma.$disconnect(); diff --git a/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx index 1204daa..165afd9 100644 --- a/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx +++ b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx @@ -25,6 +25,7 @@ import { getProposal, getProposalComments, PROPOSAL_COMMENT_MAX_LENGTH, + runProposalConsistencyCheck, type ProposalCommentsResponse, type ProposalDetail, type VoteChoice, @@ -331,6 +332,33 @@ export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; w }, }); + const consistencyMutation = useMutation({ + mutationFn: () => + runProposalConsistencyCheck({ + accessToken: accessToken ?? '', + proposalId, + worldId, + }), + onSuccess: () => { + void invalidateProposal(); + }, + }); + + useEffect(() => { + if ( + !accessToken || + !proposal || + proposal.status !== 'VOTING' || + proposal.aiWarning.acknowledgmentRequired || + consistencyMutation.isPending || + consistencyMutation.isSuccess + ) { + return; + } + + consistencyMutation.mutate(); + }, [accessToken, consistencyMutation, proposal, proposalId, worldId]); + const signMutation = useMutation({ mutationFn: async () => { const operation = await createCanonTransaction({ @@ -499,6 +527,15 @@ export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; w ) : null} + {consistencyMutation.isPending ? ( + + Checking canon consistency + + Comparing the proposal with the World Bible and published canon before voting. + + + ) : null} +
diff --git a/apps/web/src/lib/api/proposals.ts b/apps/web/src/lib/api/proposals.ts index 7b72597..29ae2ef 100644 --- a/apps/web/src/lib/api/proposals.ts +++ b/apps/web/src/lib/api/proposals.ts @@ -205,6 +205,20 @@ export function acknowledgeProposalAiWarning(input: { ); } +export function runProposalConsistencyCheck(input: { + accessToken: string; + proposalId: string; + worldId: string; +}) { + return apiClient.post<{ aiReport: unknown; warningCount: number }>( + `/worlds/${input.worldId}/proposals/${input.proposalId}/ai-consistency`, + {}, + { + headers: authHeaders(input.accessToken), + }, + ); +} + export function finalizeProposal(input: { accessToken: string; proposalId: string; From 69b87d8c7ba58fa31e3ccaea34f0db2196c4aacd Mon Sep 17 00:00:00 2001 From: Kareem Naous <62202089+KareemN1936@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:06:13 +0300 Subject: [PATCH 2/2] fix(api): model search vector in prisma schema --- .../migration.sql | 27 ++++++++++++++++--- apps/api/prisma/schema.prisma | 10 ++++--- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/api/prisma/migrations/20260902124000_add_search_vector_index/migration.sql b/apps/api/prisma/migrations/20260902124000_add_search_vector_index/migration.sql index df52a8f..9c173dd 100644 --- a/apps/api/prisma/migrations/20260902124000_add_search_vector_index/migration.sql +++ b/apps/api/prisma/migrations/20260902124000_add_search_vector_index/migration.sql @@ -1,8 +1,27 @@ ALTER TABLE "SearchIndex" -ADD COLUMN "searchVector" tsvector -GENERATED ALWAYS AS ( +ADD COLUMN "searchVector" tsvector; + +CREATE FUNCTION update_search_index_vector() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW."searchVector" := + setweight(to_tsvector('english', coalesce(NEW."title", '')), 'A') || + setweight(to_tsvector('english', coalesce(NEW."searchableContent", '')), 'B'); + RETURN NEW; +END; +$$; + +CREATE TRIGGER "SearchIndex_searchVector_update" +BEFORE INSERT OR UPDATE OF "title", "searchableContent" +ON "SearchIndex" +FOR EACH ROW +EXECUTE FUNCTION update_search_index_vector(); + +UPDATE "SearchIndex" +SET "searchVector" = setweight(to_tsvector('english', coalesce("title", '')), 'A') || - setweight(to_tsvector('english', coalesce("searchableContent", '')), 'B') -) STORED; + setweight(to_tsvector('english', coalesce("searchableContent", '')), 'B'); CREATE INDEX "SearchIndex_searchVector_idx" ON "SearchIndex" USING GIN ("searchVector"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index af045b8..7beab31 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -822,18 +822,20 @@ model UserReputationSnapshot { } model SearchIndex { - id String @id @default(cuid()) + id String @id @default(cuid()) worldId String? - world World? @relation(fields: [worldId], references: [id], onDelete: Cascade) + world World? @relation(fields: [worldId], references: [id], onDelete: Cascade) entityType SearchEntityType entityId String title String searchableContent String + searchVector Unsupported("tsvector")? metadata Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt /// Derived PostgreSQL search projection for the MVP; rebuildable from source records. @@unique([entityType, entityId]) @@index([worldId, entityType]) + @@index([searchVector], type: Gin) }