Files
Socrates/apps/web/lib/db/repo.ts
dtoro 4e725c0b2b MVP M8: background detection — assumptions, risks, inconsistencies
The editor now runs Socrates' three Phase-0-validated detection prompts
against the live model, persists the findings, and surfaces them in a
new FindingsPanel beside the IssuesPanel. Click any finding to focus its
linked element across rail + diagram. Re-detect after model edits to
refresh against the new state.

apps/web/lib/llm/prompts/socrates
- detect-assumptions.md / detect-risks.md / detect-inconsistencies.md
  promoted verbatim from phase-0 (Phase 0 corpus validated them 10/10).

apps/web/lib/llm/detect.ts
- Three sequential detection passes (parallel was OOM-prone on 4B local
  models — Phase 0 lesson). Each pass uses the Phase 0 JSON schema with
  jsonObjectMode fallback + chatJSON repair-retry. Fail-soft per pass:
  one busted pass returns [] rather than blowing up the whole detect.
- post-validate strips hallucinated element refs (drops findings whose
  refs ALL fail to resolve; keeps findings with zero refs since some
  inconsistencies are genuinely about absences).

apps/web/prisma/schema.prisma
- Finding table: kind / text / linkedElementIds (JSON) / confidence /
  severity / validationCode / status / modelVersion / provider / model.
- ResearchFinding table reserved for the Tavily integration that comes
  next — schema in place so we don't have to migrate again.

apps/web/lib/db/repo.ts
- listOpenFindings(projectId), replaceFindings(...) — replaceFindings
  wipes prior open findings in a transaction and writes the new set so
  re-detect doesn't accumulate stale findings.

apps/web/app/api/projects/[projectId]/findings/route.ts
- GET returns persisted open findings.
- POST runs detect, persists, returns findings + meta (provider, model,
  durationMs, strippedRefs, droppedFindings).

apps/web/components/editor/FindingsPanel.tsx
- New panel, anchored bottom-right just left of IssuesPanel. Shows
  count summary (asm / risk / inc), detect / re-detect button, list
  grouped by kind (inconsistencies first, then risks, then assumptions),
  per-finding glyph + tag + severity + confidence + linked refs.
- Click a finding row → focus its first linked element via the same
  setFocusBlockId path the rail and IssuesPanel already use.
- "stale" indicator when the model version has advanced past the one the
  findings were detected against.

EditorShell wires version + projectId through to FindingsPanel.

Smoke-tested end-to-end: 12 findings returned (5 asm / 4 risk / 3 inc),
0 hallucinated refs stripped, ~37s on local gemma-4-e4b. Sample
assumption "students are willing to engage with an AI tutor that is
programmed to refuse providing complete solutions" — specific to
Aristotle's refusal_policy, not a generic startup truism.

Deferred to follow-ups: inline rail/diagram badges from findings,
auto-detect-on-save, Tavily research, experiment modal.
2026-04-30 00:43:27 +02:00

279 lines
8.1 KiB
TypeScript

// Project + model persistence layer.
//
// Stores the full SysMLModel as a JSON snapshot per version, plus a changelog
// of applied ops. Reads return the latest snapshot; writes apply ops
// server-side and write a new snapshot + changelog row atomically.
import { prisma } from "./client";
import { applyOps } from "../sync/applyOps";
import { fromFixture } from "../sysml/fromFixture";
import { aristotleFixture } from "../fixtures/aristotle";
import type { SysMLModel } from "../sysml/model";
import type { ModelOp } from "../sync/ops";
export interface LoadResult {
model: SysMLModel;
version: number;
}
export async function loadProject(projectId: string): Promise<LoadResult> {
// Auto-seed: if the project doesn't exist (or has no snapshot), bootstrap
// it from the Aristotle fixture so the editor route always has data.
const seeded = await ensureSeeded(projectId);
const snapshot = await prisma.modelSnapshot.findFirst({
where: { projectId: seeded.projectId },
orderBy: { version: "desc" },
});
if (!snapshot) {
throw new Error(`Project ${projectId} has no snapshots after seed — should not happen`);
}
return {
model: JSON.parse(snapshot.json) as SysMLModel,
version: snapshot.version,
};
}
export interface ApplyResult {
applied: boolean;
model: SysMLModel;
version: number;
idMapping: Record<string, string>;
errors: Array<{ opIndex: number; code: string; message: string }>;
}
export async function applyOpsToProject(
projectId: string,
ops: ModelOp[],
expectedVersion: number | undefined,
reason?: string
): Promise<ApplyResult> {
return prisma.$transaction(async tx => {
const latest = await tx.modelSnapshot.findFirst({
where: { projectId },
orderBy: { version: "desc" },
});
if (!latest) throw new Error(`No snapshot for project ${projectId}`);
if (expectedVersion !== undefined && expectedVersion !== latest.version) {
// Optimistic-concurrency miss; caller must resync.
return {
applied: false,
model: JSON.parse(latest.json) as SysMLModel,
version: latest.version,
idMapping: {},
errors: [{ opIndex: -1, code: "VERSION_MISMATCH", message: `Expected version ${expectedVersion}, server is ${latest.version}` }],
};
}
const currentModel = JSON.parse(latest.json) as SysMLModel;
const result = applyOps(currentModel, ops);
if (!result.applied) {
return {
applied: false,
model: currentModel,
version: latest.version,
idMapping: {},
errors: result.errors,
};
}
const newVersion = latest.version + 1;
await tx.modelSnapshot.create({
data: {
projectId,
version: newVersion,
json: JSON.stringify(result.model),
},
});
await tx.changelogEntry.create({
data: {
projectId,
version: newVersion,
ops: JSON.stringify(ops),
reason,
},
});
return {
applied: true,
model: result.model,
version: newVersion,
idMapping: result.idMapping,
errors: [],
};
});
}
// ─── Seeding ─────────────────────────────────────────────────────────────
async function ensureSeeded(projectId: string): Promise<{ projectId: string }> {
const existing = await prisma.project.findUnique({ where: { id: projectId } });
if (existing) {
// Make sure there's at least one snapshot
const snapCount = await prisma.modelSnapshot.count({ where: { projectId } });
if (snapCount > 0) return { projectId };
}
// Bootstrap with the Aristotle fixture
const seedModel = fromFixture(aristotleFixture);
await prisma.project.upsert({
where: { id: projectId },
update: {},
create: {
id: projectId,
name: aristotleFixture.project.name,
scope: aristotleFixture.project.scope,
tagline: aristotleFixture.project.tagline,
},
});
await prisma.modelSnapshot.create({
data: {
projectId,
version: 1,
json: JSON.stringify(seedModel),
},
});
// Auto-create an opening Socrates thread so the dock has somewhere to talk.
// Socrates' opening turn is generated lazily on first GET (so we don't
// pin a model selection to seed time).
await prisma.socratesThread.create({
data: {
projectId,
title: "Active thread · " + aristotleFixture.project.name,
status: "open",
},
});
return { projectId };
}
// ─── Findings (M8) ───────────────────────────────────────────────────────
import type { Finding as DetectedFinding } from "../llm/detect";
export interface StoredFinding {
id: string;
kind: string;
text: string;
linkedElementIds: string[];
confidence: number;
severity: string | null;
validationCode: string | null;
status: string;
modelVersion: number;
provider: string | null;
llmModel: string | null;
createdAt: Date;
}
export async function listOpenFindings(projectId: string): Promise<StoredFinding[]> {
const rows = await prisma.finding.findMany({
where: { projectId, status: "open" },
orderBy: [{ kind: "asc" }, { createdAt: "desc" }],
});
return rows.map(rowToFinding);
}
export async function replaceFindings(
projectId: string,
findings: DetectedFinding[],
modelVersion: number,
provider: string,
llmModel: string
): Promise<StoredFinding[]> {
return prisma.$transaction(async tx => {
// Wipe previous open findings — re-running detection supersedes them.
await tx.finding.deleteMany({ where: { projectId, status: "open" } });
if (findings.length === 0) return [];
const created = await Promise.all(
findings.map(f =>
tx.finding.create({
data: {
projectId,
kind: f.kind,
text: f.text,
linkedElementIds: JSON.stringify(f.linkedElementIds),
confidence: f.confidence,
severity: f.severity ?? null,
validationCode: f.validationCode ?? null,
status: "open",
modelVersion,
provider,
llmModel,
},
})
)
);
return created.map(rowToFinding);
});
}
export async function dismissFinding(findingId: string): Promise<void> {
await prisma.finding.update({
where: { id: findingId },
data: { status: "dismissed" },
});
}
interface FindingRow {
id: string;
kind: string;
text: string;
linkedElementIds: string;
confidence: number;
severity: string | null;
validationCode: string | null;
status: string;
modelVersion: number;
provider: string | null;
llmModel: string | null;
createdAt: Date;
}
function rowToFinding(row: FindingRow): StoredFinding {
let linked: string[] = [];
try {
const parsed = JSON.parse(row.linkedElementIds);
if (Array.isArray(parsed)) linked = parsed.filter((x): x is string => typeof x === "string");
} catch {
linked = [];
}
return {
id: row.id,
kind: row.kind,
text: row.text,
linkedElementIds: linked,
confidence: row.confidence,
severity: row.severity,
validationCode: row.validationCode,
status: row.status,
modelVersion: row.modelVersion,
provider: row.provider,
llmModel: row.llmModel,
createdAt: row.createdAt,
};
}
// ─── Socrates threads ────────────────────────────────────────────────────
export async function getActiveThread(projectId: string): Promise<{ id: string; title: string | null; messages: Array<{ id: string; role: string; content: string; createdAt: Date }> } | null> {
const thread = await prisma.socratesThread.findFirst({
where: { projectId, status: "open" },
orderBy: { updatedAt: "desc" },
include: { messages: { orderBy: { createdAt: "asc" } } },
});
if (!thread) return null;
return {
id: thread.id,
title: thread.title,
messages: thread.messages.map(m => ({
id: m.id,
role: m.role,
content: m.content,
createdAt: m.createdAt,
})),
};
}