From 4b8e3f04eeb0f0638bd1bce96cd76ee53cb4b7f0 Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 30 Apr 2026 00:27:29 +0200 Subject: [PATCH] =?UTF-8?q?MVP=20M7:=20Socrates=20can=20propose=20model=20?= =?UTF-8?q?changes=20=E2=80=94=20review=20impact=20+=20Accept/Reject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dock now has a "propose" button that asks Socrates to suggest one high-value structural change. He returns a structured payload (reasoning + ops + impactSummary) which renders as a ProposalCard inline in the thread. Accept routes the ops through the same useApply() pipeline as user-originated edits (optimistic local + persisted POST + server reconciliation). apps/web/lib/llm/prompts/socrates/propose.md - Promoted verbatim from phase-0/src/prompts/. apps/web/lib/llm/proposeChange.ts - Server-side proposeChange() — calls the LLM with character + propose prompts + trimmed model + active issues. JSON Schema uses oneOf per op kind (Phase 0 lesson: small models need this to produce real op shapes instead of cramming everything into the kind name). - normalizeOps() converts the LLM-emitted op shapes into canonical ModelOp[] (fills in property ids/multiplicity defaults, expands satisfiedBy[] into RequirementRelation[], splits PropertyType union per kind). apps/web/lib/sysml/impact.ts - Pure pre-apply impact analysis. Runs the ops through the same applyOps() reducer locally, then diffs: - structural: added / removed / changed elements (per kind) - validation: issues created vs resolved (by canonical issue key) - dep-graph blast radius (closure of touched element ids on the post-apply graph) Headline stats summarized as deltas (+1 block, −1 assoc, etc.) for the proposal card. apps/web/app/api/projects/[projectId]/socrates/propose - POST: returns { reasoning, ops, impactSummary, meta }. Pure read of the model — does not apply anything; client must POST /apply with the same ops to commit. apps/web/components/socrates/ProposalCard.tsx - In-dock card: PROPOSAL tag + delta stats / reasoning / collapsible ops list / Impact section (added/removed/changed) / Validation diff (resolves ✓ / creates ⚠) / Accept + Reject. Disabled when impact analysis flagged the apply as illegal. apps/web/components/socrates/SocratesDock.tsx - New "propose" button between the input field and send. Renders ProposalCard for assistant turns of role "proposal". Accept calls useApply(); on success the card collapses to a "✓ Applied" system bubble. On apply failure the dock shows the structured error messages. - New "system" bubble role for apply confirmations + dismissals. apps/web/components/diagram-canvas/DiagramCanvas.tsx - Bug fix: new blocks/constraints arriving via the model→RF sync (e.g. from accepted proposals) are now positioned to the right of the existing layout instead of stacking at (0, 0) offscreen. apps/web/lib/sync/ModelStore.tsx - Bug fix / observability: background-POST failures and version mismatches now log to the console with structured context instead of being silently swallowed. The server's authoritative state still replaces the optimistic local state on response, but the user can now see why their accept appeared to do nothing (typically: page tab was at version N but server had advanced to N+1). --- .../[projectId]/socrates/propose/route.ts | 51 +++ .../diagram-canvas/DiagramCanvas.tsx | 34 +- apps/web/components/socrates/ProposalCard.tsx | 208 ++++++++++++ apps/web/components/socrates/SocratesDock.tsx | 190 +++++++++-- apps/web/lib/llm/prompts/socrates/propose.md | 44 +++ apps/web/lib/llm/proposeChange.ts | 315 ++++++++++++++++++ apps/web/lib/sync/ModelStore.tsx | 25 +- apps/web/lib/sysml/impact.ts | 164 +++++++++ apps/web/styles/base.css | 151 +++++++++ 9 files changed, 1134 insertions(+), 48 deletions(-) create mode 100644 apps/web/app/api/projects/[projectId]/socrates/propose/route.ts create mode 100644 apps/web/components/socrates/ProposalCard.tsx create mode 100644 apps/web/lib/llm/prompts/socrates/propose.md create mode 100644 apps/web/lib/llm/proposeChange.ts create mode 100644 apps/web/lib/sysml/impact.ts diff --git a/apps/web/app/api/projects/[projectId]/socrates/propose/route.ts b/apps/web/app/api/projects/[projectId]/socrates/propose/route.ts new file mode 100644 index 0000000..37e9909 --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/socrates/propose/route.ts @@ -0,0 +1,51 @@ +// POST /api/projects/[projectId]/socrates/propose +// +// Body: { userPrompt?: string } +// Returns: { reasoning, ops, impactSummary, meta } +// +// Does NOT apply the ops — that's a follow-up call to /apply once the user +// approves. The proposal card UI shows reasoning + impactSummary + ops so +// the user can review before committing. + +import { NextResponse } from "next/server"; +import { loadProject } from "../../../../../../lib/db/repo"; +import { proposeChange } from "../../../../../../lib/llm/proposeChange"; +import { analyzeImpact } from "../../../../../../lib/sysml/impact"; +import { validate } from "../../../../../../lib/sysml/validate"; + +export async function POST(req: Request, { params }: { params: Promise<{ projectId: string }> }) { + const { projectId } = await params; + let body: { userPrompt?: string }; + try { + body = await req.json(); + } catch { + body = {}; + } + + const { model } = await loadProject(projectId); + const issues = validate(model); + + try { + const proposal = await proposeChange({ + model, + issues, + userPrompt: body.userPrompt, + }); + + const impact = analyzeImpact(model, proposal.ops); + + return NextResponse.json({ + reasoning: proposal.reasoning, + ops: proposal.ops, + impactSummary: impact, + meta: { + provider: proposal.provider, + model: proposal.model, + inputTokens: proposal.inputTokens, + outputTokens: proposal.outputTokens, + }, + }); + } catch (err) { + return NextResponse.json({ error: (err as Error).message }, { status: 500 }); + } +} diff --git a/apps/web/components/diagram-canvas/DiagramCanvas.tsx b/apps/web/components/diagram-canvas/DiagramCanvas.tsx index e06e2bd..ada9e13 100644 --- a/apps/web/components/diagram-canvas/DiagramCanvas.tsx +++ b/apps/web/components/diagram-canvas/DiagramCanvas.tsx @@ -118,11 +118,14 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram const byId = new Map(currentNodes.map(n => [n.id, n])); const next: Node[] = []; - // Existing model elements first — preserve position & selection from RF state + // For new elements added by the model (e.g. accepted proposals), drop + // them at a sensible spot near the existing centroid so they're + // immediately visible — not at (0,0) offscreen. + const placeNew = makePlaceNewPosition(currentNodes); + for (const b of model.blocks) { const existing = byId.get(b.id); if (existing) { - // Update data only if it changed (cheap structural compare) const propNames = b.properties.map(p => p.name); const dataChanged = existing.data?.label !== b.label || @@ -134,8 +137,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram next.push(existing); } } else { - // New block from outside (rare in M5; mostly self-originated drops) - next.push(makeNodeForBlock(b)); + next.push({ ...makeNodeForBlock(b), position: placeNew() }); } } for (const c of model.constraints) { @@ -151,7 +153,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram next.push(existing); } } else { - next.push(makeNodeForConstraint(c)); + next.push({ ...makeNodeForConstraint(c), position: placeNew() }); } } return next; @@ -521,6 +523,28 @@ function makeEdgeForApplies(constraintId: string, targetBlockId: string): Edge { x: number; y: number } { + let baseX = 100; + let baseY = 100; + if (existing.length > 0) { + const maxX = Math.max(...existing.map(n => n.position.x)); + const minY = Math.min(...existing.map(n => n.position.y)); + baseX = maxX + 240; + baseY = minY; + } + let i = 0; + return () => { + const pos = { x: baseX, y: baseY + i * 140 }; + i++; + return pos; + }; +} + function snapshotIds(model: SysMLModel): { blocks: Set; assocs: Set; constraints: Set } { return { blocks: new Set(model.blocks.map(b => b.id)), diff --git a/apps/web/components/socrates/ProposalCard.tsx b/apps/web/components/socrates/ProposalCard.tsx new file mode 100644 index 0000000..fe48ce8 --- /dev/null +++ b/apps/web/components/socrates/ProposalCard.tsx @@ -0,0 +1,208 @@ +// In-dock card showing a Socrates-proposed change. +// +// Displays: reasoning, op summary, impact summary (added/removed/changed + +// validation diff), and Accept / Reject controls. Accept calls into the +// ModelStore which routes through the same applyOps + persistence pipeline +// as user-originated edits. + +"use client"; + +import { useState } from "react"; +import type { ImpactSummary } from "../../lib/sysml/impact"; +import type { ModelOp } from "../../lib/sync/ops"; + +export interface ProposalPayload { + reasoning: string; + ops: ModelOp[]; + impactSummary: ImpactSummary; + meta?: { provider?: string; model?: string }; +} + +interface ProposalCardProps { + proposal: ProposalPayload; + onAccept: () => Promise | void; + onReject: () => void; + pending?: boolean; +} + +export function ProposalCard({ proposal, onAccept, onReject, pending }: ProposalCardProps) { + const [busy, setBusy] = useState(false); + const { reasoning, ops, impactSummary } = proposal; + + async function accept() { + if (busy) return; + setBusy(true); + try { + await onAccept(); + } finally { + setBusy(false); + } + } + + return ( +
+
+ PROPOSAL + + {summarizeStats(impactSummary)} + +
+ +
{reasoning}
+ + {ops.length > 0 && ( +
+ Ops · {ops.length} +
    + {ops.map((op, i) => ( +
  • {summarizeOp(op)}
  • + ))} +
+
+ )} + + {(impactSummary.added.length > 0 || impactSummary.removed.length > 0 || impactSummary.changed.length > 0) && ( +
+
Impact
+
+ {impactSummary.added.length > 0 && ( +
+ + added + + {impactSummary.added.map(e => `${e.label} (${e.kind})`).join(", ")} + +
+ )} + {impactSummary.removed.length > 0 && ( +
+ − removed + + {impactSummary.removed.map(e => `${e.label} (${e.kind})`).join(", ")} + +
+ )} + {impactSummary.changed.length > 0 && ( +
+ ~ changed + + {impactSummary.changed.map(e => `${e.label} (${e.kind})`).join(", ")} + +
+ )} +
+
+ )} + + {(impactSummary.issuesCreated.length > 0 || impactSummary.issuesResolved.length > 0) && ( +
+
Validation
+ {impactSummary.issuesResolved.length > 0 && ( +
    + {impactSummary.issuesResolved.slice(0, 4).map((i, idx) => ( +
  • ✓ resolves {i.code}: {i.message}
  • + ))} +
+ )} + {impactSummary.issuesCreated.length > 0 && ( +
    + {impactSummary.issuesCreated.slice(0, 4).map((i, idx) => ( +
  • ⚠ creates {i.code}: {i.message}
  • + ))} +
+ )} +
+ )} + + {!impactSummary.ok && ( +
+
Cannot apply
+
    + {impactSummary.errors.map((e, i) => ( +
  • {e.code}: {e.message}
  • + ))} +
+
+ )} + +
+ + +
+ + {proposal.meta?.model && ( +
+ via {proposal.meta.provider} · {proposal.meta.model} +
+ )} +
+ ); +} + +function summarizeStats(impact: ImpactSummary): string { + if (!impact.ok) return "would not apply"; + const parts: string[] = []; + const { stats } = impact; + if (stats.blocksDelta) parts.push(`${signed(stats.blocksDelta)} block${Math.abs(stats.blocksDelta) === 1 ? "" : "s"}`); + if (stats.associationsDelta) parts.push(`${signed(stats.associationsDelta)} assoc`); + if (stats.constraintsDelta) parts.push(`${signed(stats.constraintsDelta)} constraint${Math.abs(stats.constraintsDelta) === 1 ? "" : "s"}`); + if (stats.requirementsDelta) parts.push(`${signed(stats.requirementsDelta)} req${Math.abs(stats.requirementsDelta) === 1 ? "" : "s"}`); + if (stats.issuesDelta) parts.push(`${signed(stats.issuesDelta)} issue${Math.abs(stats.issuesDelta) === 1 ? "" : "s"}`); + if (parts.length === 0) parts.push("structural rearrangement"); + return parts.join(" · "); +} +function signed(n: number): string { + return n > 0 ? `+${n}` : `${n}`; +} + +function summarizeOp(op: ModelOp): string { + switch (op.kind) { + case "add-block": + return `+ block "${op.block.label}" (${op.block.kind})`; + case "remove-block": + return `− block ${op.blockId}`; + case "update-block": + return `~ block ${op.blockId}`; + case "add-association": + return `+ assoc ${op.association.fromBlockId}→${op.association.toBlockId} (${op.association.kind})`; + case "remove-association": + return `− assoc ${op.associationId}`; + case "update-association": + return `~ assoc ${op.associationId}`; + case "add-constraint": + return `+ constraint "${op.constraint.label}"`; + case "remove-constraint": + return `− constraint ${op.constraintId}`; + case "update-constraint": + return `~ constraint ${op.constraintId}`; + case "add-requirement": + return `+ req ${op.requirement.tag}`; + case "remove-requirement": + return `− req ${op.requirementId}`; + case "update-requirement": + return `~ req ${op.requirementId}`; + case "add-property": + return `+ property ${op.blockId}.${op.property.name}`; + case "update-property": + return `~ property ${op.blockId}.${op.propertyId}`; + case "remove-property": + return `− property ${op.blockId}.${op.propertyId}`; + case "add-relation": + return `+ relation ${op.requirementId}.${op.relation.kind}`; + case "remove-relation": + return `− relation ${op.requirementId}[${op.relationIndex}]`; + } +} diff --git a/apps/web/components/socrates/SocratesDock.tsx b/apps/web/components/socrates/SocratesDock.tsx index e21dd37..d16a74d 100644 --- a/apps/web/components/socrates/SocratesDock.tsx +++ b/apps/web/components/socrates/SocratesDock.tsx @@ -8,16 +8,19 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Sigil } from "./Sigil"; +import { ProposalCard, type ProposalPayload } from "./ProposalCard"; +import { useApply } from "../../lib/sync/ModelStore"; export type SocratesPresence = "subtle" | "default" | "prominent"; export type Density = "comfortable" | "compact"; export interface DockMessage { id: string; - role: "user" | "assistant"; + role: "user" | "assistant" | "proposal" | "system"; text: string; options?: Array<{ n: number; label: string; sub?: string }>; pending?: boolean; + proposal?: ProposalPayload; } interface SocratesDockProps { @@ -38,9 +41,11 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); + const [proposing, setProposing] = useState(false); const [loaded, setLoaded] = useState(false); const [meta, setMeta] = useState<{ provider?: string; model?: string }>({}); const threadEndRef = useRef(null); + const apply = useApply(); // Load thread on mount and trigger an opening turn if the thread is empty. useEffect(() => { @@ -141,6 +146,76 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) { await sendImpl(text); }, [sending, sendImpl]); + // Ask Socrates to propose a model change. + const requestProposal = useCallback(async () => { + if (proposing || sending) return; + setProposing(true); + const pendingId = `tmp-p-${Date.now()}`; + setMessages(curr => [ + ...curr, + { id: pendingId, role: "proposal", text: "Drafting a proposal…", pending: true }, + ]); + try { + const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates/propose`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error ?? `${res.status}`); + } + const data = (await res.json()) as ProposalPayload; + setMessages(curr => curr.map(m => + m.id === pendingId + ? { id: pendingId, role: "proposal", text: data.reasoning, proposal: data } + : m + )); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setMessages(curr => curr.map(m => + m.id === pendingId + ? { ...m, text: `⚠ propose failed: ${msg}`, pending: false } + : m + )); + } finally { + setProposing(false); + } + }, [proposing, sending, projectId]); + + const acceptProposal = useCallback(async (messageId: string, proposal: ProposalPayload) => { + const result = apply(proposal.ops); + if (!result.applied) { + setMessages(curr => [ + ...curr, + { + id: `tmp-sys-${Date.now()}`, + role: "system", + text: `⚠ Couldn't apply: ${result.errors.map(e => `${e.code}: ${e.message}`).join("; ")}`, + }, + ]); + return; + } + // Mark the proposal accepted (drop the live card; keep a summary line) + setMessages(curr => curr.map(m => + m.id === messageId + ? { + id: messageId, + role: "system", + text: `✓ Applied · ${proposal.ops.length} op${proposal.ops.length === 1 ? "" : "s"} · ${truncate(proposal.reasoning, 90)}`, + } + : m + )); + }, [apply]); + + const rejectProposal = useCallback((messageId: string) => { + setMessages(curr => curr.map(m => + m.id === messageId + ? { id: messageId, role: "system", text: "Proposal dismissed." } + : m + )); + }, []); + // Number-key shortcuts on the most recent assistant turn with options useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -198,39 +273,74 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) { )} - {messages.map(m => ( -
- {m.role === "assistant" && Σ} - - - {m.text} - - {m.options && m.options.length > 0 && ( -
- {m.options.map(o => ( - - ))} -
- Press 1{m.options.length}, or type a reply + {messages.map(m => { + // Proposal card — render in place of a normal bubble + if (m.role === "proposal") { + if (m.proposal) { + return ( + acceptProposal(m.id, m.proposal!)} + onReject={() => rejectProposal(m.id)} + /> + ); + } + // Pending or errored proposal — show a thin status line + return ( +
+ Σ + + + {m.text} + + +
+ ); + } + // System notes (apply confirmation, errors) + if (m.role === "system") { + return ( +
+ {m.text} +
+ ); + } + // Normal user / assistant bubble + return ( +
+ {m.role === "assistant" && Σ} + + + {m.text} + + {m.options && m.options.length > 0 && ( +
+ {m.options.map(o => ( + + ))} +
+ Press 1{m.options.length}, or type a reply +
-
- )} - -
- ))} + )} + +
+ ); + })}
@@ -250,8 +360,16 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) { value={input} onChange={e => setInput(e.target.value)} disabled={sending} - autoFocus /> +