Files
Socrates/apps/web/lib/db/repo.ts
dtoro 78faca9968 MVP M6: live Socrates dock — LLM gateway + persisted thread + reply loop
The dock now talks to a real LLM. On first load it asks Socrates for an
opening turn that's grounded in the project's actual SysML model + active
validation issues. User replies and option-picks send turns through the
same channel. Thread + every message persist in SQLite so refresh keeps
the conversation.

apps/web/lib/llm
- gateway.ts: LLMGateway interface (chat + ChatOptions). Two adapters:
    - lmstudio: OpenAI SDK against LMSTUDIO_BASE_URL (default
      http://localhost:1234/v1)
    - anthropic: @anthropic-ai/sdk against claude-sonnet-4-6 (set
      ANTHROPIC_API_KEY when LLM_PROVIDER=anthropic)
  Provider chosen via LLM_PROVIDER env (default: lmstudio).
- chatJSON(): JSON-mode helper with parse-error repair-retry — the same
  defensive pattern proven against gemma-4-e4b in Phase 0.
- prompts.ts: server-only loader that caches .md prompts.
- prompts/socrates/character.md + review.md: ported verbatim from
  phase-0/src/prompts/ (Phase 0 corpus validated these 10/10).
- socrates.ts: sendUserTurn() — builds the system prompt (character +
  review + project context with trimmed model + active issues), runs
  chatJSON against the gateway, persists user + assistant turns,
  returns the structured turn. SocratesTurn schema is { text, options? }
  with up to 3 numbered options matching the prototype.

apps/web/prisma
- SocratesThread + SocratesMessage tables. Auto-create one open thread
  per project on first load.

apps/web/app/api/projects/[projectId]/socrates
- GET: returns active thread + parsed messages.
- POST: body { text }. Empty text triggers an opening turn. Persists user
  + assistant turns, returns assistant turn + provider metadata.

apps/web/components/socrates/SocratesDock.tsx
- Replaces the static thread prop with a projectId. Loads from API on
  mount, auto-triggers an opening turn if the thread is empty, sends
  user replies via POST. Numbered options click-to-pick or 1–3 keyboard
  shortcut (skipped when focus is in an input). Status line shows the
  active provider + model. Optimistic-local: user message appears
  instantly, "thinking…" placeholder shows while the LLM works, errors
  surface inline.

apps/web/.env.example + .env.local
- LLM_PROVIDER, LMSTUDIO_BASE_URL/MODEL/API_KEY, ANTHROPIC_API_KEY/MODEL.
- .env.local committed only with the local default (no real secrets);
  user supplies their own per-machine.

What's not yet here (next iterations):
- Streaming responses (currently waits for full response, ~5-15s)
- Multi-thread switcher (one auto-thread per project)
- Socrates-proposes-ops flow (M7)
2026-04-29 07:50:34 +02:00

171 lines
5.3 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 };
}
// ─── 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,
})),
};
}