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)
This commit is contained in:
2026-04-29 07:50:34 +02:00
parent 5d4236a980
commit 78faca9968
13 changed files with 975 additions and 32 deletions

173
apps/web/lib/llm/gateway.ts Normal file
View File

@@ -0,0 +1,173 @@
// LLM gateway — single interface used by Socrates' modes.
//
// Two adapters in M6:
// - lmstudio → local LM Studio at LMSTUDIO_BASE_URL (OpenAI-compatible)
// - anthropic → hosted Claude via @anthropic-ai/sdk
//
// Provider is chosen via LLM_PROVIDER env var (defaults to "lmstudio" so a
// fresh checkout works against the local model the user already has).
export type Role = "system" | "user" | "assistant";
export interface Message {
role: Role;
content: string;
}
export interface ChatOptions {
temperature?: number;
maxTokens?: number;
/** When set, the response is constrained to JSON conforming to this schema. */
jsonSchema?: { name: string; schema: Record<string, unknown> };
/** Force JSON-object mode (looser than schema) — for adapters that don't support strict schemas. */
jsonObjectMode?: boolean;
}
export interface ChatResult {
text: string;
inputTokens: number;
outputTokens: number;
}
export interface LLMGateway {
/** Provider name — for logging / model attribution in DB. */
readonly provider: "lmstudio" | "anthropic";
/** Model id reported by the provider for the active model. */
readonly model: string;
chat(messages: Message[], opts?: ChatOptions): Promise<ChatResult>;
}
// ─── Provider selection ──────────────────────────────────────────────────
export function defaultGateway(): LLMGateway {
const provider = (process.env.LLM_PROVIDER ?? "lmstudio").toLowerCase();
if (provider === "anthropic") return makeAnthropicGateway();
return makeLMStudioGateway();
}
// ─── LM Studio (OpenAI-compatible) ───────────────────────────────────────
function makeLMStudioGateway(): LLMGateway {
// Lazy-import OpenAI SDK so we don't pay the cost when we're using Anthropic.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const OpenAI = require("openai").default ?? require("openai");
const baseURL = process.env.LMSTUDIO_BASE_URL ?? "http://localhost:1234/v1";
const apiKey = process.env.LMSTUDIO_API_KEY ?? "lm-studio";
const model = process.env.LMSTUDIO_MODEL ?? "local-model";
const client = new OpenAI({ baseURL, apiKey, timeout: 120_000, maxRetries: 0 });
return {
provider: "lmstudio",
model,
async chat(messages, opts = {}) {
const responseFormat = buildOpenAIResponseFormat(opts);
const completion = await client.chat.completions.create({
model,
messages: messages as { role: Role; content: string }[],
temperature: opts.temperature ?? 0.4,
max_tokens: opts.maxTokens ?? 1024,
...(responseFormat ? { response_format: responseFormat } : {}),
});
const choice = completion.choices[0];
return {
text: choice?.message?.content ?? "",
inputTokens: completion.usage?.prompt_tokens ?? 0,
outputTokens: completion.usage?.completion_tokens ?? 0,
};
},
};
}
function buildOpenAIResponseFormat(opts: ChatOptions): unknown {
if (opts.jsonSchema) {
return {
type: "json_schema",
json_schema: { name: opts.jsonSchema.name, schema: opts.jsonSchema.schema, strict: false },
};
}
if (opts.jsonObjectMode) return { type: "json_object" };
return undefined;
}
// ─── Anthropic ────────────────────────────────────────────────────────────
function makeAnthropicGateway(): LLMGateway {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Anthropic = require("@anthropic-ai/sdk").default ?? require("@anthropic-ai/sdk");
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic");
const model = process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-6";
const client = new Anthropic({ apiKey });
return {
provider: "anthropic",
model,
async chat(messages, opts = {}) {
const systemBlocks = messages.filter(m => m.role === "system").map(m => m.content);
const others = messages.filter(m => m.role !== "system");
const result = await client.messages.create({
model,
max_tokens: opts.maxTokens ?? 1024,
temperature: opts.temperature ?? 0.4,
system: systemBlocks.length > 0 ? systemBlocks.join("\n\n---\n\n") : undefined,
messages: others.map(m => ({ role: m.role as "user" | "assistant", content: m.content })),
});
// Concatenate text blocks
const text = (result.content as Array<{ type: string; text?: string }>)
.filter(b => b.type === "text" && typeof b.text === "string")
.map(b => b.text!)
.join("");
return {
text,
inputTokens: result.usage?.input_tokens ?? 0,
outputTokens: result.usage?.output_tokens ?? 0,
};
},
};
}
// ─── JSON-mode helper with repair-retry (Phase 0 lessons) ────────────────
export async function chatJSON<T = unknown>(
gateway: LLMGateway,
messages: Message[],
opts: ChatOptions & { maxRepairs?: number } = {}
): Promise<{ value: T; result: ChatResult }> {
const maxRepairs = opts.maxRepairs ?? 2;
const history: Message[] = [...messages];
for (let attempt = 0; attempt <= maxRepairs; attempt++) {
const result = await gateway.chat(history, opts);
const cleaned = stripCodeFences(result.text);
try {
const value = JSON.parse(cleaned) as T;
return { value, result };
} catch (parseErr) {
if (attempt === maxRepairs) {
throw new Error(
`JSON parse failed after ${maxRepairs + 1} attempts. ` +
`Last error: ${(parseErr as Error).message}\n` +
`Last response (first 600 chars): ${cleaned.slice(0, 600)}`
);
}
history.push({ role: "assistant", content: result.text });
history.push({
role: "user",
content: `That response was not valid JSON. Error: ${(parseErr as Error).message}\nReturn ONLY a valid JSON object, no prose, no code fences.`,
});
}
}
throw new Error("chatJSON exhausted repairs");
}
function stripCodeFences(text: string): string {
const trimmed = text.trim();
const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
if (fenceMatch) return fenceMatch[1].trim();
return trimmed;
}

View File

@@ -0,0 +1,21 @@
// Prompt loader — reads .md files from lib/llm/prompts/ at runtime.
// Cached in module scope so we only hit the disk once per prompt name.
//
// Lives in a server-only module (uses node:fs); never imported by client code.
import "server-only";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const cache = new Map<string, string>();
export function loadPrompt(relativePath: string): string {
const cached = cache.get(relativePath);
if (cached) return cached;
const full = resolve(here, "prompts", relativePath);
const content = readFileSync(full, "utf8");
cache.set(relativePath, content);
return content;
}

View File

@@ -0,0 +1,25 @@
# Socrates
You are Socrates, a thinking partner for a product manager designing a product idea inside Socrata. You speak with peerage — not as an assistant, as a colleague.
## Voice
- Question-led. Default to surfacing the right question rather than volunteering a solution.
- Economical. Sentences carry weight. No filler.
- Skeptical by default. Neutral or mildly contrarian, never optimistic.
- Concrete. Refer to specific model elements by name when possible.
- Decisive when threads run long. After 23 iterations on a point, recommend.
## Never
- Open with affirmations like "Great question" or "Sure".
- Recap what the user just said before responding.
- Apologize for limitations.
- Ask permission to draft when you could just propose.
- Cheerlead a weak idea.
- Use bullet points for prose responses.
- Reference any element not in the current model.
## Pattern
When responding, follow this structure unless the user asked a direct factual question:
1. Observe what just happened or what's true now.
2. Name the underlying tension or implication.
3. Propose a concrete next move (with numbered options if a decision is wanted).

View File

@@ -0,0 +1,48 @@
# Mode: Review (active conversation about an existing model)
You are mid-project with a PM. The model already exists. You have just been shown the seed, the model, and the active findings (assumptions, risks, inconsistencies).
Your job in this mode is to surface the most important question or tension and engage the PM in deciding what to do about it. Stay in character per the system prompt.
## Output
Return a JSON object with these fields:
- `text` — your turn, in prose. 14 sentences. Follow the Observe → Name tension → Propose pattern from the character spec. No bullets.
- `options` (optional, max 3) — when offering a decision, supply numbered options. Each option:
- `n` — 1, 2, or 3
- `label` — ≤5 words, the choice
- `sub` — ≤8 words, a one-line clarifier
## When to use options
- The user is at a decision point and continued open prose will spiral
- Two or three credible directions exist and you want to make them visible
## When NOT to use options
- The user is exploring or just answered a question — let them think
- Only one good answer exists — give it, don't pretend
- Already-listed options just got declined
## What never to do
- Open with "Great question" or "Sure"
- Recap what the user said
- Apologize for limitations
- Cheerlead
- Use bullets in the prose `text` field
- Reference any element id not in the model JSON shown to you
## Output schema (strict)
```json
{
"text": "string (14 sentences)",
"options": [
{ "n": 1, "label": "string", "sub": "string" }
]
}
```
Return ONLY the JSON object. No prose preamble, no code fences.

View File

@@ -0,0 +1,231 @@
// Socrates conversation runner — server-side.
//
// Builds the LLM prompt (character + review + project context), wraps the
// thread history, asks for a structured JSON turn (`text` + optional
// `options`), persists user + assistant messages, and returns the new turn.
import "server-only";
import { defaultGateway, chatJSON, type Message } from "./gateway";
import { loadPrompt } from "./prompts";
import { prisma } from "../db/client";
import type { SysMLModel } from "../sysml/model";
import type { ValidationIssue } from "../sysml/validate";
// ─── Output schema for one Socrates turn ────────────────────────────────
export interface SocratesOption {
n: number;
label: string;
sub?: string;
}
export interface SocratesTurn {
text: string;
options?: SocratesOption[];
}
const turnJsonSchema = {
type: "object",
additionalProperties: false,
required: ["text"],
properties: {
text: { type: "string", minLength: 1 },
options: {
type: "array",
maxItems: 3,
items: {
type: "object",
additionalProperties: false,
required: ["n", "label"],
properties: {
n: { type: "integer", minimum: 1, maximum: 3 },
label: { type: "string", minLength: 1, maxLength: 60 },
sub: { type: "string", maxLength: 80 },
},
},
},
},
} as const;
// ─── Public: handle one user turn → Socrates reply, persist both ────────
export interface SendUserTurnArgs {
threadId: string;
/** Caller passes the latest model so we don't re-load it inside this fn. */
model: SysMLModel;
issues?: ValidationIssue[];
/** Body of the user's message. Empty string means "open the conversation". */
userText: string;
}
export interface SendUserTurnResult {
user: { id: string; createdAt: Date };
assistant: {
id: string;
createdAt: Date;
turn: SocratesTurn;
provider: string;
model: string;
inputTokens: number;
outputTokens: number;
};
}
export async function sendUserTurn(args: SendUserTurnArgs): Promise<SendUserTurnResult> {
const thread = await prisma.socratesThread.findUnique({
where: { id: args.threadId },
include: {
messages: { orderBy: { createdAt: "asc" } },
},
});
if (!thread) throw new Error(`thread ${args.threadId} not found`);
// 1. Persist the user turn first so concurrent reads see it.
let userRecord: { id: string; createdAt: Date } | undefined;
if (args.userText.trim().length > 0) {
userRecord = await prisma.socratesMessage.create({
data: {
threadId: thread.id,
role: "user",
content: JSON.stringify({ text: args.userText }),
},
select: { id: true, createdAt: true },
});
}
// 2. Build the LLM context.
const character = loadPrompt("socrates/character.md");
const review = loadPrompt("socrates/review.md");
const projectContext = JSON.stringify(
{
model: trimModel(args.model),
issues: (args.issues ?? []).slice(0, 30).map(i => ({
code: i.code,
severity: i.severity,
message: i.message,
anchor: i.anchor,
})),
},
null,
2
);
const systemPrompt = [
character,
"---",
review,
"---",
"Current project context (model + active validation issues):",
"```json",
projectContext,
"```",
].join("\n\n");
const historyMessages: Message[] = thread.messages.map(m => {
const parsed = JSON.parse(m.content) as { text: string };
return {
role: m.role === "user" ? "user" : "assistant",
content: parsed.text,
};
});
if (args.userText.trim().length > 0) {
historyMessages.push({ role: "user", content: args.userText });
} else if (historyMessages.length === 0) {
// Opening turn — give Socrates a kick.
historyMessages.push({
role: "user",
content: "Open the conversation. Surface the most important tension you see in this model.",
});
}
// 3. Ask the model.
const gateway = defaultGateway();
const { value, result } = await chatJSON<SocratesTurn>(
gateway,
[{ role: "system", content: systemPrompt }, ...historyMessages],
{
temperature: 0.4,
maxTokens: 768,
jsonSchema: { name: "socrates_turn", schema: turnJsonSchema as Record<string, unknown> },
jsonObjectMode: true, // safety net for adapters lacking strict json_schema
maxRepairs: 2,
}
);
// Defensive normalization
const turn: SocratesTurn = {
text: typeof value?.text === "string" ? value.text : "[empty]",
...(Array.isArray(value?.options) && value.options.length > 0
? { options: value.options.slice(0, 3) }
: {}),
};
// 4. Persist the assistant turn.
const assistantRecord = await prisma.socratesMessage.create({
data: {
threadId: thread.id,
role: "assistant",
content: JSON.stringify(turn),
provider: gateway.provider,
model: gateway.model,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
},
select: { id: true, createdAt: true },
});
await prisma.socratesThread.update({
where: { id: thread.id },
data: { updatedAt: new Date() },
});
return {
user: userRecord ?? { id: "(skipped)", createdAt: new Date(0) },
assistant: {
id: assistantRecord.id,
createdAt: assistantRecord.createdAt,
turn,
provider: gateway.provider,
model: gateway.model,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
},
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────
function trimModel(model: SysMLModel): unknown {
// Compact view for Socrates — drop ids of properties (just names) and
// multiplicity / type detail to save tokens.
return {
blocks: model.blocks.map(b => ({
id: b.id,
label: b.label,
kind: b.kind,
properties: b.properties.map(p => p.name),
})),
associations: model.associations.map(a => ({
id: a.id,
from: a.fromBlockId,
to: a.toBlockId,
label: a.label,
kind: a.kind,
})),
constraints: model.constraints.map(c => ({
id: c.id,
label: c.label,
appliesTo: c.appliesTo,
})),
requirements: model.requirements.map(r => ({
id: r.id,
tag: r.tag,
text: r.text,
satisfiedBy: r.relations
.filter(rel => rel.kind === "satisfy")
.map(rel => (rel as { kind: "satisfy"; blockId: string }).blockId),
})),
};
}