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:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,11 +118,14 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
|||||||
const byId = new Map(currentNodes.map(n => [n.id, n]));
|
const byId = new Map(currentNodes.map(n => [n.id, n]));
|
||||||
const next: Node<BlockNodeData>[] = [];
|
const next: Node<BlockNodeData>[] = [];
|
||||||
|
|
||||||
// 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) {
|
for (const b of model.blocks) {
|
||||||
const existing = byId.get(b.id);
|
const existing = byId.get(b.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Update data only if it changed (cheap structural compare)
|
|
||||||
const propNames = b.properties.map(p => p.name);
|
const propNames = b.properties.map(p => p.name);
|
||||||
const dataChanged =
|
const dataChanged =
|
||||||
existing.data?.label !== b.label ||
|
existing.data?.label !== b.label ||
|
||||||
@@ -134,8 +137,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
|||||||
next.push(existing);
|
next.push(existing);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New block from outside (rare in M5; mostly self-originated drops)
|
next.push({ ...makeNodeForBlock(b), position: placeNew() });
|
||||||
next.push(makeNodeForBlock(b));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const c of model.constraints) {
|
for (const c of model.constraints) {
|
||||||
@@ -151,7 +153,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
|||||||
next.push(existing);
|
next.push(existing);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
next.push(makeNodeForConstraint(c));
|
next.push({ ...makeNodeForConstraint(c), position: placeNew() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -521,6 +523,28 @@ function makeEdgeForApplies(constraintId: string, targetBlockId: string): Edge<S
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position factory for newly-arrived nodes — places each at the right edge of
|
||||||
|
* the existing layout, stacked vertically, so they're visible without panning.
|
||||||
|
* Falls back to a fixed offset when there are no existing nodes.
|
||||||
|
*/
|
||||||
|
function makePlaceNewPosition(existing: { position: { x: number; y: number } }[]): () => { 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<string>; assocs: Set<string>; constraints: Set<string> } {
|
function snapshotIds(model: SysMLModel): { blocks: Set<string>; assocs: Set<string>; constraints: Set<string> } {
|
||||||
return {
|
return {
|
||||||
blocks: new Set(model.blocks.map(b => b.id)),
|
blocks: new Set(model.blocks.map(b => b.id)),
|
||||||
|
|||||||
208
apps/web/components/socrates/ProposalCard.tsx
Normal file
208
apps/web/components/socrates/ProposalCard.tsx
Normal 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}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,16 +8,19 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Sigil } from "./Sigil";
|
import { Sigil } from "./Sigil";
|
||||||
|
import { ProposalCard, type ProposalPayload } from "./ProposalCard";
|
||||||
|
import { useApply } from "../../lib/sync/ModelStore";
|
||||||
|
|
||||||
export type SocratesPresence = "subtle" | "default" | "prominent";
|
export type SocratesPresence = "subtle" | "default" | "prominent";
|
||||||
export type Density = "comfortable" | "compact";
|
export type Density = "comfortable" | "compact";
|
||||||
|
|
||||||
export interface DockMessage {
|
export interface DockMessage {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant" | "proposal" | "system";
|
||||||
text: string;
|
text: string;
|
||||||
options?: Array<{ n: number; label: string; sub?: string }>;
|
options?: Array<{ n: number; label: string; sub?: string }>;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
|
proposal?: ProposalPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SocratesDockProps {
|
interface SocratesDockProps {
|
||||||
@@ -38,9 +41,11 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) {
|
|||||||
const [messages, setMessages] = useState<DockMessage[]>([]);
|
const [messages, setMessages] = useState<DockMessage[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [proposing, setProposing] = useState(false);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [meta, setMeta] = useState<{ provider?: string; model?: string }>({});
|
const [meta, setMeta] = useState<{ provider?: string; model?: string }>({});
|
||||||
const threadEndRef = useRef<HTMLDivElement | null>(null);
|
const threadEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const apply = useApply();
|
||||||
|
|
||||||
// Load thread on mount and trigger an opening turn if the thread is empty.
|
// Load thread on mount and trigger an opening turn if the thread is empty.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,6 +146,76 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) {
|
|||||||
await sendImpl(text);
|
await sendImpl(text);
|
||||||
}, [sending, sendImpl]);
|
}, [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
|
// Number-key shortcuts on the most recent assistant turn with options
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -198,7 +273,41 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map(m => (
|
{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"}`}>
|
<div key={m.id} className={`bubble bubble-${m.role === "assistant" ? "socrates" : "user"}`}>
|
||||||
{m.role === "assistant" && <span className="bubble-sigil">Σ</span>}
|
{m.role === "assistant" && <span className="bubble-sigil">Σ</span>}
|
||||||
<span className="bubble-body">
|
<span className="bubble-body">
|
||||||
@@ -230,7 +339,8 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<div ref={threadEndRef} />
|
<div ref={threadEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -250,8 +360,16 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
disabled={sending}
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="dock-input-send"
|
className="dock-input-send"
|
||||||
@@ -265,3 +383,7 @@ export function SocratesDock({ projectId, presence }: SocratesDockProps) {
|
|||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n: number): string {
|
||||||
|
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||||||
|
}
|
||||||
|
|||||||
44
apps/web/lib/llm/prompts/socrates/propose.md
Normal file
44
apps/web/lib/llm/prompts/socrates/propose.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Mode: Propose a model change
|
||||||
|
|
||||||
|
You are reviewing the existing model and the active findings (assumptions, risks, inconsistencies). The PM has asked you to propose ONE concrete change to the model.
|
||||||
|
|
||||||
|
Pick the highest-value change you can make. Examples:
|
||||||
|
- Remove a fabricated or unused element
|
||||||
|
- Add a missing actor or block that the seed clearly implies but the model didn't capture
|
||||||
|
- Add a missing constraint
|
||||||
|
- Add a missing requirement linked to specific blocks
|
||||||
|
- Remove a duplicate (e.g., a constraint already covered by a requirement, or vice versa)
|
||||||
|
- Add an association the model is missing
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Return a JSON object with:
|
||||||
|
|
||||||
|
- `reasoning` — 1–3 sentences explaining what you propose to change and why. Reference specific element ids.
|
||||||
|
- `ops` — an array of operations. Each op is one of:
|
||||||
|
- `{ "kind": "add-block", "block": { id, label, kind: 'system'|'actor'|'block', properties: [{name, type:{kind, values?}}] } }`
|
||||||
|
- `{ "kind": "remove-block", "blockId": "id" }` (also removes dependent associations and references)
|
||||||
|
- `{ "kind": "add-association", "association": { id, fromBlockId, toBlockId, label, kind: 'association'|'composition'|'generalization' } }`
|
||||||
|
- `{ "kind": "remove-association", "associationId": "id" }`
|
||||||
|
- `{ "kind": "add-constraint", "constraint": { id, label, expression, appliesTo: ["blockId"] } }`
|
||||||
|
- `{ "kind": "remove-constraint", "constraintId": "id" }`
|
||||||
|
- `{ "kind": "add-requirement", "requirement": { id, tag, text, satisfiedBy: ["blockId"] } }`
|
||||||
|
- `{ "kind": "remove-requirement", "requirementId": "id" }`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Prefer SMALL changes. 1–4 ops is ideal. A proposal that rewrites half the model is too big.
|
||||||
|
- Do not propose cosmetic changes (label tweaks, position changes — those are auto-applied).
|
||||||
|
- Every id you reference (`blockId`, `associationId`, etc.) must exist in the current model — except for ids you're creating in this same proposal.
|
||||||
|
- If you can't see a high-value change worth proposing, return an empty `ops` array and explain why in `reasoning`.
|
||||||
|
|
||||||
|
## Output schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reasoning": "string (1–3 sentences)",
|
||||||
|
"ops": [ /* array of op objects per the kinds above */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Return ONLY the JSON object. No prose preamble, no code fences.
|
||||||
315
apps/web/lib/llm/proposeChange.ts
Normal file
315
apps/web/lib/llm/proposeChange.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
// Server-side: ask Socrates to propose a single model change.
|
||||||
|
//
|
||||||
|
// Returns { reasoning, ops } as a structured JSON object. The op schema uses
|
||||||
|
// oneOf per kind (Phase 0 lesson: small models cram all op fields into the
|
||||||
|
// kind name without it). Caller is responsible for applying the ops; this
|
||||||
|
// function only generates them.
|
||||||
|
|
||||||
|
import "server-only";
|
||||||
|
import { defaultGateway, chatJSON, type Message } from "./gateway";
|
||||||
|
import { loadPrompt } from "./prompts";
|
||||||
|
import type { SysMLModel } from "../sysml/model";
|
||||||
|
import type { ValidationIssue } from "../sysml/validate";
|
||||||
|
import type { ModelOp } from "../sync/ops";
|
||||||
|
|
||||||
|
// ─── Op-shape JSON Schema (oneOf per kind) ──────────────────────────────
|
||||||
|
|
||||||
|
const propertyTypeSchema = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["kind"],
|
||||||
|
properties: {
|
||||||
|
kind: { type: "string", enum: ["string", "number", "boolean", "enum"] },
|
||||||
|
values: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const blockShape = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["id", "label", "kind"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", minLength: 1 },
|
||||||
|
label: { type: "string", minLength: 1 },
|
||||||
|
kind: { type: "string", enum: ["system", "actor", "block"] },
|
||||||
|
properties: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["name", "type"],
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", minLength: 1 },
|
||||||
|
type: propertyTypeSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const associationShape = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["id", "fromBlockId", "toBlockId", "kind"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", minLength: 1 },
|
||||||
|
fromBlockId: { type: "string", minLength: 1 },
|
||||||
|
toBlockId: { type: "string", minLength: 1 },
|
||||||
|
label: { type: "string" },
|
||||||
|
kind: { type: "string", enum: ["association", "composition", "aggregation", "generalization"] },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const constraintShape = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["id", "label"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", minLength: 1 },
|
||||||
|
label: { type: "string", minLength: 1 },
|
||||||
|
expression: { type: "string" },
|
||||||
|
appliesTo: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const requirementShape = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["id", "tag", "text"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", minLength: 1 },
|
||||||
|
tag: { type: "string", minLength: 1 },
|
||||||
|
text: { type: "string", minLength: 1 },
|
||||||
|
satisfiedBy: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const proposalJsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["reasoning", "ops"],
|
||||||
|
properties: {
|
||||||
|
reasoning: { type: "string", minLength: 1 },
|
||||||
|
ops: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
oneOf: [
|
||||||
|
{ type: "object", additionalProperties: false, required: ["kind", "block"],
|
||||||
|
properties: { kind: { type: "string", enum: ["add-block"] }, block: blockShape } },
|
||||||
|
{ type: "object", additionalProperties: false, required: ["kind", "blockId"],
|
||||||
|
properties: { kind: { type: "string", enum: ["remove-block"] }, blockId: { type: "string", minLength: 1 } } },
|
||||||
|
{ type: "object", additionalProperties: false, required: ["kind", "association"],
|
||||||
|
properties: { kind: { type: "string", enum: ["add-association"] }, association: associationShape } },
|
||||||
|
{ type: "object", additionalProperties: false, required: ["kind", "associationId"],
|
||||||
|
properties: { kind: { type: "string", enum: ["remove-association"] }, associationId: { type: "string", minLength: 1 } } },
|
||||||
|
{ type: "object", additionalProperties: false, required: ["kind", "constraint"],
|
||||||
|
properties: { kind: { type: "string", enum: ["add-constraint"] }, constraint: constraintShape } },
|
||||||
|
{ type: "object", additionalProperties: false, required: ["kind", "constraintId"],
|
||||||
|
properties: { kind: { type: "string", enum: ["remove-constraint"] }, constraintId: { type: "string", minLength: 1 } } },
|
||||||
|
{ type: "object", additionalProperties: false, required: ["kind", "requirement"],
|
||||||
|
properties: { kind: { type: "string", enum: ["add-requirement"] }, requirement: requirementShape } },
|
||||||
|
{ type: "object", additionalProperties: false, required: ["kind", "requirementId"],
|
||||||
|
properties: { kind: { type: "string", enum: ["remove-requirement"] }, requirementId: { type: "string", minLength: 1 } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Public API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProposeArgs {
|
||||||
|
model: SysMLModel;
|
||||||
|
issues?: ValidationIssue[];
|
||||||
|
/** Optional user-supplied prompt narrowing what to propose. */
|
||||||
|
userPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawProposalOp {
|
||||||
|
kind: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProposeResult {
|
||||||
|
reasoning: string;
|
||||||
|
/** Ops in their LLM-emitted shape — caller normalizes to the canonical
|
||||||
|
* ModelOp tagged-union (this handles add-block.block.properties needing
|
||||||
|
* id/multiplicity defaults, etc.). */
|
||||||
|
ops: ModelOp[];
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function proposeChange(args: ProposeArgs): Promise<ProposeResult> {
|
||||||
|
const character = loadPrompt("socrates/character.md");
|
||||||
|
const propose = loadPrompt("socrates/propose.md");
|
||||||
|
|
||||||
|
const userPayload =
|
||||||
|
`Current model:\n\`\`\`json\n${JSON.stringify(trimModel(args.model), null, 2)}\n\`\`\`\n\n` +
|
||||||
|
(args.issues?.length
|
||||||
|
? `Active validation issues:\n\`\`\`json\n${JSON.stringify(args.issues.slice(0, 20), null, 2)}\n\`\`\`\n\n`
|
||||||
|
: "") +
|
||||||
|
(args.userPrompt
|
||||||
|
? `User has asked:\n${args.userPrompt}\n`
|
||||||
|
: `Pick the highest-value change you can make right now.\n`);
|
||||||
|
|
||||||
|
const messages: Message[] = [
|
||||||
|
{ role: "system", content: `${character}\n\n---\n\n${propose}` },
|
||||||
|
{ role: "user", content: userPayload },
|
||||||
|
];
|
||||||
|
|
||||||
|
const gateway = defaultGateway();
|
||||||
|
const { value, result } = await chatJSON<{ reasoning: string; ops: RawProposalOp[] }>(
|
||||||
|
gateway,
|
||||||
|
messages,
|
||||||
|
{
|
||||||
|
temperature: 0.3,
|
||||||
|
maxTokens: 1024,
|
||||||
|
jsonSchema: { name: "proposal", schema: proposalJsonSchema as Record<string, unknown> },
|
||||||
|
jsonObjectMode: true,
|
||||||
|
maxRepairs: 2,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const ops = normalizeOps(value?.ops ?? []);
|
||||||
|
return {
|
||||||
|
reasoning: typeof value?.reasoning === "string" ? value.reasoning : "",
|
||||||
|
ops,
|
||||||
|
inputTokens: result.inputTokens,
|
||||||
|
outputTokens: result.outputTokens,
|
||||||
|
provider: gateway.provider,
|
||||||
|
model: gateway.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Normalize the LLM-shape ops to canonical ModelOp ───────────────────
|
||||||
|
|
||||||
|
function normalizeOps(raw: RawProposalOp[]): ModelOp[] {
|
||||||
|
const out: ModelOp[] = [];
|
||||||
|
for (const op of raw) {
|
||||||
|
if (!op || typeof op.kind !== "string") continue;
|
||||||
|
switch (op.kind) {
|
||||||
|
case "add-block": {
|
||||||
|
const block = op.block as { id?: string; label?: string; kind?: string; properties?: Array<{ name: string; type: { kind: string; values?: string[] } }> } | undefined;
|
||||||
|
if (!block?.id || !block.label || !isBlockKind(block.kind)) continue;
|
||||||
|
out.push({
|
||||||
|
kind: "add-block",
|
||||||
|
block: {
|
||||||
|
id: block.id,
|
||||||
|
label: block.label,
|
||||||
|
kind: block.kind,
|
||||||
|
stereotypes: [block.kind],
|
||||||
|
properties: (block.properties ?? []).map((p, i) => ({
|
||||||
|
id: `p${i + 1}`,
|
||||||
|
name: p.name,
|
||||||
|
type: p.type.kind === "enum"
|
||||||
|
? { kind: "enum" as const, values: p.type.values ?? [] }
|
||||||
|
: { kind: p.type.kind as "string" | "number" | "boolean" },
|
||||||
|
multiplicity: "0..1" as const,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
tempId: block.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove-block": {
|
||||||
|
if (typeof op.blockId === "string") out.push({ kind: "remove-block", blockId: op.blockId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "add-association": {
|
||||||
|
const a = op.association as { id?: string; fromBlockId?: string; toBlockId?: string; label?: string; kind?: string } | undefined;
|
||||||
|
if (!a?.id || !a.fromBlockId || !a.toBlockId || !isAssocKind(a.kind)) continue;
|
||||||
|
out.push({
|
||||||
|
kind: "add-association",
|
||||||
|
association: {
|
||||||
|
id: a.id,
|
||||||
|
fromBlockId: a.fromBlockId,
|
||||||
|
toBlockId: a.toBlockId,
|
||||||
|
label: a.label ?? "",
|
||||||
|
kind: a.kind,
|
||||||
|
},
|
||||||
|
tempId: a.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove-association": {
|
||||||
|
if (typeof op.associationId === "string") out.push({ kind: "remove-association", associationId: op.associationId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "add-constraint": {
|
||||||
|
const c = op.constraint as { id?: string; label?: string; expression?: string; appliesTo?: string[] } | undefined;
|
||||||
|
if (!c?.id || !c.label) continue;
|
||||||
|
out.push({
|
||||||
|
kind: "add-constraint",
|
||||||
|
constraint: { id: c.id, label: c.label, expression: c.expression ?? "", appliesTo: c.appliesTo ?? [] },
|
||||||
|
tempId: c.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove-constraint": {
|
||||||
|
if (typeof op.constraintId === "string") out.push({ kind: "remove-constraint", constraintId: op.constraintId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "add-requirement": {
|
||||||
|
const r = op.requirement as { id?: string; tag?: string; text?: string; satisfiedBy?: string[] } | undefined;
|
||||||
|
if (!r?.id || !r.tag || !r.text) continue;
|
||||||
|
out.push({
|
||||||
|
kind: "add-requirement",
|
||||||
|
requirement: {
|
||||||
|
id: r.id,
|
||||||
|
tag: r.tag,
|
||||||
|
text: r.text,
|
||||||
|
relations: (r.satisfiedBy ?? []).map(b => ({ kind: "satisfy" as const, blockId: b })),
|
||||||
|
},
|
||||||
|
tempId: r.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove-requirement": {
|
||||||
|
if (typeof op.requirementId === "string") out.push({ kind: "remove-requirement", requirementId: op.requirementId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockKind(s: unknown): s is "system" | "actor" | "block" {
|
||||||
|
return s === "system" || s === "actor" || s === "block";
|
||||||
|
}
|
||||||
|
function isAssocKind(s: unknown): s is "association" | "composition" | "aggregation" | "generalization" {
|
||||||
|
return s === "association" || s === "composition" || s === "aggregation" || s === "generalization";
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimModel(model: SysMLModel): unknown {
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export function ModelStoreProvider({ initialModel, initialVersion, projectId, ch
|
|||||||
return result.applied ? result.model : current;
|
return result.applied ? result.model : current;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Background: POST to the server (best-effort; server is the truth).
|
// 2. Background: POST to the server (server is authoritative).
|
||||||
if (projectId && result.applied) {
|
if (projectId && result.applied) {
|
||||||
const expectedVersion = versionRef.current;
|
const expectedVersion = versionRef.current;
|
||||||
void fetch(`/api/projects/${encodeURIComponent(projectId)}/apply`, {
|
void fetch(`/api/projects/${encodeURIComponent(projectId)}/apply`, {
|
||||||
@@ -57,24 +57,31 @@ export function ModelStoreProvider({ initialModel, initialVersion, projectId, ch
|
|||||||
body: JSON.stringify({ ops, expectedVersion }),
|
body: JSON.stringify({ ops, expectedVersion }),
|
||||||
})
|
})
|
||||||
.then(async res => {
|
.then(async res => {
|
||||||
if (!res.ok) return;
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
console.error("[ModelStore] /apply HTTP", res.status, body.slice(0, 200));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
applied: boolean;
|
applied: boolean;
|
||||||
model: SysMLModel;
|
model: SysMLModel;
|
||||||
version: number;
|
version: number;
|
||||||
idMapping: Record<string, string>;
|
idMapping: Record<string, string>;
|
||||||
|
errors?: Array<{ opIndex: number; code: string; message: string }>;
|
||||||
};
|
};
|
||||||
// Always trust the server's model + version after a successful apply
|
if (!data.applied) {
|
||||||
|
console.warn(
|
||||||
|
"[ModelStore] server rejected ops; reverting to server state.",
|
||||||
|
data.errors,
|
||||||
|
"expected", expectedVersion, "got", data.version
|
||||||
|
);
|
||||||
|
}
|
||||||
versionRef.current = data.version;
|
versionRef.current = data.version;
|
||||||
setVersion(data.version);
|
setVersion(data.version);
|
||||||
// Note: server's model may differ in tempId resolution from our local
|
|
||||||
// optimistic apply. Replacing wholesale is safe because the model
|
|
||||||
// shape is structural; React Flow's useEffect will diff and update.
|
|
||||||
setModel(data.model);
|
setModel(data.model);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(err => {
|
||||||
// Network error — keep optimistic state. The next successful apply
|
console.error("[ModelStore] /apply network error:", err);
|
||||||
// will reconcile via the new server model.
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
164
apps/web/lib/sysml/impact.ts
Normal file
164
apps/web/lib/sysml/impact.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Pre-apply impact analysis: given the current model + a candidate set of
|
||||||
|
// ops, simulate the apply (without persisting) and report what would change.
|
||||||
|
//
|
||||||
|
// Used for the M7 proposal card so the PM can see consequences before
|
||||||
|
// approving. Composed of:
|
||||||
|
// - structural diff (added/removed/changed elements)
|
||||||
|
// - validation diff (issues created/resolved)
|
||||||
|
// - dep-graph blast radius (closure of element ids touched)
|
||||||
|
|
||||||
|
import { applyOps } from "../sync/applyOps";
|
||||||
|
import { validate, type ValidationIssue } from "./validate";
|
||||||
|
import { buildDepGraph, dependentsOf } from "./depgraph";
|
||||||
|
import type { SysMLModel } from "./model";
|
||||||
|
import type { ModelOp } from "../sync/ops";
|
||||||
|
|
||||||
|
export interface ElementSummary {
|
||||||
|
id: string;
|
||||||
|
kind: "block" | "association" | "constraint" | "requirement" | "property";
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImpactSummary {
|
||||||
|
/** Was the candidate apply legal at all? Errors mean something rejected. */
|
||||||
|
ok: boolean;
|
||||||
|
errors: Array<{ opIndex: number; code: string; message: string }>;
|
||||||
|
|
||||||
|
added: ElementSummary[];
|
||||||
|
removed: ElementSummary[];
|
||||||
|
changed: ElementSummary[];
|
||||||
|
|
||||||
|
issuesCreated: ValidationIssue[];
|
||||||
|
issuesResolved: ValidationIssue[];
|
||||||
|
|
||||||
|
/** All element ids in the dep-graph closure of the touched elements. */
|
||||||
|
blastRadius: string[];
|
||||||
|
|
||||||
|
/** Compact stats for headline display. */
|
||||||
|
stats: {
|
||||||
|
blocksDelta: number;
|
||||||
|
associationsDelta: number;
|
||||||
|
constraintsDelta: number;
|
||||||
|
requirementsDelta: number;
|
||||||
|
issuesDelta: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeImpact(currentModel: SysMLModel, ops: ModelOp[]): ImpactSummary {
|
||||||
|
const beforeIssues = validate(currentModel);
|
||||||
|
const result = applyOps(currentModel, ops);
|
||||||
|
|
||||||
|
if (!result.applied) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
errors: result.errors,
|
||||||
|
added: [], removed: [], changed: [],
|
||||||
|
issuesCreated: [], issuesResolved: [],
|
||||||
|
blastRadius: [],
|
||||||
|
stats: { blocksDelta: 0, associationsDelta: 0, constraintsDelta: 0, requirementsDelta: 0, issuesDelta: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = result.model;
|
||||||
|
const afterIssues = validate(after);
|
||||||
|
|
||||||
|
const beforeBlocks = idMap(currentModel.blocks);
|
||||||
|
const afterBlocks = idMap(after.blocks);
|
||||||
|
const beforeAssocs = idMap(currentModel.associations);
|
||||||
|
const afterAssocs = idMap(after.associations);
|
||||||
|
const beforeCons = idMap(currentModel.constraints);
|
||||||
|
const afterCons = idMap(after.constraints);
|
||||||
|
const beforeReqs = idMap(currentModel.requirements);
|
||||||
|
const afterReqs = idMap(after.requirements);
|
||||||
|
|
||||||
|
const added: ElementSummary[] = [];
|
||||||
|
const removed: ElementSummary[] = [];
|
||||||
|
const changed: ElementSummary[] = [];
|
||||||
|
|
||||||
|
// Blocks
|
||||||
|
for (const [id, b] of afterBlocks) {
|
||||||
|
const prev = beforeBlocks.get(id);
|
||||||
|
if (!prev) added.push({ id, kind: "block", label: b.label });
|
||||||
|
else if (prev.label !== b.label || prev.kind !== b.kind || prev.properties.length !== b.properties.length) {
|
||||||
|
changed.push({ id, kind: "block", label: b.label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, b] of beforeBlocks) {
|
||||||
|
if (!afterBlocks.has(id)) removed.push({ id, kind: "block", label: b.label });
|
||||||
|
}
|
||||||
|
// Associations
|
||||||
|
for (const [id, a] of afterAssocs) {
|
||||||
|
const prev = beforeAssocs.get(id);
|
||||||
|
if (!prev) added.push({ id, kind: "association", label: a.label || `${a.fromBlockId}→${a.toBlockId}` });
|
||||||
|
else if (prev.label !== a.label || prev.kind !== a.kind || prev.fromBlockId !== a.fromBlockId || prev.toBlockId !== a.toBlockId) {
|
||||||
|
changed.push({ id, kind: "association", label: a.label || `${a.fromBlockId}→${a.toBlockId}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, a] of beforeAssocs) {
|
||||||
|
if (!afterAssocs.has(id)) removed.push({ id, kind: "association", label: a.label || `${a.fromBlockId}→${a.toBlockId}` });
|
||||||
|
}
|
||||||
|
// Constraints
|
||||||
|
for (const [id, c] of afterCons) {
|
||||||
|
const prev = beforeCons.get(id);
|
||||||
|
if (!prev) added.push({ id, kind: "constraint", label: c.label });
|
||||||
|
else if (prev.label !== c.label || prev.expression !== c.expression || prev.appliesTo.length !== c.appliesTo.length) {
|
||||||
|
changed.push({ id, kind: "constraint", label: c.label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, c] of beforeCons) {
|
||||||
|
if (!afterCons.has(id)) removed.push({ id, kind: "constraint", label: c.label });
|
||||||
|
}
|
||||||
|
// Requirements
|
||||||
|
for (const [id, r] of afterReqs) {
|
||||||
|
const prev = beforeReqs.get(id);
|
||||||
|
if (!prev) added.push({ id, kind: "requirement", label: r.tag });
|
||||||
|
else if (prev.tag !== r.tag || prev.text !== r.text || prev.relations.length !== r.relations.length) {
|
||||||
|
changed.push({ id, kind: "requirement", label: r.tag });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, r] of beforeReqs) {
|
||||||
|
if (!afterReqs.has(id)) removed.push({ id, kind: "requirement", label: r.tag });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation diff (by message — issues carry no stable id)
|
||||||
|
const beforeKeys = new Set(beforeIssues.map(issueKey));
|
||||||
|
const afterKeys = new Set(afterIssues.map(issueKey));
|
||||||
|
const issuesCreated = afterIssues.filter(i => !beforeKeys.has(issueKey(i)));
|
||||||
|
const issuesResolved = beforeIssues.filter(i => !afterKeys.has(issueKey(i)));
|
||||||
|
|
||||||
|
// Dep-graph blast radius — closure over each touched element on the AFTER graph
|
||||||
|
const graph = buildDepGraph(after);
|
||||||
|
const touched = new Set<string>([
|
||||||
|
...added.map(e => e.id),
|
||||||
|
...removed.map(e => e.id),
|
||||||
|
...changed.map(e => e.id),
|
||||||
|
]);
|
||||||
|
const blast = new Set<string>();
|
||||||
|
for (const id of touched) {
|
||||||
|
blast.add(id);
|
||||||
|
for (const r of dependentsOf(graph, id)) blast.add(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
errors: [],
|
||||||
|
added, removed, changed,
|
||||||
|
issuesCreated, issuesResolved,
|
||||||
|
blastRadius: [...blast],
|
||||||
|
stats: {
|
||||||
|
blocksDelta: after.blocks.length - currentModel.blocks.length,
|
||||||
|
associationsDelta: after.associations.length - currentModel.associations.length,
|
||||||
|
constraintsDelta: after.constraints.length - currentModel.constraints.length,
|
||||||
|
requirementsDelta: after.requirements.length - currentModel.requirements.length,
|
||||||
|
issuesDelta: afterIssues.length - beforeIssues.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function idMap<T extends { id: string }>(items: T[]): Map<string, T> {
|
||||||
|
return new Map(items.map(i => [i.id, i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueKey(i: ValidationIssue): string {
|
||||||
|
return `${i.code}|${i.severity}|${i.message}`;
|
||||||
|
}
|
||||||
@@ -1094,3 +1094,154 @@ button { font-family: inherit; }
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Proposal card (M7) ─── */
|
||||||
|
.proposal-card {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 6px 0;
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
font-family: var(--font-prose);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--prose);
|
||||||
|
}
|
||||||
|
.proposal-card-pending { opacity: 0.6; }
|
||||||
|
.proposal-card-head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
.proposal-card-tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-on);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.proposal-card-stats {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.proposal-card-reasoning {
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.proposal-card-ops {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
.proposal-card-ops summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.proposal-card-ops ul {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
.proposal-card-ops li { margin-bottom: 2px; }
|
||||||
|
.proposal-card-section {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.proposal-card-section-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9.5px;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.proposal-card-impact-grid {
|
||||||
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
|
}
|
||||||
|
.proposal-card-impact-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
.proposal-card-impact-key { color: var(--muted-strong); }
|
||||||
|
.proposal-card-impact-val { color: var(--fg); word-break: break-word; }
|
||||||
|
.proposal-card-issue-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 14px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.proposal-card-issue-list li { line-height: 1.4; }
|
||||||
|
.proposal-card-issues-resolved li { color: var(--ok-strong); }
|
||||||
|
.proposal-card-issues-created li { color: var(--warn-strong); }
|
||||||
|
.proposal-card-error { color: var(--warn-strong); }
|
||||||
|
.proposal-card-actions {
|
||||||
|
display: flex; gap: 6px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
.proposal-card-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.proposal-card-btn:hover:not(:disabled) { background: var(--surface-2); }
|
||||||
|
.proposal-card-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.proposal-card-btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-on);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.proposal-card-btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
.proposal-card-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System bubble (apply confirmation, dismissals) */
|
||||||
|
.bubble.bubble-system {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Propose button next to send */
|
||||||
|
.dock-input-propose {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dock-input-propose:hover:not(:disabled) {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.dock-input-propose:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user