MVP M7: Socrates can propose model changes — review impact + Accept/Reject

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).
This commit is contained in:
2026-04-30 00:27:29 +02:00
parent 78faca9968
commit 4b8e3f04ee
9 changed files with 1134 additions and 48 deletions

View File

@@ -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> | 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 (
<div className={`proposal-card ${pending ? "proposal-card-pending" : ""}`}>
<div className="proposal-card-head">
<span className="proposal-card-tag">PROPOSAL</span>
<span className="proposal-card-stats">
{summarizeStats(impactSummary)}
</span>
</div>
<div className="proposal-card-reasoning">{reasoning}</div>
{ops.length > 0 && (
<details className="proposal-card-ops" open>
<summary>Ops · {ops.length}</summary>
<ul>
{ops.map((op, i) => (
<li key={i}>{summarizeOp(op)}</li>
))}
</ul>
</details>
)}
{(impactSummary.added.length > 0 || impactSummary.removed.length > 0 || impactSummary.changed.length > 0) && (
<div className="proposal-card-section">
<div className="proposal-card-section-label">Impact</div>
<div className="proposal-card-impact-grid">
{impactSummary.added.length > 0 && (
<div className="proposal-card-impact-row">
<span className="proposal-card-impact-key">+ added</span>
<span className="proposal-card-impact-val">
{impactSummary.added.map(e => `${e.label} (${e.kind})`).join(", ")}
</span>
</div>
)}
{impactSummary.removed.length > 0 && (
<div className="proposal-card-impact-row">
<span className="proposal-card-impact-key"> removed</span>
<span className="proposal-card-impact-val">
{impactSummary.removed.map(e => `${e.label} (${e.kind})`).join(", ")}
</span>
</div>
)}
{impactSummary.changed.length > 0 && (
<div className="proposal-card-impact-row">
<span className="proposal-card-impact-key">~ changed</span>
<span className="proposal-card-impact-val">
{impactSummary.changed.map(e => `${e.label} (${e.kind})`).join(", ")}
</span>
</div>
)}
</div>
</div>
)}
{(impactSummary.issuesCreated.length > 0 || impactSummary.issuesResolved.length > 0) && (
<div className="proposal-card-section">
<div className="proposal-card-section-label">Validation</div>
{impactSummary.issuesResolved.length > 0 && (
<ul className="proposal-card-issue-list proposal-card-issues-resolved">
{impactSummary.issuesResolved.slice(0, 4).map((i, idx) => (
<li key={idx}> resolves {i.code}: {i.message}</li>
))}
</ul>
)}
{impactSummary.issuesCreated.length > 0 && (
<ul className="proposal-card-issue-list proposal-card-issues-created">
{impactSummary.issuesCreated.slice(0, 4).map((i, idx) => (
<li key={idx}> creates {i.code}: {i.message}</li>
))}
</ul>
)}
</div>
)}
{!impactSummary.ok && (
<div className="proposal-card-section proposal-card-error">
<div className="proposal-card-section-label">Cannot apply</div>
<ul className="proposal-card-issue-list">
{impactSummary.errors.map((e, i) => (
<li key={i}>{e.code}: {e.message}</li>
))}
</ul>
</div>
)}
<div className="proposal-card-actions">
<button
type="button"
className="proposal-card-btn proposal-card-btn-primary"
onClick={accept}
disabled={busy || pending || !impactSummary.ok || ops.length === 0}
>
{busy ? "Applying…" : "Accept"}
</button>
<button
type="button"
className="proposal-card-btn"
onClick={onReject}
disabled={busy || pending}
>
Reject
</button>
</div>
{proposal.meta?.model && (
<div className="proposal-card-meta">
via {proposal.meta.provider} · {proposal.meta.model}
</div>
)}
</div>
);
}
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}]`;
}
}

View File

@@ -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<DockMessage[]>([]);
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<HTMLDivElement | null>(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) {
</span>
</div>
)}
{messages.map(m => (
<div key={m.id} className={`bubble bubble-${m.role === "assistant" ? "socrates" : "user"}`}>
{m.role === "assistant" && <span className="bubble-sigil">Σ</span>}
<span className="bubble-body">
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
{m.text}
</span>
{m.options && m.options.length > 0 && (
<div className="bubble-options">
{m.options.map(o => (
<button
key={o.n}
className="bubble-option"
type="button"
onClick={() => pickOption(o)}
disabled={sending}
>
<span className="bubble-option-num">{o.n}</span>
<span className="bubble-option-text">
<span className="bubble-option-label">{o.label}</span>
{o.sub && <span className="bubble-option-sub">{o.sub}</span>}
</span>
<span className="bubble-option-key">{o.n}</span>
</button>
))}
<div className="bubble-options-hint">
Press <kbd>1</kbd><kbd>{m.options.length}</kbd>, or type a reply
{messages.map(m => {
// Proposal card — render in place of a normal bubble
if (m.role === "proposal") {
if (m.proposal) {
return (
<ProposalCard
key={m.id}
proposal={m.proposal}
onAccept={() => acceptProposal(m.id, m.proposal!)}
onReject={() => rejectProposal(m.id)}
/>
);
}
// Pending or errored proposal — show a thin status line
return (
<div key={m.id} className="bubble bubble-socrates">
<span className="bubble-sigil">Σ</span>
<span className="bubble-body">
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
{m.text}
</span>
</span>
</div>
);
}
// System notes (apply confirmation, errors)
if (m.role === "system") {
return (
<div key={m.id} className="bubble bubble-system">
<span className="bubble-text">{m.text}</span>
</div>
);
}
// Normal user / assistant bubble
return (
<div key={m.id} className={`bubble bubble-${m.role === "assistant" ? "socrates" : "user"}`}>
{m.role === "assistant" && <span className="bubble-sigil">Σ</span>}
<span className="bubble-body">
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
{m.text}
</span>
{m.options && m.options.length > 0 && (
<div className="bubble-options">
{m.options.map(o => (
<button
key={o.n}
className="bubble-option"
type="button"
onClick={() => pickOption(o)}
disabled={sending}
>
<span className="bubble-option-num">{o.n}</span>
<span className="bubble-option-text">
<span className="bubble-option-label">{o.label}</span>
{o.sub && <span className="bubble-option-sub">{o.sub}</span>}
</span>
<span className="bubble-option-key">{o.n}</span>
</button>
))}
<div className="bubble-options-hint">
Press <kbd>1</kbd><kbd>{m.options.length}</kbd>, or type a reply
</div>
</div>
</div>
)}
</span>
</div>
))}
)}
</span>
</div>
);
})}
<div ref={threadEndRef} />
</div>
</section>
@@ -250,8 +360,16 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) {
value={input}
onChange={e => setInput(e.target.value)}
disabled={sending}
autoFocus
/>
<button
type="button"
className="dock-input-propose"
onClick={() => void requestProposal()}
disabled={proposing || sending}
title="Ask Socrates to propose a model change"
>
propose
</button>
<button
type="submit"
className="dock-input-send"
@@ -265,3 +383,7 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) {
</aside>
);
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + "…" : s;
}