Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
ALTER TABLE "SearchIndex"
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');

CREATE INDEX "SearchIndex_searchVector_idx" ON "SearchIndex" USING GIN ("searchVector");
10 changes: 6 additions & 4 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
5 changes: 5 additions & 0 deletions apps/api/scripts/check-upgrade-prisma-migrations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -54,6 +55,7 @@ export async function buildApp() {
await registerContributionRoutes(app);
await registerProposalRoutes(app);
await registerProfileRoutes(app);
await registerSearchRoutes(app);

return app;
}
118 changes: 118 additions & 0 deletions apps/api/src/lib/proposal-consistency.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }) {
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<T>(callback: (transaction: unknown) => Promise<T>) {
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<unknown>) = 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.');
});
});
171 changes: 171 additions & 0 deletions apps/api/src/lib/proposal-consistency.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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<Record<string, string>> = [];

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,
};
});
}
Loading
Loading