Pivot to text-first column-stack workspace + merge-with-review across AI artifacts
Workspace - Pivot from "set of open panes" to a Finder-style miller column stack: TopBar / LeftSidebar / [section → entity → entity ...] / pinned text editor. openPanesStore is now an ordered Column[] with pushFrom / closeFrom / setStack; only one top-level section is rooted at a time. - New entity column panes: Term, Block, Association, Constraint, Requirement, Finding. Click-through navigation truncates deeper columns automatically. - LeftSidebar surfaces a pending-count chip per section (single-glance navigation cue) and spins its analyze ↻ via SVG Spinner whenever the LLM is working — including server-initiated runs caught by the runs poll, not just user-triggered ones. Analyze pipeline + persistence - Unified `concepts` pass (taxonomy + glossary in one LLM call) replaces the two-pass setup. Server still accepts ?section=taxonomy|glossary and normalizes them for back-compat. - model / requirements / detection (assumptions, risks, inconsistencies) + cross-layer validation rules (X1–X4: stale term link, unlinked formalism, undefined linked term, prose-only term). - Persistence: NarrativeDocument, ModelSnapshot, ChangelogEntry, TaxonomyTerm, RequirementEntry, Finding, AnalysisRun. Re-runs MERGE instead of replace: gentle update on existing items, suggested on new, deprecated on missing — same idiom for every artifact kind. User pins preserve "kept" decisions across re-analyses. - Migrations: pivot_text_first, add_requirement_linked_term, term_review_state, review_state_for_reqs_and_findings, add_term_definition_pinned. Concept ↔ ontology integration - linkedTermId on Block / Association / Constraint / Requirement. PromoteToolbar lets the user formalize a concept inline: + Block / + Association / + Constraint / + Requirement, all routed through applyOps so undo/redo and SSE work for free. - decideElement op for in-canvas keep/discard on review-pending model elements. User-authored definitions - TermColumn definition is click-to-edit. Save (Cmd-Enter / blur), Cancel (Esc), Reset to AI suggestion when pinned. - definitionPinned flag on TaxonomyTerm: future Analyze runs leave the user's text alone. setTermDefinition repo function + POST /api/projects/[id]/terms/[termId]/definition endpoint. - mergeTaxonomySuggestion + applyGlossaryDefinitions both pin-aware. UX/UI - StatusChip: single component for all state idioms (suggested, deprecated, accepted, dismissed, resolved, severity, validation code, confidence, warn). Replaces 5+ ad-hoc badge classes. - PaneControls (PaneViewTabs + PaneFilterChip): separates view-mode toggles from filter chips so toggling Pending no longer flips you off the current view. - PaneEmpty: unified empty-state with title + hint + action. - PaneDrawer: collapsible groups for Pending / Discarded review; cards group as Kept (top) → Pending (bottom drawer) → Discarded (Findings only, hidden when empty). Restore action recovers dismissed/resolved findings. - ConceptCard unifies Tree and A–Z views in Concepts; only Tree parents carry the chevron (no empty placeholder offset). - Type + spacing tokens (--text-xs..xl, --space-1..6, --lh-tight/ui/ prose, --radius-*) replace every ad-hoc value. - Buttons standardized to body sans 500 (was a mishmash of mono / display). - Card shells unified across Concepts / Requirements / Findings. Cleanup - Removed: LeftRail, FindingsPanel, IssuesPanel, SocratesDock, ProposalCard, SlashMenu, SlashExtension, slashSuggestion, CanvasHeader, TaxonomyPane, GlossaryPane, TermDetail (popover; now TermColumn). - Section ids in openPanesStore: dropped taxonomy/glossary, added concepts. localStorage migration runs on hydrate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
33
apps/web/app/api/projects/[projectId]/analyze/route.ts
Normal file
33
apps/web/app/api/projects/[projectId]/analyze/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Analyze endpoint. POST with optional ?section= to run one section, or no
|
||||
// query param (or ?section=all) to run the full pipeline.
|
||||
//
|
||||
// Legacy section names "taxonomy" and "glossary" are accepted and mapped to
|
||||
// the unified "concepts" pass (T3 of the integration plan). This keeps older
|
||||
// clients and any external scripts working through one release.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { runAnalyze, type RunSection } from "../../../../../lib/llm/analyze/runAll";
|
||||
import { normalizeAnalysisSection } from "../../../../../lib/db/repo";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}
|
||||
|
||||
export async function POST(req: Request, ctx: RouteContext) {
|
||||
const { projectId } = await ctx.params;
|
||||
const url = new URL(req.url);
|
||||
const sectionRaw = (url.searchParams.get("section") ?? "all").toLowerCase();
|
||||
let section: RunSection;
|
||||
try {
|
||||
section = normalizeAnalysisSection(sectionRaw) as RunSection;
|
||||
} catch {
|
||||
return NextResponse.json({ error: `Invalid section: ${sectionRaw}` }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await runAnalyze(projectId, section);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
console.error("[analyze] route failed:", err);
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
31
apps/web/app/api/projects/[projectId]/document/route.ts
Normal file
31
apps/web/app/api/projects/[projectId]/document/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Narrative document save endpoint. The TipTap editor PUTs the current
|
||||
// ProseMirror JSON here on debounce. Source of truth after the pivot.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadDocument, saveDocument } from "../../../../../lib/db/repo";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, ctx: RouteContext) {
|
||||
const { projectId } = await ctx.params;
|
||||
const doc = await loadDocument(projectId);
|
||||
if (!doc) return NextResponse.json({ doc: null, version: 0 });
|
||||
return NextResponse.json({ doc: doc.doc, version: doc.version, updatedAt: doc.updatedAt });
|
||||
}
|
||||
|
||||
export async function PUT(req: Request, ctx: RouteContext) {
|
||||
const { projectId } = await ctx.params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
if (!body || typeof body !== "object" || !("doc" in body)) {
|
||||
return NextResponse.json({ error: "Missing `doc` field" }, { status: 400 });
|
||||
}
|
||||
const stored = await saveDocument(projectId, (body as { doc: unknown }).doc);
|
||||
return NextResponse.json({ version: stored.version, updatedAt: stored.updatedAt });
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Per-finding keep/discard/resolve/restore decision.
|
||||
//
|
||||
// Body: { decision: "keep" | "discard" | "resolve" | "restore" }
|
||||
// keep → status="accepted" (visible). If was "deprecated", also pinned.
|
||||
// discard → status="dismissed". Persists; future detections skip it.
|
||||
// resolve → status="resolved". Like discard but signals "I fixed it."
|
||||
// restore → status="accepted", pinned cleared. Recovers a dismissed/
|
||||
// resolved finding from the Discarded drawer.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
setFindingDecision,
|
||||
restoreFinding,
|
||||
type FindingDecision,
|
||||
} from "../../../../../../../lib/db/repo";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string; findingId: string }>;
|
||||
}
|
||||
|
||||
export async function POST(req: Request, ctx: RouteContext) {
|
||||
const { findingId } = await ctx.params;
|
||||
let body: { decision?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
|
||||
}
|
||||
const decision = body.decision;
|
||||
if (
|
||||
decision !== "keep" &&
|
||||
decision !== "discard" &&
|
||||
decision !== "resolve" &&
|
||||
decision !== "restore"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "decision must be 'keep', 'discard', 'resolve', or 'restore'" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const finding =
|
||||
decision === "restore"
|
||||
? await restoreFinding(findingId)
|
||||
: await setFindingDecision(findingId, decision as FindingDecision);
|
||||
return NextResponse.json({ finding });
|
||||
} catch (err) {
|
||||
console.error("[finding/decision] failed:", err);
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Per-finding contextual Socrates thread.
|
||||
// GET → loads existing messages.
|
||||
// POST → sends a user turn; if the thread is empty, primes it with the
|
||||
// finding text so Socrates' opening turn engages with the finding.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../../../lib/db/client";
|
||||
import { loadProject } from "../../../../../../../lib/db/repo";
|
||||
import { validate } from "../../../../../../../lib/sysml/validate";
|
||||
import {
|
||||
getOrCreateAnchoredThread,
|
||||
listAnchoredThreadMessages,
|
||||
sendUserTurn,
|
||||
} from "../../../../../../../lib/llm/socrates";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string; findingId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, ctx: RouteContext) {
|
||||
const { projectId, findingId } = await ctx.params;
|
||||
const finding = await prisma.finding.findUnique({ where: { id: findingId } });
|
||||
if (!finding || finding.projectId !== projectId) {
|
||||
return NextResponse.json({ error: "finding not found" }, { status: 404 });
|
||||
}
|
||||
const threadId = await getOrCreateAnchoredThread(projectId, `finding:${findingId}`, finding.text);
|
||||
const messages = await listAnchoredThreadMessages(threadId);
|
||||
return NextResponse.json({ threadId, finding: { id: finding.id, text: finding.text, kind: finding.kind }, messages });
|
||||
}
|
||||
|
||||
export async function POST(req: Request, ctx: RouteContext) {
|
||||
const { projectId, findingId } = await ctx.params;
|
||||
let body: { text?: string } = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const finding = await prisma.finding.findUnique({ where: { id: findingId } });
|
||||
if (!finding || finding.projectId !== projectId) {
|
||||
return NextResponse.json({ error: "finding not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const threadId = await getOrCreateAnchoredThread(projectId, `finding:${findingId}`, finding.text);
|
||||
|
||||
// If the thread is empty, prime with the finding text as the opening user
|
||||
// turn so Socrates engages with the right thing.
|
||||
const existing = await listAnchoredThreadMessages(threadId);
|
||||
let userText = (body.text ?? "").trim();
|
||||
if (existing.length === 0 && !userText) {
|
||||
userText = `Discuss this ${finding.kind}: "${finding.text}"`;
|
||||
}
|
||||
|
||||
const { model } = await loadProject(projectId);
|
||||
const issues = validate(model);
|
||||
|
||||
const result = await sendUserTurn({ threadId, model, issues, userText });
|
||||
return NextResponse.json({ threadId, ...result });
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, ctx: RouteContext) {
|
||||
// Resolve / dismiss the finding from the contextual thread.
|
||||
const { projectId, findingId } = await ctx.params;
|
||||
let body: { status?: string } = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const status = body.status;
|
||||
if (status !== "resolved" && status !== "dismissed" && status !== "open") {
|
||||
return NextResponse.json({ error: "invalid status" }, { status: 400 });
|
||||
}
|
||||
const finding = await prisma.finding.findUnique({ where: { id: findingId } });
|
||||
if (!finding || finding.projectId !== projectId) {
|
||||
return NextResponse.json({ error: "finding not found" }, { status: 404 });
|
||||
}
|
||||
await prisma.finding.update({ where: { id: findingId }, data: { status } });
|
||||
return NextResponse.json({ ok: true, status });
|
||||
}
|
||||
@@ -1,27 +1,51 @@
|
||||
// GET /api/projects/[id]/findings — returns the project's open findings
|
||||
// POST /api/projects/[id]/findings — runs detection, persists, returns the new set
|
||||
// GET /api/projects/[id]/findings — returns the project's currently visible
|
||||
// findings (suggested + accepted + deprecated)
|
||||
// POST /api/projects/[id]/findings — runs detection, MERGES (no wipe), returns
|
||||
// the new set. Suggestions and deprecations
|
||||
// await user keep/discard via the
|
||||
// /findings/[findingId]/decision endpoint.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadProject, listOpenFindings, replaceFindings } from "../../../../../lib/db/repo";
|
||||
import {
|
||||
loadProject,
|
||||
listVisibleFindings,
|
||||
listDiscardedFindings,
|
||||
mergeFindingsSuggestion,
|
||||
} from "../../../../../lib/db/repo";
|
||||
import { detectFindings } from "../../../../../lib/llm/detect";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ projectId: string }> }) {
|
||||
function toApi(f: Awaited<ReturnType<typeof listVisibleFindings>>[number]) {
|
||||
return {
|
||||
id: f.id,
|
||||
kind: f.kind,
|
||||
text: f.text,
|
||||
linkedElementIds: f.linkedElementIds,
|
||||
confidence: f.confidence,
|
||||
severity: f.severity,
|
||||
validationCode: f.validationCode,
|
||||
status: f.status,
|
||||
pinned: f.pinned,
|
||||
modelVersion: f.modelVersion,
|
||||
provider: f.provider,
|
||||
llmModel: f.llmModel,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ projectId: string }> }) {
|
||||
const { projectId } = await params;
|
||||
const findings = await listOpenFindings(projectId);
|
||||
return NextResponse.json({
|
||||
findings: findings.map(f => ({
|
||||
id: f.id,
|
||||
kind: f.kind,
|
||||
text: f.text,
|
||||
linkedElementIds: f.linkedElementIds,
|
||||
confidence: f.confidence,
|
||||
severity: f.severity,
|
||||
validationCode: f.validationCode,
|
||||
modelVersion: f.modelVersion,
|
||||
provider: f.provider,
|
||||
llmModel: f.llmModel,
|
||||
})),
|
||||
});
|
||||
// ?include=all merges in dismissed + resolved so the FindingsPane Discarded
|
||||
// drawer can render them. Default stays visible-only for back-compat.
|
||||
const url = new URL(req.url);
|
||||
const include = (url.searchParams.get("include") ?? "").toLowerCase();
|
||||
if (include === "all") {
|
||||
const [visible, discarded] = await Promise.all([
|
||||
listVisibleFindings(projectId),
|
||||
listDiscardedFindings(projectId),
|
||||
]);
|
||||
return NextResponse.json({ findings: [...visible, ...discarded].map(toApi) });
|
||||
}
|
||||
const findings = await listVisibleFindings(projectId);
|
||||
return NextResponse.json({ findings: findings.map(toApi) });
|
||||
}
|
||||
|
||||
export async function POST(_req: Request, { params }: { params: Promise<{ projectId: string }> }) {
|
||||
@@ -29,21 +53,16 @@ export async function POST(_req: Request, { params }: { params: Promise<{ projec
|
||||
try {
|
||||
const { model, version } = await loadProject(projectId);
|
||||
const result = await detectFindings(model);
|
||||
const stored = await replaceFindings(projectId, result.findings, version, result.provider, result.model);
|
||||
const stored = await mergeFindingsSuggestion(
|
||||
projectId,
|
||||
result.findings,
|
||||
version,
|
||||
result.provider,
|
||||
result.model
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
findings: stored.map(f => ({
|
||||
id: f.id,
|
||||
kind: f.kind,
|
||||
text: f.text,
|
||||
linkedElementIds: f.linkedElementIds,
|
||||
confidence: f.confidence,
|
||||
severity: f.severity,
|
||||
validationCode: f.validationCode,
|
||||
modelVersion: f.modelVersion,
|
||||
provider: f.provider,
|
||||
llmModel: f.llmModel,
|
||||
})),
|
||||
findings: stored.map(toApi),
|
||||
meta: {
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Per-requirement keep/discard decision. Mirrors the term decision endpoint
|
||||
// (see /api/projects/[id]/terms/[termId]/decision).
|
||||
//
|
||||
// Body: { decision: "keep" | "discard" }
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { setRequirementDecision, type RequirementDecision } from "../../../../../../../lib/db/repo";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string; reqId: string }>;
|
||||
}
|
||||
|
||||
export async function POST(req: Request, ctx: RouteContext) {
|
||||
const { reqId } = await ctx.params;
|
||||
let body: { decision?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
|
||||
}
|
||||
const decision = body.decision;
|
||||
if (decision !== "keep" && decision !== "discard") {
|
||||
return NextResponse.json(
|
||||
{ error: "decision must be 'keep' or 'discard'" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const requirement = await setRequirementDecision(reqId, decision as RequirementDecision);
|
||||
return NextResponse.json({ requirement });
|
||||
} catch (err) {
|
||||
console.error("[requirement/decision] failed:", err);
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/web/app/api/projects/[projectId]/requirements/route.ts
Normal file
12
apps/web/app/api/projects/[projectId]/requirements/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listRequirements } from "../../../../../lib/db/repo";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, ctx: RouteContext) {
|
||||
const { projectId } = await ctx.params;
|
||||
const reqs = await listRequirements(projectId);
|
||||
return NextResponse.json({ requirements: reqs });
|
||||
}
|
||||
12
apps/web/app/api/projects/[projectId]/runs/route.ts
Normal file
12
apps/web/app/api/projects/[projectId]/runs/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listLatestAnalysisRuns } from "../../../../../lib/db/repo";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, ctx: RouteContext) {
|
||||
const { projectId } = await ctx.params;
|
||||
const runs = await listLatestAnalysisRuns(projectId);
|
||||
return NextResponse.json({ runs });
|
||||
}
|
||||
12
apps/web/app/api/projects/[projectId]/taxonomy/route.ts
Normal file
12
apps/web/app/api/projects/[projectId]/taxonomy/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listTerms } from "../../../../../lib/db/repo";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, ctx: RouteContext) {
|
||||
const { projectId } = await ctx.params;
|
||||
const terms = await listTerms(projectId);
|
||||
return NextResponse.json({ terms });
|
||||
}
|
||||
20
apps/web/app/api/projects/[projectId]/term-link/route.ts
Normal file
20
apps/web/app/api/projects/[projectId]/term-link/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { linkTermToBlock } from "../../../../../lib/db/repo";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}
|
||||
|
||||
export async function POST(req: Request, _ctx: RouteContext) {
|
||||
let body: { termId?: string; blockId?: string | null } = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
if (!body.termId) {
|
||||
return NextResponse.json({ error: "Missing termId" }, { status: 400 });
|
||||
}
|
||||
await linkTermToBlock(body.termId, body.blockId ?? null);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Per-term keep/discard decision. Drives the merge-with-review flow:
|
||||
// `mergeTaxonomySuggestion` marks pending changes; this endpoint confirms or
|
||||
// rejects them.
|
||||
//
|
||||
// Body: { decision: "keep" | "discard" }
|
||||
// keep → status="accepted"; if previously "deprecated", also pinned=true
|
||||
// so the next merge doesn't re-deprecate it.
|
||||
// discard → row deleted.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { setTermDecision, type TermDecision } from "../../../../../../../lib/db/repo";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string; termId: string }>;
|
||||
}
|
||||
|
||||
export async function POST(req: Request, ctx: RouteContext) {
|
||||
const { termId } = await ctx.params;
|
||||
let body: { decision?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
|
||||
}
|
||||
const decision = body.decision;
|
||||
if (decision !== "keep" && decision !== "discard") {
|
||||
return NextResponse.json(
|
||||
{ error: "decision must be 'keep' or 'discard'" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const term = await setTermDecision(termId, decision as TermDecision);
|
||||
return NextResponse.json({ term });
|
||||
} catch (err) {
|
||||
console.error("[term/decision] failed:", err);
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// User-authored definition for a concept term.
|
||||
//
|
||||
// Body: { definition: string | null }
|
||||
// non-empty trimmed → set definition + pin (future Analyze leaves it alone)
|
||||
// empty / null → clear definition + clear pin ("Reset to AI suggestion")
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { setTermDefinition } from "../../../../../../../lib/db/repo";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ projectId: string; termId: string }>;
|
||||
}
|
||||
|
||||
export async function POST(req: Request, ctx: RouteContext) {
|
||||
const { termId } = await ctx.params;
|
||||
let body: { definition?: string | null };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
|
||||
}
|
||||
if (body.definition !== null && typeof body.definition !== "string" && typeof body.definition !== "undefined") {
|
||||
return NextResponse.json(
|
||||
{ error: "definition must be a string or null" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const term = await setTermDefinition(termId, body.definition ?? null);
|
||||
return NextResponse.json({ term });
|
||||
} catch (err) {
|
||||
console.error("[term/definition] failed:", err);
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
18
apps/web/app/api/projects/route.ts
Normal file
18
apps/web/app/api/projects/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// GET /api/projects — list projects on this server.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { listProjects } from "../../../lib/db/repo";
|
||||
|
||||
export async function GET() {
|
||||
const projects = await listProjects();
|
||||
return NextResponse.json({
|
||||
projects: projects.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
scope: p.scope,
|
||||
tagline: p.tagline,
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
latestVersion: p.latestVersion,
|
||||
})),
|
||||
});
|
||||
}
|
||||
51
apps/web/app/api/seed/finalize/route.ts
Normal file
51
apps/web/app/api/seed/finalize/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// POST /api/seed/finalize
|
||||
//
|
||||
// Pivot flow: turn the SeedDraft into structured prose, persist it as the
|
||||
// NarrativeDocument, create an empty model snapshot, and return the new
|
||||
// project id so the client can navigate to /editor/[id]. The analyze
|
||||
// pipeline runs on first paint of the editor (or via Analyze All).
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { createProjectFromSeed, saveDocument } from "../../../../lib/db/repo";
|
||||
import { seedToProseDoc } from "../../../../lib/llm/seedToProse";
|
||||
import { runAnalyze } from "../../../../lib/llm/analyze/runAll";
|
||||
import type { SeedDraft } from "../../../../lib/llm/seedInterview";
|
||||
import type { SysMLModel } from "../../../../lib/sysml/model";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { draft?: SeedDraft };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
|
||||
}
|
||||
const draft = body.draft;
|
||||
if (!draft || !draft.problem || !draft.targetUser || !draft.desiredOutcome) {
|
||||
return NextResponse.json(
|
||||
{ error: "draft must include problem, targetUser, desiredOutcome" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const emptyModel: SysMLModel = { blocks: [], associations: [], constraints: [], requirements: [] };
|
||||
const created = await createProjectFromSeed({
|
||||
name: draft.title?.trim() || "Untitled idea",
|
||||
scope: "Untitled scope",
|
||||
tagline: draft.problem.slice(0, 80),
|
||||
model: emptyModel,
|
||||
});
|
||||
await saveDocument(created.projectId, seedToProseDoc(draft));
|
||||
|
||||
// Kick off the full Analyze pipeline in the background so the editor lands
|
||||
// populated. Errors are logged but do not block the redirect — the user
|
||||
// can hit Analyze All again from the TopBar if anything goes sideways.
|
||||
void runAnalyze(created.projectId, "all").catch(err =>
|
||||
console.error(`[seed/finalize] background analyze failed for ${created.projectId}:`, err)
|
||||
);
|
||||
|
||||
return NextResponse.json({ projectId: created.projectId, version: created.version });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
44
apps/web/app/api/seed/turn/route.ts
Normal file
44
apps/web/app/api/seed/turn/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// POST /api/seed/turn
|
||||
//
|
||||
// Per-turn handler for the live seed interview. Stateless — client passes
|
||||
// the running thread + draft + user text; we return the next assistant turn
|
||||
// + an updated draft + a `ready` flag.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { interviewStep, type InterviewTurn, type SeedDraft } from "../../../../lib/llm/seedInterview";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { history?: InterviewTurn[]; userText?: string; draft?: SeedDraft };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const history = Array.isArray(body.history) ? body.history : [];
|
||||
const draft: SeedDraft = body.draft ?? {
|
||||
title: "",
|
||||
problem: "",
|
||||
targetUser: "",
|
||||
desiredOutcome: "",
|
||||
};
|
||||
const userText = body.userText ?? "";
|
||||
|
||||
try {
|
||||
const result = await interviewStep({ history, userText, draft });
|
||||
return NextResponse.json({
|
||||
assistant: { text: result.text },
|
||||
draft: result.draft,
|
||||
confidence: result.confidence,
|
||||
ready: result.ready,
|
||||
meta: {
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
inputTokens: result.inputTokens,
|
||||
outputTokens: result.outputTokens,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,67 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "../../../lib/db/client";
|
||||
import { EditorShell } from "../../../components/editor/EditorShell";
|
||||
import { aristotleFixture } from "../../../lib/fixtures/aristotle";
|
||||
import { loadProject, ensureAristotleSeeded, ProjectNotFoundError } from "../../../lib/db/repo";
|
||||
|
||||
// M1: every projectId resolves to the Aristotle fixture.
|
||||
// M4–M5 wire this to a real database lookup.
|
||||
// (EditorShell wraps itself in Suspense for useSearchParams; no boundary
|
||||
// needed at this level.)
|
||||
export default async function EditorPage(_props: { params: Promise<{ projectId: string }> }) {
|
||||
return <EditorShell data={aristotleFixture} />;
|
||||
// Server component: loads the latest model + project metadata. Auto-seeds the
|
||||
// Aristotle demo for the literal "aristotle" id; any other unknown id 404s.
|
||||
export default async function EditorPage(props: { params: Promise<{ projectId: string }> }) {
|
||||
const { projectId } = await props.params;
|
||||
|
||||
if (projectId === "aristotle") await ensureAristotleSeeded();
|
||||
|
||||
let model, version;
|
||||
try {
|
||||
({ model, version } = await loadProject(projectId));
|
||||
} catch (err) {
|
||||
if (err instanceof ProjectNotFoundError) notFound();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const projectRow = await prisma.project.findUnique({ where: { id: projectId } });
|
||||
if (!projectRow) notFound();
|
||||
|
||||
// The narrative is fixture-derived for the Aristotle demo only. For any
|
||||
// other project we render a near-empty fixture so the prose surface
|
||||
// doesn't lie about the project's actual content. (Future polish: have
|
||||
// the seed-finalize step also produce an opening narrative.)
|
||||
const isAristotle = projectId === "aristotle";
|
||||
const fixtureForUi = isAristotle
|
||||
? aristotleFixture
|
||||
: {
|
||||
...aristotleFixture,
|
||||
project: {
|
||||
...aristotleFixture.project,
|
||||
name: projectRow.name,
|
||||
scope: projectRow.scope,
|
||||
tagline: projectRow.tagline,
|
||||
},
|
||||
narrative: [
|
||||
{ type: "h1" as const, text: projectRow.name },
|
||||
{
|
||||
type: "p" as const,
|
||||
children: [
|
||||
{ t: "text" as const, v: projectRow.tagline },
|
||||
],
|
||||
},
|
||||
{ type: "h2" as const, text: "Notes" },
|
||||
{
|
||||
type: "p" as const,
|
||||
children: [
|
||||
{ t: "text" as const, v: "Start writing — slash to insert chips that reference your blocks, requirements, and constraints." },
|
||||
],
|
||||
},
|
||||
],
|
||||
socratesThread: [], // dock loads from DB anyway
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorShell
|
||||
data={fixtureForUi}
|
||||
initialModel={model}
|
||||
initialVersion={version}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import Link from "next/link";
|
||||
// Landing page — list existing projects + entry point to start a new one.
|
||||
|
||||
import Link from "next/link";
|
||||
import { listProjects } from "../lib/db/repo";
|
||||
|
||||
export default async function HomePage() {
|
||||
const projects = await listProjects();
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div style={{ padding: "60px 80px", maxWidth: 720, margin: "0 auto", lineHeight: 1.55 }}>
|
||||
<h1 style={{ fontFamily: "var(--font-display)", fontSize: 32, fontWeight: 600, marginBottom: 8 }}>
|
||||
@@ -9,42 +14,108 @@ export default function HomePage() {
|
||||
<p style={{ color: "var(--muted)", marginBottom: 32 }}>
|
||||
Structured thinking and validation platform for product managers.
|
||||
</p>
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 12 }}>
|
||||
<li>
|
||||
<Link
|
||||
href="/editor/aristotle"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "12px 18px",
|
||||
border: "1px solid var(--border-strong)",
|
||||
borderRadius: 6,
|
||||
color: "var(--accent)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 13,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
→ Open editor (Aristotle fixture)
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/seed"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "12px 18px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
color: "var(--muted)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 13,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
→ Seed screen (coming next)
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Link
|
||||
href="/seed"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "12px 18px",
|
||||
border: "1px solid var(--accent)",
|
||||
background: "var(--accent)",
|
||||
color: "var(--accent-on)",
|
||||
borderRadius: 6,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 13,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
→ Start a new idea
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.10em",
|
||||
color: "var(--muted)",
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
{projects.length === 0 ? "no projects yet" : `${projects.length} project${projects.length === 1 ? "" : "s"}`}
|
||||
</h2>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<p style={{ color: "var(--muted)", fontStyle: "italic" }}>
|
||||
Click "Start a new idea" above to begin a Socratic interview that bootstraps a fresh project.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 8 }}>
|
||||
{projects.map(p => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={`/editor/${encodeURIComponent(p.id)}`}
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "12px 14px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
color: "var(--fg)",
|
||||
textDecoration: "none",
|
||||
background: "var(--surface)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||||
<span style={{
|
||||
fontFamily: "var(--font-display)",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
color: "var(--accent-strong)",
|
||||
}}>{p.name}</span>
|
||||
<span style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10.5,
|
||||
color: "var(--muted)",
|
||||
}}>
|
||||
{p.scope} · v{p.latestVersion}
|
||||
</span>
|
||||
<span style={{
|
||||
marginLeft: "auto",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10.5,
|
||||
color: "var(--muted)",
|
||||
}}>
|
||||
{timeAgo(new Date(p.updatedAt))}
|
||||
</span>
|
||||
</div>
|
||||
{p.tagline && (
|
||||
<div style={{
|
||||
fontSize: 12.5,
|
||||
color: "var(--muted-strong)",
|
||||
marginTop: 4,
|
||||
lineHeight: 1.45,
|
||||
}}>
|
||||
{p.tagline}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(d: Date): string {
|
||||
const sec = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
type ModelOp,
|
||||
} from "../../lib/sync/ops";
|
||||
import type { ValidationIssue } from "../../lib/sysml/validate";
|
||||
import type { Density } from "../socrates/SocratesDock";
|
||||
import type { Density } from "../../lib/workspace/types";
|
||||
import type { FixtureData, BlockKind } from "../../lib/fixtures/aristotle";
|
||||
import type { Block, Property, PropertyType, SysMLModel } from "../../lib/sysml/model";
|
||||
|
||||
@@ -69,6 +69,8 @@ interface DiagramCanvasProps {
|
||||
focusBlockId: string | null;
|
||||
onSelect?: (id: string | null) => void;
|
||||
issuesByElement?: Map<string, ValidationIssue[]>;
|
||||
/** When set, term-drop on the canvas links the chosen term server-side. */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const nodeTypes = { sysmlBlock: BlockNode };
|
||||
@@ -93,7 +95,7 @@ export function DiagramCanvas(props: DiagramCanvasProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: DiagramCanvasProps) {
|
||||
function DiagramInner({ data, focusBlockId, onSelect, issuesByElement, projectId }: DiagramCanvasProps) {
|
||||
const model = useModel();
|
||||
const apply = useApply();
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -130,14 +132,29 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
||||
const dataChanged =
|
||||
existing.data?.label !== b.label ||
|
||||
existing.data?.kind !== b.kind ||
|
||||
!sameStringArray(existing.data?.properties ?? [], propNames);
|
||||
!sameStringArray(existing.data?.properties ?? [], propNames) ||
|
||||
existing.data?.reviewStatus !== b.reviewStatus;
|
||||
if (dataChanged) {
|
||||
next.push({ ...existing, data: { ...existing.data, label: b.label, kind: b.kind, properties: propNames } });
|
||||
next.push({
|
||||
...existing,
|
||||
data: {
|
||||
...existing.data,
|
||||
label: b.label,
|
||||
kind: b.kind,
|
||||
properties: propNames,
|
||||
reviewStatus: b.reviewStatus,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
next.push(existing);
|
||||
}
|
||||
} else {
|
||||
next.push({ ...makeNodeForBlock(b), position: placeNew() });
|
||||
const created = makeNodeForBlock(b);
|
||||
next.push({
|
||||
...created,
|
||||
position: placeNew(),
|
||||
data: { ...created.data, reviewStatus: b.reviewStatus },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const c of model.constraints) {
|
||||
@@ -146,14 +163,30 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
||||
const expr = c.expression || "{ }";
|
||||
const dataChanged =
|
||||
existing.data?.label !== c.label ||
|
||||
existing.data?.expression !== expr;
|
||||
existing.data?.expression !== expr ||
|
||||
existing.data?.reviewStatus !== c.reviewStatus;
|
||||
if (dataChanged) {
|
||||
next.push({ ...existing, data: { ...existing.data, label: c.label, kind: "constraint", properties: [], expression: expr } });
|
||||
next.push({
|
||||
...existing,
|
||||
data: {
|
||||
...existing.data,
|
||||
label: c.label,
|
||||
kind: "constraint",
|
||||
properties: [],
|
||||
expression: expr,
|
||||
reviewStatus: c.reviewStatus,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
next.push(existing);
|
||||
}
|
||||
} else {
|
||||
next.push({ ...makeNodeForConstraint(c), position: placeNew() });
|
||||
const created = makeNodeForConstraint(c);
|
||||
next.push({
|
||||
...created,
|
||||
position: placeNew(),
|
||||
data: { ...created.data, reviewStatus: c.reviewStatus },
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
@@ -295,9 +328,47 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
|
||||
// Taxonomy/glossary term drop → block stamped from the term.
|
||||
const termRaw = event.dataTransfer.getData("application/x-socrata-term");
|
||||
if (termRaw) {
|
||||
try {
|
||||
const t = JSON.parse(termRaw) as { termId?: string; label?: string; definition?: string };
|
||||
if (!t.label || !t.termId) return;
|
||||
const bid = tempId("b");
|
||||
const block: Block = {
|
||||
id: bid,
|
||||
label: t.label,
|
||||
kind: "block",
|
||||
stereotypes: ["block"],
|
||||
properties: [],
|
||||
description: t.definition ?? undefined,
|
||||
linkedTermId: t.termId,
|
||||
};
|
||||
const result = apply([addBlockOp(block, bid)]);
|
||||
if (result.applied) {
|
||||
const final = result.idMapping[bid] ?? bid;
|
||||
setNodes(curr => curr.map(n => (n.id === final ? { ...n, position } : n)));
|
||||
onSelect?.(final);
|
||||
// Persist the term → block link server-side so it survives reload
|
||||
// and so other UI can show the "linked" indicator immediately.
|
||||
if (projectId) {
|
||||
void fetch(`/api/projects/${encodeURIComponent(projectId)}/term-link`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ termId: t.termId, blockId: final }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[DiagramCanvas] term drop parse:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const kindRaw = event.dataTransfer.getData("application/sysml-kind");
|
||||
if (!kindRaw) return;
|
||||
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
|
||||
if (kindRaw === "constraint") {
|
||||
const cid = tempId("c");
|
||||
@@ -332,7 +403,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
||||
onSelect?.(final);
|
||||
}
|
||||
},
|
||||
[apply, onSelect, screenToFlowPosition, setNodes]
|
||||
[apply, onSelect, screenToFlowPosition, setNodes, projectId]
|
||||
);
|
||||
|
||||
function patchSelected(patch: Partial<BlockNodeData>) {
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface BlockNodeData extends Record<string, unknown> {
|
||||
expression?: string;
|
||||
/** Highest severity of any validation issue anchored to this block. */
|
||||
issueSeverity?: "error" | "warning" | "soft";
|
||||
/** Review state — drives a small visual distinction so unreviewed analyzer
|
||||
* suggestions stand out on the canvas. */
|
||||
reviewStatus?: "suggested" | "accepted" | "deprecated";
|
||||
}
|
||||
|
||||
const STEREO: Record<string, string> = {
|
||||
@@ -33,6 +36,7 @@ export function BlockNode({ data, selected }: NodeProps) {
|
||||
`sysml-node-${kind}`,
|
||||
selected ? "sysml-node-selected" : "",
|
||||
d.issueSeverity ? `sysml-node-issue-${d.issueSeverity}` : "",
|
||||
d.reviewStatus && d.reviewStatus !== "accepted" ? `sysml-node-review-${d.reviewStatus}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// Header above each canvas (Narrative / Model). Title + subtitle on the left, optional actions on the right.
|
||||
// Ported from docs/design-source/socrata/project/editor-shell.jsx (CanvasHeader).
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface CanvasHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
right?: ReactNode;
|
||||
}
|
||||
|
||||
export function CanvasHeader({ title, subtitle, right }: CanvasHeaderProps) {
|
||||
return (
|
||||
<div className="canvas-header">
|
||||
<div>
|
||||
<div className="canvas-title">{title}</div>
|
||||
<div className="canvas-sub">{subtitle}</div>
|
||||
</div>
|
||||
<div className="canvas-header-right">{right}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,36 @@
|
||||
// The dual-canvas workspace shell.
|
||||
// M5: state lives in ModelStoreProvider; both canvases consume the canonical
|
||||
// SysMLModel and emit ModelOps back through useApply().
|
||||
// The pivoted workspace shell.
|
||||
//
|
||||
// Layout: TopBar → [LeftSidebar | MainWorkspace] → StatusBar.
|
||||
// LeftSidebar lists all sections (Outline + Structure + Findings).
|
||||
// MainWorkspace is a flex of panes: a pinned text editor + zero-or-more
|
||||
// section panes (Model, Taxonomy, Glossary, Requirements, Findings) that
|
||||
// the user opens from the sidebar.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, Suspense } from "react";
|
||||
import { useMemo, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { LeftRail } from "./LeftRail";
|
||||
import { CanvasHeader } from "./CanvasHeader";
|
||||
import { LeftSidebar } from "./LeftSidebar";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { IssuesPanel } from "./IssuesPanel";
|
||||
import { FindingsPanel } from "./FindingsPanel";
|
||||
import { TextCanvas } from "../text-canvas/TextCanvas";
|
||||
import { DiagramCanvas, type DiagramVariant } from "../diagram-canvas/DiagramCanvas";
|
||||
import { SocratesDock, type Density, type SocratesPresence } from "../socrates/SocratesDock";
|
||||
import type { MarkupStyle } from "../text-canvas/Chip";
|
||||
import { MainWorkspace } from "./MainWorkspace";
|
||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||
import { fromFixture } from "../../lib/sysml/fromFixture";
|
||||
import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks";
|
||||
import { ModelStoreProvider, useModelStore } from "../../lib/sync/ModelStore";
|
||||
import { ModelStoreProvider } from "../../lib/sync/ModelStore";
|
||||
import { OpenPanesProvider } from "../../lib/workspace/openPanesStore";
|
||||
import { AnalysisStoreProvider } from "../../lib/workspace/analysisStore";
|
||||
import { EditorPaneContextProvider } from "./sections/paneContext";
|
||||
import type { Density } from "../../lib/workspace/types";
|
||||
|
||||
interface EditorShellProps {
|
||||
data: FixtureData;
|
||||
/** Server-loaded initial model + version (M5.9). When omitted, falls back to
|
||||
/** Server-loaded initial model + version. When omitted, falls back to
|
||||
* deriving from `data` (legacy fixture path; useful for tests). */
|
||||
initialModel?: import("../../lib/sysml/model").SysMLModel;
|
||||
initialVersion?: number;
|
||||
/** When set, apply() POSTs to /api/projects/[projectId]/apply. */
|
||||
projectId?: string;
|
||||
density?: Density;
|
||||
markupStyle?: MarkupStyle;
|
||||
diagramStyle?: DiagramVariant;
|
||||
presence?: SocratesPresence;
|
||||
}
|
||||
|
||||
export function EditorShell(props: EditorShellProps) {
|
||||
@@ -43,16 +41,7 @@ export function EditorShell(props: EditorShellProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function EditorShellInner({
|
||||
data,
|
||||
initialModel,
|
||||
initialVersion,
|
||||
projectId,
|
||||
density = "comfortable",
|
||||
markupStyle = "color",
|
||||
diagramStyle = "softened",
|
||||
presence = "default",
|
||||
}: EditorShellProps) {
|
||||
function EditorShellInner({ data, initialModel, initialVersion, projectId, density = "comfortable" }: EditorShellProps) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const breaks = useMemo<BreakName[]>(() => {
|
||||
@@ -61,112 +50,29 @@ function EditorShellInner({
|
||||
return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS);
|
||||
}, [searchParams]);
|
||||
|
||||
// Prefer server-loaded model; fall back to fixture derivation for legacy
|
||||
// callers without DB persistence wiring.
|
||||
const startingModel = useMemo(() => {
|
||||
const base = initialModel ?? fromFixture(data);
|
||||
return breaks.length > 0 ? applyBreaks(base, breaks) : base;
|
||||
}, [initialModel, data, breaks]);
|
||||
|
||||
const id = projectId ?? "aristotle";
|
||||
|
||||
return (
|
||||
<ModelStoreProvider
|
||||
initialModel={startingModel}
|
||||
initialVersion={initialVersion ?? 1}
|
||||
projectId={projectId}
|
||||
>
|
||||
<ShellBody
|
||||
data={data}
|
||||
projectId={projectId}
|
||||
density={density}
|
||||
markupStyle={markupStyle}
|
||||
diagramStyle={diagramStyle}
|
||||
presence={presence}
|
||||
breaks={breaks}
|
||||
/>
|
||||
<ModelStoreProvider initialModel={startingModel} initialVersion={initialVersion ?? 1} projectId={id}>
|
||||
<OpenPanesProvider projectId={id}>
|
||||
<AnalysisStoreProvider projectId={id}>
|
||||
<EditorPaneContextProvider projectId={id}>
|
||||
<div className={`shell shell-density-${density}`}>
|
||||
<TopBar data={data} />
|
||||
<div className="shell-body shell-body-pivot">
|
||||
<LeftSidebar />
|
||||
<MainWorkspace data={data} projectId={id} />
|
||||
</div>
|
||||
<StatusBar data={data} />
|
||||
</div>
|
||||
</EditorPaneContextProvider>
|
||||
</AnalysisStoreProvider>
|
||||
</OpenPanesProvider>
|
||||
</ModelStoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShellBodyProps {
|
||||
data: FixtureData;
|
||||
projectId?: string;
|
||||
density: Density;
|
||||
markupStyle: MarkupStyle;
|
||||
diagramStyle: DiagramVariant;
|
||||
presence: SocratesPresence;
|
||||
breaks: BreakName[];
|
||||
}
|
||||
|
||||
function ShellBody({ data, projectId, density, markupStyle, diagramStyle, presence, breaks }: ShellBodyProps) {
|
||||
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
||||
const { model, version, issues, issuesByElement } = useModelStore();
|
||||
|
||||
const stats = `SysML · ${model.blocks.length} blocks · ${model.associations.length} associations · ${model.constraints.length} constraints`;
|
||||
const subtitle = breaks.length > 0 ? `${stats} · breaks active: ${breaks.join(", ")}` : stats;
|
||||
|
||||
return (
|
||||
<div className={`shell shell-density-${density} shell-presence-${presence}`}>
|
||||
<TopBar data={data} />
|
||||
|
||||
<div className="shell-body">
|
||||
<SocratesDock projectId={projectId ?? "aristotle"} presence={presence} density={density} />
|
||||
<LeftRail
|
||||
data={data}
|
||||
focusBlockId={focusBlockId}
|
||||
setFocusBlockId={setFocusBlockId}
|
||||
issuesByElement={issuesByElement}
|
||||
/>
|
||||
|
||||
<main className="canvases">
|
||||
<section className="canvas canvas-text">
|
||||
<CanvasHeader title="Narrative" subtitle="Markup-augmented prose · synced to model" />
|
||||
<div className="canvas-scroll">
|
||||
<TextCanvas
|
||||
data={data}
|
||||
density={density}
|
||||
markupStyle={markupStyle}
|
||||
focusBlockId={focusBlockId}
|
||||
setFocusBlockId={setFocusBlockId}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="canvas-divider" />
|
||||
|
||||
<section className="canvas canvas-diagram">
|
||||
<CanvasHeader
|
||||
title="Model"
|
||||
subtitle={subtitle}
|
||||
right={
|
||||
<div className="canvas-actions">
|
||||
<span className="canvas-mode-pill">Fit</span>
|
||||
<span className="canvas-mode-pill canvas-mode-active">100%</span>
|
||||
<span className="canvas-mode-pill">Layout</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="canvas-scroll canvas-scroll-diagram">
|
||||
<DiagramCanvas
|
||||
data={data}
|
||||
density={density}
|
||||
variant={diagramStyle}
|
||||
focusBlockId={focusBlockId}
|
||||
onSelect={setFocusBlockId}
|
||||
issuesByElement={issuesByElement}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<StatusBar data={data} />
|
||||
|
||||
<IssuesPanel issues={issues} onSelectAnchor={id => setFocusBlockId(id)} />
|
||||
<FindingsPanel
|
||||
projectId={projectId ?? "aristotle"}
|
||||
modelVersion={version}
|
||||
onSelectAnchor={id => setFocusBlockId(id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
// Findings panel — lists detected assumptions / risks / inconsistencies.
|
||||
// Lives in the bottom-right corner above the IssuesPanel; collapsible;
|
||||
// has a "detect" button that triggers a fresh background pass.
|
||||
//
|
||||
// Items are clickable: clicking focuses the first linked element across
|
||||
// the rail + diagram (using the same setFocusBlockId path as the rail).
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type FindingKind = "assumption" | "risk" | "inconsistency";
|
||||
|
||||
export interface FindingDTO {
|
||||
id: string;
|
||||
kind: FindingKind;
|
||||
text: string;
|
||||
linkedElementIds: string[];
|
||||
confidence: number;
|
||||
severity?: "low" | "medium" | "high" | null;
|
||||
validationCode?: string | null;
|
||||
modelVersion: number;
|
||||
provider?: string | null;
|
||||
llmModel?: string | null;
|
||||
}
|
||||
|
||||
interface FindingsPanelProps {
|
||||
projectId: string;
|
||||
/** Re-fetch whenever this changes. Pass the model version so we know when to refresh. */
|
||||
modelVersion: number;
|
||||
onSelectAnchor?: (elementId: string) => void;
|
||||
}
|
||||
|
||||
const KIND_GLYPH: Record<FindingKind, string> = {
|
||||
assumption: "●",
|
||||
risk: "▲",
|
||||
inconsistency: "!",
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<FindingKind, string> = {
|
||||
assumption: "asm",
|
||||
risk: "risk",
|
||||
inconsistency: "inc",
|
||||
};
|
||||
|
||||
export function FindingsPanel({ projectId, modelVersion, onSelectAnchor }: FindingsPanelProps) {
|
||||
const [findings, setFindings] = useState<FindingDTO[] | null>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [meta, setMeta] = useState<{ provider?: string; model?: string; durationMs?: number; modelVersion?: number } | null>(null);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/findings`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = (await res.json()) as { findings: FindingDTO[] };
|
||||
if (cancelled) return;
|
||||
setFindings(data.findings);
|
||||
if (data.findings.length > 0) {
|
||||
const first = data.findings[0]!;
|
||||
setMeta({ provider: first.provider ?? undefined, model: first.llmModel ?? undefined, modelVersion: first.modelVersion });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError((err as Error).message);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [projectId]);
|
||||
|
||||
const detect = useCallback(async () => {
|
||||
if (running) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/findings`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
findings: FindingDTO[];
|
||||
meta: { provider: string; model: string; durationMs: number; strippedRefs: number; droppedFindings: number };
|
||||
};
|
||||
setFindings(data.findings);
|
||||
setMeta({
|
||||
provider: data.meta.provider,
|
||||
model: data.meta.model,
|
||||
durationMs: data.meta.durationMs,
|
||||
modelVersion,
|
||||
});
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [projectId, modelVersion, running]);
|
||||
|
||||
const counts = {
|
||||
assumption: findings?.filter(f => f.kind === "assumption").length ?? 0,
|
||||
risk: findings?.filter(f => f.kind === "risk").length ?? 0,
|
||||
inconsistency: findings?.filter(f => f.kind === "inconsistency").length ?? 0,
|
||||
};
|
||||
const total = counts.assumption + counts.risk + counts.inconsistency;
|
||||
const stale = meta?.modelVersion !== undefined && meta.modelVersion !== modelVersion;
|
||||
|
||||
return (
|
||||
<div className={`findings-panel ${collapsed ? "findings-panel-collapsed" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="findings-panel-head"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
>
|
||||
<span className="findings-panel-counts">
|
||||
{counts.assumption > 0 && <span className="findings-count findings-count-asm">● {counts.assumption}</span>}
|
||||
{counts.risk > 0 && <span className="findings-count findings-count-risk">▲ {counts.risk}</span>}
|
||||
{counts.inconsistency > 0 && <span className="findings-count findings-count-inc">! {counts.inconsistency}</span>}
|
||||
{total === 0 && findings !== null && <span className="findings-count findings-count-none">no findings</span>}
|
||||
{findings === null && <span className="findings-count findings-count-none">loading…</span>}
|
||||
</span>
|
||||
<span className="findings-panel-title">
|
||||
{findings === null ? "findings" : total === 0 ? "no detected findings" : `${total} detected finding${total === 1 ? "" : "s"}`}
|
||||
{stale && <span className="findings-panel-stale" title="Model has changed since these were detected">· stale</span>}
|
||||
</span>
|
||||
<span className="findings-panel-caret">{collapsed ? "▴" : "▾"}</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="findings-panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="findings-panel-btn"
|
||||
onClick={detect}
|
||||
disabled={running}
|
||||
>
|
||||
{running ? "detecting…" : findings && findings.length > 0 ? "re-detect" : "detect"}
|
||||
</button>
|
||||
{meta?.durationMs && (
|
||||
<span className="findings-panel-meta">
|
||||
{meta.provider} · {meta.model?.split("/").pop()} · {(meta.durationMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="findings-panel-error">⚠ {error}</div>}
|
||||
|
||||
{findings && findings.length > 0 && (
|
||||
<ul className="findings-panel-list">
|
||||
{(["inconsistency", "risk", "assumption"] as const).flatMap(kind =>
|
||||
findings.filter(f => f.kind === kind).map(f => (
|
||||
<li
|
||||
key={f.id}
|
||||
className={`findings-item findings-item-${f.kind}`}
|
||||
onClick={() => {
|
||||
if (f.linkedElementIds.length > 0 && onSelectAnchor) {
|
||||
onSelectAnchor(f.linkedElementIds[0]!);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: f.linkedElementIds.length > 0 ? "pointer" : "default" }}
|
||||
>
|
||||
<span className={`findings-item-glyph findings-item-glyph-${f.kind}`}>{KIND_GLYPH[f.kind]}</span>
|
||||
<span className="findings-item-tag">{KIND_LABEL[f.kind]}</span>
|
||||
{f.severity && <span className={`findings-item-sev findings-item-sev-${f.severity}`}>{f.severity}</span>}
|
||||
{f.validationCode && <span className="findings-item-code">{f.validationCode}</span>}
|
||||
<span className="findings-item-text">{f.text}</span>
|
||||
<span className="findings-item-conf">{f.confidence.toFixed(2)}</span>
|
||||
{f.linkedElementIds.length > 0 && (
|
||||
<span className="findings-item-refs" title={f.linkedElementIds.join(", ")}>
|
||||
[{f.linkedElementIds.slice(0, 3).join(", ")}{f.linkedElementIds.length > 3 ? "…" : ""}]
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Floating panel listing current validation issues — shown next to the
|
||||
// status bar. Click an issue to focus its anchored element across the rail
|
||||
// + diagram (via setFocusBlockId). Lets the user verify the validator is
|
||||
// actually firing on the broken-fixture demos.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ValidationIssue } from "../../lib/sysml/validate";
|
||||
|
||||
interface IssuesPanelProps {
|
||||
issues: ValidationIssue[];
|
||||
onSelectAnchor?: (id: string) => void;
|
||||
}
|
||||
|
||||
const SEV_GLYPH: Record<ValidationIssue["severity"], string> = {
|
||||
error: "●",
|
||||
warning: "▲",
|
||||
soft: "·",
|
||||
};
|
||||
|
||||
export function IssuesPanel({ issues, onSelectAnchor }: IssuesPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const grouped = {
|
||||
error: issues.filter(i => i.severity === "error"),
|
||||
warning: issues.filter(i => i.severity === "warning"),
|
||||
soft: issues.filter(i => i.severity === "soft"),
|
||||
};
|
||||
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className={`issues-panel issues-panel-clean ${collapsed ? "issues-panel-collapsed" : ""}`}>
|
||||
<button className="issues-panel-head" onClick={() => setCollapsed(c => !c)} type="button">
|
||||
<span className="issues-panel-clean-glyph">✓</span>
|
||||
<span className="issues-panel-title">Model is clean</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`issues-panel ${collapsed ? "issues-panel-collapsed" : ""}`}>
|
||||
<button className="issues-panel-head" onClick={() => setCollapsed(c => !c)} type="button">
|
||||
<span className="issues-panel-counts">
|
||||
{grouped.error.length > 0 && <span className="issues-count issues-count-error">● {grouped.error.length}</span>}
|
||||
{grouped.warning.length > 0 && <span className="issues-count issues-count-warning">▲ {grouped.warning.length}</span>}
|
||||
{grouped.soft.length > 0 && <span className="issues-count issues-count-soft">· {grouped.soft.length}</span>}
|
||||
</span>
|
||||
<span className="issues-panel-title">{issues.length} validation issue{issues.length === 1 ? "" : "s"}</span>
|
||||
<span className="issues-panel-caret">{collapsed ? "▴" : "▾"}</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<ul className="issues-panel-list">
|
||||
{(["error", "warning", "soft"] as const).flatMap(sev =>
|
||||
grouped[sev].map((i, idx) => (
|
||||
<li
|
||||
key={`${sev}-${idx}`}
|
||||
className={`issues-item issues-item-${i.severity}`}
|
||||
onClick={() => onSelectAnchor?.(i.anchor.kind === "property" ? i.anchor.blockId : (i.anchor as { id: string }).id ?? "")}
|
||||
>
|
||||
<span className="issues-item-sev">{SEV_GLYPH[i.severity]}</span>
|
||||
<span className="issues-item-code">{i.code}</span>
|
||||
<span className="issues-item-msg">{i.message}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
// Outline / Model / Requirements sections, each independently collapsible.
|
||||
// The whole rail can also collapse to a 36px vertical strip.
|
||||
//
|
||||
// M5: Model + Requirements sections read from the canonical SysMLModel via
|
||||
// useModelStore() so renames in either canvas reflect here immediately.
|
||||
// Outline section is still narrative-derived and uses the fixture (M6 will
|
||||
// migrate it to the live ProseMirror outline).
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||
import type { ValidationIssue, Severity } from "../../lib/sysml/validate";
|
||||
import { useModel } from "../../lib/sync/ModelStore";
|
||||
|
||||
interface LeftRailProps {
|
||||
data: FixtureData;
|
||||
focusBlockId: string | null;
|
||||
setFocusBlockId: (id: string | null) => void;
|
||||
/** Map keyed by element key (block id, `req:<id>`, `assoc:<id>`, …) → issues. */
|
||||
issuesByElement?: Map<string, ValidationIssue[]>;
|
||||
}
|
||||
|
||||
function maxSeverityForKey(map: Map<string, ValidationIssue[]> | undefined, key: string): Severity | null {
|
||||
const items = map?.get(key);
|
||||
if (!items || items.length === 0) return null;
|
||||
if (items.some(i => i.severity === "error")) return "error";
|
||||
if (items.some(i => i.severity === "warning")) return "warning";
|
||||
return "soft";
|
||||
}
|
||||
|
||||
function IssueDot({ severity, title }: { severity: Severity | null; title?: string }) {
|
||||
if (!severity) return null;
|
||||
return <span className={`rail-issue-dot rail-issue-dot-${severity}`} title={title} />;
|
||||
}
|
||||
|
||||
export function LeftRail({ focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [open, setOpen] = useState({ outline: true, model: true, requirements: true });
|
||||
const toggle = (k: keyof typeof open) => setOpen(s => ({ ...s, [k]: !s[k] }));
|
||||
const model = useModel();
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<nav className="leftrail leftrail-collapsed">
|
||||
<button
|
||||
className="rail-collapse-btn"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title="Expand rail"
|
||||
type="button"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<div className="rail-collapsed-stack">
|
||||
<span className="rail-collapsed-tag" title="Outline">OUT</span>
|
||||
<span className="rail-collapsed-tag" title={`Model · ${model.blocks.length + model.constraints.length} elements`}>MOD</span>
|
||||
<span className="rail-collapsed-tag" title="Requirements">REQ</span>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Combine blocks + constraints in the Model section, sorted by kind for a
|
||||
// predictable order: system → block → actor → constraint.
|
||||
const kindRank: Record<string, number> = { system: 0, block: 1, actor: 2, constraint: 3 };
|
||||
const modelEntries: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "block" | "actor" | "constraint" | "system";
|
||||
propertyCount: number;
|
||||
}> = [
|
||||
...model.blocks.map(b => ({
|
||||
id: b.id,
|
||||
label: b.label,
|
||||
kind: b.kind,
|
||||
propertyCount: b.properties.length,
|
||||
})),
|
||||
...model.constraints.map(c => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
kind: "constraint" as const,
|
||||
propertyCount: 0,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
const r = (kindRank[a.kind] ?? 99) - (kindRank[b.kind] ?? 99);
|
||||
return r !== 0 ? r : a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className="leftrail">
|
||||
<div className="rail-section">
|
||||
<button className="rail-section-head" onClick={() => toggle("outline")} type="button">
|
||||
<span className={`rail-caret ${open.outline ? "rail-caret-open" : ""}`}>▸</span>
|
||||
<span className="rail-label">Outline</span>
|
||||
</button>
|
||||
{open.outline && (
|
||||
<ul className="rail-list">
|
||||
<li className="rail-item rail-item-active">
|
||||
<span className="rail-item-text">Problem framing</span>
|
||||
</li>
|
||||
<li className="rail-item">
|
||||
<span className="rail-item-text">Constraints</span>
|
||||
<span className="rail-count rail-count-req">3</span>
|
||||
</li>
|
||||
<li className="rail-item">
|
||||
<span className="rail-item-text">Why now</span>
|
||||
</li>
|
||||
<li className="rail-item rail-item-muted">
|
||||
<span className="rail-item-text">Hypotheses</span>
|
||||
<span className="rail-count rail-count-asm">3</span>
|
||||
</li>
|
||||
<li className="rail-item rail-item-muted">
|
||||
<span className="rail-item-text">Open questions</span>
|
||||
<span className="rail-count rail-count-q">5</span>
|
||||
</li>
|
||||
<li className="rail-item rail-item-muted">
|
||||
<span className="rail-item-text">Risks</span>
|
||||
<span className="rail-count rail-count-risk">2</span>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rail-section">
|
||||
<button className="rail-section-head" onClick={() => toggle("model")} type="button">
|
||||
<span className={`rail-caret ${open.model ? "rail-caret-open" : ""}`}>▸</span>
|
||||
<span className="rail-label">Model</span>
|
||||
</button>
|
||||
{open.model && (
|
||||
<ul className="rail-list rail-blocks">
|
||||
{modelEntries.map(b => {
|
||||
const sev = maxSeverityForKey(issuesByElement, b.id);
|
||||
const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n");
|
||||
return (
|
||||
<li
|
||||
key={b.id}
|
||||
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
|
||||
onMouseEnter={() => setFocusBlockId(b.id)}
|
||||
onMouseLeave={() => setFocusBlockId(null)}
|
||||
onClick={() => setFocusBlockId(b.id)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<span className="rail-block-glyph">
|
||||
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : b.kind === "system" ? "◎" : "▢"}
|
||||
</span>
|
||||
<span className="rail-block-label">{b.label}</span>
|
||||
<IssueDot severity={sev} title={tooltip} />
|
||||
{b.kind !== "constraint" && (
|
||||
<span
|
||||
className="rail-block-count"
|
||||
title={`${b.propertyCount} ${b.propertyCount === 1 ? "property" : "properties"}`}
|
||||
>
|
||||
<span className="rail-block-count-glyph">·</span>
|
||||
{b.propertyCount}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rail-section">
|
||||
<button className="rail-section-head" onClick={() => toggle("requirements")} type="button">
|
||||
<span className={`rail-caret ${open.requirements ? "rail-caret-open" : ""}`}>▸</span>
|
||||
<span className="rail-label">Requirements</span>
|
||||
</button>
|
||||
{open.requirements && (
|
||||
<ul className="rail-list">
|
||||
{model.requirements.map(r => {
|
||||
const key = `req:${r.id}`;
|
||||
const sev = maxSeverityForKey(issuesByElement, key);
|
||||
const tooltip = issuesByElement?.get(key)?.map(i => `${i.code}: ${i.message}`).join("\n");
|
||||
const traced = r.relations.some(rel => rel.kind === "satisfy");
|
||||
return (
|
||||
<li key={r.id} className="rail-req">
|
||||
<span className="req-tag">{r.tag}</span>
|
||||
{sev ? (
|
||||
<IssueDot severity={sev} title={tooltip} />
|
||||
) : (
|
||||
<span className={`req-status ${traced ? "req-traced" : "req-untraced"}`} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="rail-collapse-btn rail-collapse-btn-bottom"
|
||||
onClick={() => setCollapsed(true)}
|
||||
title="Collapse rail"
|
||||
type="button"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
163
apps/web/components/editor/LeftSidebar.tsx
Normal file
163
apps/web/components/editor/LeftSidebar.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// Single sidebar for the pivoted shell. Lists every section grouped under
|
||||
// Structure / Findings. Each row shows: title, count, last-run timestamp,
|
||||
// per-section [Analyze] button, and is itself clickable to toggle the matching
|
||||
// pane open in the main workspace.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useOpenPanes, SECTION_TITLES, type SectionPaneId } from "../../lib/workspace/openPanesStore";
|
||||
import { useAnalysis } from "../../lib/workspace/analysisStore";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { useState } from "react";
|
||||
|
||||
const STRUCTURE: SectionPaneId[] = ["concepts", "model", "requirements"];
|
||||
const FINDINGS: SectionPaneId[] = ["assumptions", "risks", "inconsistencies"];
|
||||
|
||||
export function LeftSidebar() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="leftsidebar leftsidebar-collapsed" aria-label="Sections">
|
||||
<button className="leftsidebar-collapse-toggle" type="button" onClick={() => setCollapsed(false)} aria-label="Expand sidebar">
|
||||
›
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="leftsidebar" aria-label="Sections">
|
||||
<div className="leftsidebar-head">
|
||||
<span className="leftsidebar-title">Workspace</span>
|
||||
<button className="leftsidebar-collapse-toggle" type="button" onClick={() => setCollapsed(true)} aria-label="Collapse sidebar">
|
||||
‹
|
||||
</button>
|
||||
</div>
|
||||
<Group label="Structure" sections={STRUCTURE} />
|
||||
<Group label="Findings" sections={FINDINGS} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Group({ label, sections }: { label: string; sections: SectionPaneId[] }) {
|
||||
return (
|
||||
<div className="leftsidebar-group">
|
||||
<div className="leftsidebar-group-label">{label}</div>
|
||||
<ul className="leftsidebar-list">
|
||||
{sections.map(s => (
|
||||
<SectionRow key={s} id={s} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionRow({ id }: { id: SectionPaneId }) {
|
||||
const { isSectionOpen, toggleSection } = useOpenPanes();
|
||||
const { terms, requirements, findings, inFlight, runs, analyze } = useAnalysis();
|
||||
|
||||
const open = isSectionOpen(id);
|
||||
const count = sectionCount(id, { terms, requirements, findings });
|
||||
const pending = pendingCount(id, { terms, requirements, findings });
|
||||
// Spinner is on whenever:
|
||||
// - the user just clicked Analyze on this section (inFlight)
|
||||
// - the user clicked Analyze All
|
||||
// - the server has a "running" AnalysisRun for this section (catches
|
||||
// background runs like seed-finalize and the first-paint auto-analyze
|
||||
// that the local inFlight set doesn't know about). The analysisStore
|
||||
// polls `runs` every 3s while anything is running, so this stays
|
||||
// truthy until the run actually finishes.
|
||||
const running =
|
||||
inFlight.has(id) ||
|
||||
inFlight.has("all") ||
|
||||
(id === "concepts" && (inFlight.has("taxonomy") || inFlight.has("glossary"))) ||
|
||||
runs[id]?.status === "running" ||
|
||||
runs.all?.status === "running" ||
|
||||
(id === "concepts" &&
|
||||
(runs.taxonomy?.status === "running" || runs.glossary?.status === "running"));
|
||||
|
||||
// ONE number per row: pending if there's review work, otherwise total.
|
||||
// Runs the user the most useful signal first ("how much attention does
|
||||
// this section need?") and avoids the duplicate-count effect when
|
||||
// pending === total because nothing is accepted yet.
|
||||
const showPending = pending > 0;
|
||||
|
||||
return (
|
||||
<li className={`leftsidebar-row ${open ? "leftsidebar-row-open" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="leftsidebar-row-main"
|
||||
onClick={() => toggleSection(id)}
|
||||
aria-expanded={open}
|
||||
aria-label={
|
||||
showPending
|
||||
? `${SECTION_TITLES[id]} — ${pending} pending review`
|
||||
: `${SECTION_TITLES[id]} (${count})`
|
||||
}
|
||||
>
|
||||
<span className="leftsidebar-row-title">{SECTION_TITLES[id]}</span>
|
||||
{showPending ? (
|
||||
<span className="leftsidebar-pending-chip" title="Pending review">
|
||||
{pending}
|
||||
</span>
|
||||
) : (
|
||||
<span className="leftsidebar-row-count">{count}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`leftsidebar-analyze ${running ? "leftsidebar-analyze-running" : ""}`}
|
||||
title={running ? "Analyzing…" : `Re-run ${SECTION_TITLES[id]} analysis`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void analyze(id);
|
||||
}}
|
||||
disabled={running}
|
||||
aria-label={running ? "Analyzing" : `Re-run ${SECTION_TITLES[id]} analysis`}
|
||||
>
|
||||
{running ? <Spinner /> : <span aria-hidden="true">↻</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function sectionCount(
|
||||
id: SectionPaneId,
|
||||
data: {
|
||||
terms: { id: string; status: string }[];
|
||||
requirements: { id: string; status: string }[];
|
||||
findings: { kind: string; status: string }[];
|
||||
}
|
||||
): number {
|
||||
if (id === "concepts") return data.terms.length;
|
||||
if (id === "model") return 0; // Model count is shown inside the pane itself.
|
||||
if (id === "requirements") return data.requirements.length;
|
||||
return data.findings.filter(f => f.kind === idToFindingKind(id)).length;
|
||||
}
|
||||
|
||||
/** Count items the user hasn't reviewed yet (suggested + deprecated). The
|
||||
* most useful navigation cue we can put in the sidebar — tells the user
|
||||
* where attention is needed at a glance. */
|
||||
function pendingCount(
|
||||
id: SectionPaneId,
|
||||
data: {
|
||||
terms: { status: string }[];
|
||||
requirements: { status: string }[];
|
||||
findings: { kind: string; status: string }[];
|
||||
}
|
||||
): number {
|
||||
const isPending = (s: string) => s === "suggested" || s === "deprecated";
|
||||
if (id === "concepts") return data.terms.filter(t => isPending(t.status)).length;
|
||||
if (id === "requirements") return data.requirements.filter(r => isPending(r.status)).length;
|
||||
if (id === "model") return 0; // Model pending count needs ModelStore — surfaced in the pane header.
|
||||
return data.findings.filter(f => f.kind === idToFindingKind(id) && isPending(f.status)).length;
|
||||
}
|
||||
|
||||
function idToFindingKind(id: SectionPaneId): string {
|
||||
if (id === "assumptions") return "assumption";
|
||||
if (id === "risks") return "risk";
|
||||
if (id === "inconsistencies") return "inconsistency";
|
||||
return "";
|
||||
}
|
||||
|
||||
177
apps/web/components/editor/MainWorkspace.tsx
Normal file
177
apps/web/components/editor/MainWorkspace.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
// Main column-stack workspace (Finder-style).
|
||||
//
|
||||
// Layout: [column 0 (section)] [column 1] … [column N] [text editor (pinned)].
|
||||
// The text-editor pane is always rendered last and absorbs the remaining
|
||||
// horizontal space; the column stack on its left is horizontally scrollable
|
||||
// so deep chains stay reachable on narrow viewports.
|
||||
//
|
||||
// Each column has a drag-to-resize handle on its right edge. Each column also
|
||||
// gets `index` as a prop so its inner click handlers can call `pushFrom(index, …)`
|
||||
// to drill in (truncating any deeper columns first).
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { TextCanvasPane } from "./sections/TextCanvasPane";
|
||||
import { ModelPane } from "./sections/ModelPane";
|
||||
import { ConceptsPane } from "./sections/ConceptsPane";
|
||||
import { RequirementsPane } from "./sections/RequirementsPane";
|
||||
import { FindingsPane } from "./sections/FindingsPane";
|
||||
import {
|
||||
TermColumn,
|
||||
BlockColumn,
|
||||
AssociationColumn,
|
||||
ConstraintColumn,
|
||||
RequirementColumn,
|
||||
FindingColumn,
|
||||
} from "./columns/EntityColumns";
|
||||
import {
|
||||
useOpenPanes,
|
||||
PANE_MIN_WIDTH,
|
||||
PANE_MAX_WIDTH,
|
||||
columnKey,
|
||||
type Column,
|
||||
} from "../../lib/workspace/openPanesStore";
|
||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||
|
||||
interface MainWorkspaceProps {
|
||||
data: FixtureData;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function MainWorkspace({ data, projectId }: MainWorkspaceProps) {
|
||||
const { columns, closeFrom, widthFor, setWidth } = useOpenPanes();
|
||||
|
||||
return (
|
||||
<main className="main-workspace">
|
||||
<div className="main-workspace-columns">
|
||||
{columns.map((col, index) => (
|
||||
<ResizableColumn
|
||||
key={columnKey(col)}
|
||||
column={col}
|
||||
index={index}
|
||||
projectId={projectId}
|
||||
width={widthFor(col)}
|
||||
onResize={w => setWidth(col, w)}
|
||||
onClose={() => closeFrom(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<TextCanvasPane data={data} projectId={projectId} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResizableColumnProps {
|
||||
column: Column;
|
||||
index: number;
|
||||
projectId: string;
|
||||
width: number;
|
||||
onResize: (w: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ResizableColumn({ column, index, projectId, width, onResize, onClose }: ResizableColumnProps) {
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(0);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const dx = e.clientX - startXRef.current;
|
||||
const next = Math.max(PANE_MIN_WIDTH, Math.min(PANE_MAX_WIDTH, startWidthRef.current + dx));
|
||||
onResize(next);
|
||||
},
|
||||
[onResize]
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const move = (e: PointerEvent) => onPointerMove(e);
|
||||
const up = () => onPointerUp();
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
};
|
||||
}, [onPointerMove, onPointerUp]);
|
||||
|
||||
const onHandleDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
startXRef.current = e.clientX;
|
||||
startWidthRef.current = width;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pane-with-handle" style={{ width, minWidth: width, maxWidth: width }}>
|
||||
<ColumnRenderer column={column} index={index} projectId={projectId} onClose={onClose} />
|
||||
<div
|
||||
className="pane-resize-handle"
|
||||
onPointerDown={onHandleDown}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize column ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColumnRendererProps {
|
||||
column: Column;
|
||||
index: number;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ColumnRenderer({ column, index, projectId, onClose }: ColumnRendererProps) {
|
||||
switch (column.kind) {
|
||||
case "section":
|
||||
switch (column.id) {
|
||||
case "concepts":
|
||||
return <ConceptsPane index={index} onClose={onClose} />;
|
||||
case "model":
|
||||
return <ModelPane index={index} onClose={onClose} />;
|
||||
case "requirements":
|
||||
return <RequirementsPane index={index} onClose={onClose} />;
|
||||
case "assumptions":
|
||||
return <FindingsPane kind="assumption" index={index} projectId={projectId} onClose={onClose} />;
|
||||
case "risks":
|
||||
return <FindingsPane kind="risk" index={index} projectId={projectId} onClose={onClose} />;
|
||||
case "inconsistencies":
|
||||
return <FindingsPane kind="inconsistency" index={index} projectId={projectId} onClose={onClose} />;
|
||||
}
|
||||
case "term":
|
||||
return <TermColumn termId={column.id} index={index} onClose={onClose} />;
|
||||
case "block":
|
||||
return <BlockColumn blockId={column.id} index={index} onClose={onClose} />;
|
||||
case "association":
|
||||
return <AssociationColumn associationId={column.id} index={index} onClose={onClose} />;
|
||||
case "constraint":
|
||||
return <ConstraintColumn constraintId={column.id} index={index} onClose={onClose} />;
|
||||
case "requirement":
|
||||
return <RequirementColumn requirementId={column.id} index={index} onClose={onClose} />;
|
||||
case "finding":
|
||||
return (
|
||||
<FindingColumn
|
||||
findingId={column.id}
|
||||
index={index}
|
||||
projectId={projectId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
97
apps/web/components/editor/PaneControls.tsx
Normal file
97
apps/web/components/editor/PaneControls.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
// Shared pane-header controls. Two primitives:
|
||||
//
|
||||
// - <PaneViewTabs/> — segmented control for VIEW MODES that are mutually
|
||||
// exclusive (Tree | A–Z, Diagram | Summary). Always exactly one active.
|
||||
//
|
||||
// - <PaneFilterChip/> — toggleable filter chip with an optional count pip.
|
||||
// Use for filters like "Pending" that overlay the current view mode
|
||||
// rather than replacing it. Disabled when count=0.
|
||||
//
|
||||
// The intent is to stop mixing "view mode" and "filter" in the same
|
||||
// pane-tabs cluster, which currently makes "Pending" feel like a third
|
||||
// view mode instead of a filter you can stack on Tree or A–Z.
|
||||
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// ─── PaneViewTabs ────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaneViewTab<V extends string> {
|
||||
value: V;
|
||||
label: ReactNode;
|
||||
/** Optional tooltip. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface PaneViewTabsProps<V extends string> {
|
||||
value: V;
|
||||
onChange: (v: V) => void;
|
||||
tabs: PaneViewTab<V>[];
|
||||
/** ARIA label for the segmented group. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function PaneViewTabs<V extends string>({
|
||||
value,
|
||||
onChange,
|
||||
tabs,
|
||||
ariaLabel = "View mode",
|
||||
}: PaneViewTabsProps<V>) {
|
||||
return (
|
||||
<div className="pane-view-tabs" role="tablist" aria-label={ariaLabel}>
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === t.value}
|
||||
className={`pane-view-tab ${value === t.value ? "pane-view-tab-active" : ""}`}
|
||||
onClick={() => onChange(t.value)}
|
||||
title={t.title}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PaneFilterChip ──────────────────────────────────────────────────────
|
||||
|
||||
interface PaneFilterChipProps {
|
||||
/** Whether the filter is currently applied. */
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
label: string;
|
||||
/** Optional count to render as a pip (e.g. number of pending items).
|
||||
* When 0 (or undefined), the chip is disabled. */
|
||||
count?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function PaneFilterChip({
|
||||
active,
|
||||
onToggle,
|
||||
label,
|
||||
count,
|
||||
title,
|
||||
}: PaneFilterChipProps) {
|
||||
const hasCount = typeof count === "number" && count > 0;
|
||||
const disabled = typeof count === "number" && count === 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`pane-filter-chip ${active ? "pane-filter-chip-active" : ""} ${
|
||||
hasCount ? "pane-filter-chip-attention" : ""
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="pane-filter-chip-label">{label}</span>
|
||||
{hasCount ? <span className="pane-filter-chip-pip">{count}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
75
apps/web/components/editor/PaneDrawer.tsx
Normal file
75
apps/web/components/editor/PaneDrawer.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// PaneDrawer — collapsible group inside a list pane. Used to group rows by
|
||||
// review state without losing screen real estate when a group is empty or
|
||||
// the user doesn't want to see it.
|
||||
//
|
||||
// Stack layout: Kept list (always-on, fills) → [Pending drawer] → [Discarded
|
||||
// drawer]. Drawers always render their header (so the count is visible at a
|
||||
// glance); their body collapses on toggle.
|
||||
//
|
||||
// Tone is one of:
|
||||
// "default" — neutral, used for kept-only views
|
||||
// "pending" — accent strip + count chip in accent
|
||||
// "muted" — dim styling, used for the Discarded drawer
|
||||
//
|
||||
// The drawer is non-sticky on purpose: the user can scroll past kept items
|
||||
// to reach pending, and the kept count never gets crushed by an over-tall
|
||||
// drawer. CSS lives in styles/base.css under .pane-drawer.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
interface PaneDrawerProps {
|
||||
title: string;
|
||||
/** Optional count rendered as a pip in the header. */
|
||||
count?: number;
|
||||
/** Visual tone. */
|
||||
tone?: "default" | "pending" | "muted";
|
||||
/** Initial expanded state. */
|
||||
defaultOpen?: boolean;
|
||||
/** When the drawer would be empty AND `hideWhenEmpty` is true, the entire
|
||||
* drawer (header included) is omitted. Useful for the Discarded drawer
|
||||
* where 0 items means "nothing dismissed yet, don't even show me the
|
||||
* header." */
|
||||
hideWhenEmpty?: boolean;
|
||||
/** Override action shown to the right of the title (e.g. "Restore all"). */
|
||||
rightAction?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PaneDrawer({
|
||||
title,
|
||||
count,
|
||||
tone = "default",
|
||||
defaultOpen = false,
|
||||
hideWhenEmpty = false,
|
||||
rightAction,
|
||||
children,
|
||||
}: PaneDrawerProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const isEmpty = typeof count === "number" && count === 0;
|
||||
if (hideWhenEmpty && isEmpty) return null;
|
||||
|
||||
return (
|
||||
<section className={`pane-drawer pane-drawer-${tone} ${open ? "pane-drawer-open" : ""}`}>
|
||||
<header className="pane-drawer-head">
|
||||
<button
|
||||
type="button"
|
||||
className="pane-drawer-toggle"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="pane-drawer-caret" aria-hidden="true">
|
||||
{open ? "▾" : "▸"}
|
||||
</span>
|
||||
<span className="pane-drawer-title">{title}</span>
|
||||
{typeof count === "number" ? (
|
||||
<span className="pane-drawer-count">{count}</span>
|
||||
) : null}
|
||||
</button>
|
||||
{rightAction ? <div className="pane-drawer-action">{rightAction}</div> : null}
|
||||
</header>
|
||||
{open ? <div className="pane-drawer-body">{children}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
43
apps/web/components/editor/PaneEmpty.tsx
Normal file
43
apps/web/components/editor/PaneEmpty.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// PaneEmpty — unified empty-state for any list pane or detail column.
|
||||
//
|
||||
// Replaces the scatter of `.pane-empty` blocks across panes with a
|
||||
// consistent structure: title (one short line), an optional descriptive
|
||||
// hint, and an optional primary action.
|
||||
//
|
||||
// The visual treatment is deliberately quiet so empty states don't shout.
|
||||
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PaneEmptyProps {
|
||||
/** Short headline (≤8 words). */
|
||||
title: string;
|
||||
/** Optional secondary copy. Accepts ReactNode so callers can embed a
|
||||
* link or a kbd tag inline. */
|
||||
hint?: ReactNode;
|
||||
/** Optional primary action button (e.g. "Run Analyze →"). */
|
||||
action?: { label: string; onClick: () => void; disabled?: boolean };
|
||||
/** Optional decorative icon — kept tiny, no SVG dependency. */
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function PaneEmpty({ title, hint, action, icon }: PaneEmptyProps) {
|
||||
return (
|
||||
<div className="pane-empty-v2" role="status">
|
||||
{icon ? <div className="pane-empty-icon">{icon}</div> : null}
|
||||
<div className="pane-empty-title">{title}</div>
|
||||
{hint ? <div className="pane-empty-hint">{hint}</div> : null}
|
||||
{action ? (
|
||||
<button
|
||||
type="button"
|
||||
className="pane-empty-action"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/web/components/editor/PaneFrame.tsx
Normal file
37
apps/web/components/editor/PaneFrame.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// Wrapper for any main-area pane: header (title + subtitle + close) + body.
|
||||
// The text-editor pane passes closable={false} so it has no close button.
|
||||
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface PaneFrameProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
right?: ReactNode;
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PaneFrame({ title, subtitle, right, closable = true, onClose, children }: PaneFrameProps) {
|
||||
return (
|
||||
<section className="pane">
|
||||
<header className="pane-header">
|
||||
<div className="pane-titles">
|
||||
<span className="pane-title">{title}</span>
|
||||
{subtitle ? <span className="pane-subtitle">{subtitle}</span> : null}
|
||||
</div>
|
||||
<div className="pane-right">
|
||||
{right}
|
||||
{closable ? (
|
||||
<button className="pane-close" type="button" onClick={onClose} aria-label="Close pane">
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className="pane-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
391
apps/web/components/editor/PromoteToolbar.tsx
Normal file
391
apps/web/components/editor/PromoteToolbar.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
// PromoteToolbar — converts a taxonomy term into a formal SysML element
|
||||
// without leaving the document. Lives inside TermDetail.
|
||||
//
|
||||
// The four buttons map onto the existing ModelOp alphabet so undo/redo and
|
||||
// SSE sync work for free; each op carries `linkedTermId = term.id` so the
|
||||
// new formalism is anchored back to the concept that named it.
|
||||
//
|
||||
// All forms are inline + dismissive: an open form replaces the toolbar, and
|
||||
// the user can cancel back to the toolbar without a destructive action.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useModelStore } from "../../lib/sync/ModelStore";
|
||||
import type { ClientTerm } from "../../lib/workspace/analysisStore";
|
||||
import { useOpenPanes } from "../../lib/workspace/openPanesStore";
|
||||
import { useEditorPaneContext } from "./sections/paneContext";
|
||||
import {
|
||||
addBlock,
|
||||
addAssociation,
|
||||
addConstraint,
|
||||
addRequirement,
|
||||
tempId,
|
||||
} from "../../lib/sync/ops";
|
||||
|
||||
interface Props {
|
||||
term: ClientTerm;
|
||||
/** Called after a successful promote, so the parent can flash + scroll. */
|
||||
onPromoted?: (kind: "block" | "association" | "constraint" | "requirement") => void;
|
||||
}
|
||||
|
||||
type FormKind = null | "association" | "constraint" | "requirement";
|
||||
|
||||
export function PromoteToolbar({ term, onPromoted }: Props) {
|
||||
const { model, apply } = useModelStore();
|
||||
const [open, setOpen] = useState<FormKind>(null);
|
||||
const { openSection } = useOpenPanes();
|
||||
const { setFocusBlockId } = useEditorPaneContext();
|
||||
|
||||
// Persist the term-to-block link server-side after a successful promote so
|
||||
// the next analyze pass sees the link.
|
||||
const persistTermLink = async (blockId: string) => {
|
||||
try {
|
||||
const projectId = window.location.pathname.split("/").pop() ?? "";
|
||||
if (!projectId) return;
|
||||
await fetch(`/api/projects/${encodeURIComponent(projectId)}/term-link`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ termId: term.id, blockId }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[PromoteToolbar] term-link persistence failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const promoteBlock = () => {
|
||||
const id = tempId("blk");
|
||||
apply([
|
||||
addBlock(
|
||||
{
|
||||
id,
|
||||
label: term.label,
|
||||
kind: "block",
|
||||
stereotypes: ["block"],
|
||||
properties: [],
|
||||
linkedTermId: term.id,
|
||||
},
|
||||
id
|
||||
),
|
||||
]);
|
||||
void persistTermLink(id);
|
||||
openSection("model");
|
||||
setFocusBlockId(id);
|
||||
onPromoted?.("block");
|
||||
};
|
||||
|
||||
if (open === "association") {
|
||||
return (
|
||||
<AssociationForm
|
||||
term={term}
|
||||
blocks={model.blocks}
|
||||
onCancel={() => setOpen(null)}
|
||||
onSubmit={(fromId, toId, label) => {
|
||||
const id = tempId("a");
|
||||
apply([
|
||||
addAssociation(
|
||||
{ id, fromBlockId: fromId, toBlockId: toId, label, kind: "association", linkedTermId: term.id },
|
||||
id
|
||||
),
|
||||
]);
|
||||
setOpen(null);
|
||||
openSection("model");
|
||||
onPromoted?.("association");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (open === "constraint") {
|
||||
return (
|
||||
<ConstraintForm
|
||||
term={term}
|
||||
blocks={model.blocks}
|
||||
onCancel={() => setOpen(null)}
|
||||
onSubmit={(label, expression, appliesTo) => {
|
||||
const id = tempId("c");
|
||||
apply([
|
||||
addConstraint(
|
||||
{ id, label, expression, appliesTo, linkedTermId: term.id },
|
||||
id
|
||||
),
|
||||
]);
|
||||
setOpen(null);
|
||||
openSection("model");
|
||||
onPromoted?.("constraint");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (open === "requirement") {
|
||||
return (
|
||||
<RequirementForm
|
||||
term={term}
|
||||
blocks={model.blocks}
|
||||
onCancel={() => setOpen(null)}
|
||||
onSubmit={(tag, text, satisfiedBy) => {
|
||||
const id = tempId("r");
|
||||
apply([
|
||||
addRequirement(
|
||||
{
|
||||
id,
|
||||
tag,
|
||||
text,
|
||||
relations: satisfiedBy.map(blockId => ({ kind: "satisfy" as const, blockId })),
|
||||
linkedTermId: term.id,
|
||||
},
|
||||
id
|
||||
),
|
||||
]);
|
||||
setOpen(null);
|
||||
openSection("requirements");
|
||||
onPromoted?.("requirement");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// No block linked yet → recommend "Make Block" as the primary action.
|
||||
const hasBlock = !!term.linkedBlockId;
|
||||
return (
|
||||
<div className="promote-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className={`promote-btn ${!hasBlock ? "promote-btn-primary" : ""}`}
|
||||
onClick={promoteBlock}
|
||||
title={hasBlock ? "Already a block" : "Create a SysML block from this concept"}
|
||||
disabled={hasBlock}
|
||||
>
|
||||
+ Block
|
||||
</button>
|
||||
<button type="button" className="promote-btn" onClick={() => setOpen("association")}>
|
||||
+ Association
|
||||
</button>
|
||||
<button type="button" className="promote-btn" onClick={() => setOpen("constraint")}>
|
||||
+ Constraint
|
||||
</button>
|
||||
<button type="button" className="promote-btn" onClick={() => setOpen("requirement")}>
|
||||
+ Requirement
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inline forms ───────────────────────────────────────────────────────
|
||||
|
||||
interface AssocFormProps {
|
||||
term: ClientTerm;
|
||||
blocks: { id: string; label: string }[];
|
||||
onCancel: () => void;
|
||||
onSubmit: (fromId: string, toId: string, label: string) => void;
|
||||
}
|
||||
|
||||
function AssociationForm({ term, blocks, onCancel, onSubmit }: AssocFormProps) {
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [label, setLabel] = useState(term.label);
|
||||
const canSubmit = from && to && from !== to;
|
||||
return (
|
||||
<form
|
||||
className="promote-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onSubmit(from, to, label.trim() || term.label);
|
||||
}}
|
||||
>
|
||||
<FormHead title="New Association" subtitle={`anchored to "${term.label}"`} onCancel={onCancel} />
|
||||
<BlockSelect label="From" value={from} onChange={setFrom} blocks={blocks} />
|
||||
<BlockSelect label="To" value={to} onChange={setTo} blocks={blocks} excludeId={from} />
|
||||
<FieldRow label="Label">
|
||||
<input
|
||||
className="promote-input"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="verb phrase"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FormActions canSubmit={!!canSubmit} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConstraintFormProps {
|
||||
term: ClientTerm;
|
||||
blocks: { id: string; label: string }[];
|
||||
onCancel: () => void;
|
||||
onSubmit: (label: string, expression: string, appliesTo: string[]) => void;
|
||||
}
|
||||
|
||||
function ConstraintForm({ term, blocks, onCancel, onSubmit }: ConstraintFormProps) {
|
||||
const [label, setLabel] = useState(term.label);
|
||||
const [expression, setExpression] = useState("");
|
||||
const [appliesTo, setAppliesTo] = useState<string[]>([]);
|
||||
const canSubmit = !!label.trim();
|
||||
return (
|
||||
<form
|
||||
className="promote-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onSubmit(label.trim(), expression.trim(), appliesTo);
|
||||
}}
|
||||
>
|
||||
<FormHead title="New Constraint" subtitle={`anchored to "${term.label}"`} onCancel={onCancel} />
|
||||
<FieldRow label="Label">
|
||||
<input className="promote-input" value={label} onChange={e => setLabel(e.target.value)} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Expression">
|
||||
<input
|
||||
className="promote-input"
|
||||
value={expression}
|
||||
onChange={e => setExpression(e.target.value)}
|
||||
placeholder="e.g. sessions_per_day <= 3"
|
||||
/>
|
||||
</FieldRow>
|
||||
<BlockMultiSelect label="Applies to" value={appliesTo} onChange={setAppliesTo} blocks={blocks} />
|
||||
<FormActions canSubmit={canSubmit} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface RequirementFormProps {
|
||||
term: ClientTerm;
|
||||
blocks: { id: string; label: string }[];
|
||||
onCancel: () => void;
|
||||
onSubmit: (tag: string, text: string, satisfiedBy: string[]) => void;
|
||||
}
|
||||
|
||||
function RequirementForm({ term, blocks, onCancel, onSubmit }: RequirementFormProps) {
|
||||
const [tag, setTag] = useState("REQ-001");
|
||||
const [text, setText] = useState("");
|
||||
const [satisfiedBy, setSatisfiedBy] = useState<string[]>(
|
||||
term.linkedBlockId ? [term.linkedBlockId] : []
|
||||
);
|
||||
const canSubmit = !!tag.trim() && !!text.trim();
|
||||
return (
|
||||
<form
|
||||
className="promote-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onSubmit(tag.trim(), text.trim(), satisfiedBy);
|
||||
}}
|
||||
>
|
||||
<FormHead title="New Requirement" subtitle={`anchored to "${term.label}"`} onCancel={onCancel} />
|
||||
<FieldRow label="Tag">
|
||||
<input className="promote-input" value={tag} onChange={e => setTag(e.target.value)} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Text">
|
||||
<textarea
|
||||
className="promote-textarea"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
placeholder="The system must …"
|
||||
rows={2}
|
||||
/>
|
||||
</FieldRow>
|
||||
<BlockMultiSelect label="Satisfied by" value={satisfiedBy} onChange={setSatisfiedBy} blocks={blocks} />
|
||||
<FormActions canSubmit={canSubmit} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Form atoms ─────────────────────────────────────────────────────────
|
||||
|
||||
function FormHead({ title, subtitle, onCancel }: { title: string; subtitle: string; onCancel: () => void }) {
|
||||
return (
|
||||
<div className="promote-form-head">
|
||||
<div>
|
||||
<div className="promote-form-title">{title}</div>
|
||||
<div className="promote-form-subtitle">{subtitle}</div>
|
||||
</div>
|
||||
<button type="button" className="promote-form-cancel" onClick={onCancel} aria-label="Cancel">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="promote-field">
|
||||
<span className="promote-field-label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FormActions({ canSubmit }: { canSubmit: boolean }) {
|
||||
return (
|
||||
<div className="promote-form-actions">
|
||||
<button type="submit" className="promote-btn promote-btn-primary" disabled={!canSubmit}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BlockSelectProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
blocks: { id: string; label: string }[];
|
||||
excludeId?: string;
|
||||
}
|
||||
|
||||
function BlockSelect({ label, value, onChange, blocks, excludeId }: BlockSelectProps) {
|
||||
const options = blocks.filter(b => b.id !== excludeId);
|
||||
return (
|
||||
<FieldRow label={label}>
|
||||
<select className="promote-input" value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">— pick block —</option>
|
||||
{options.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface BlockMultiSelectProps {
|
||||
label: string;
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
blocks: { id: string; label: string }[];
|
||||
}
|
||||
|
||||
function BlockMultiSelect({ label, value, onChange, blocks }: BlockMultiSelectProps) {
|
||||
if (blocks.length === 0) {
|
||||
return (
|
||||
<FieldRow label={label}>
|
||||
<span className="promote-empty">No blocks yet — promote one first.</span>
|
||||
</FieldRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FieldRow label={label}>
|
||||
<div className="promote-checkbox-list">
|
||||
{blocks.map(b => {
|
||||
const checked = value.includes(b.id);
|
||||
return (
|
||||
<label key={b.id} className="promote-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() =>
|
||||
onChange(checked ? value.filter(x => x !== b.id) : [...value, b.id])
|
||||
}
|
||||
/>
|
||||
<span>{b.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FieldRow>
|
||||
);
|
||||
}
|
||||
|
||||
74
apps/web/components/editor/Spinner.tsx
Normal file
74
apps/web/components/editor/Spinner.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
// Spinner — a small, polished SVG ring used everywhere the UI needs to
|
||||
// signal "working." Two stacked circles: a faint full ring (track) and a
|
||||
// shorter accent arc on top, with the arc rotating around the center.
|
||||
// The dasharray + animation give a smooth motion that doesn't depend on
|
||||
// CSS-only border tricks (which look chunky at small sizes).
|
||||
|
||||
"use client";
|
||||
|
||||
interface SpinnerProps {
|
||||
/** Pixel size; defaults to 14 (matches sidebar button glyph metrics). */
|
||||
size?: number;
|
||||
/** Accent stroke color; defaults to `currentColor` so the spinner picks
|
||||
* up the surrounding text color. */
|
||||
color?: string;
|
||||
/** Track stroke color; defaults to a faint border tone. */
|
||||
trackColor?: string;
|
||||
/** Stroke width in SVG units (viewBox 24×24). 2.5 reads cleanly at 14px. */
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function Spinner({
|
||||
size = 14,
|
||||
color = "currentColor",
|
||||
trackColor = "rgba(0,0,0,0.14)",
|
||||
strokeWidth = 2.5,
|
||||
className,
|
||||
title,
|
||||
}: SpinnerProps) {
|
||||
return (
|
||||
<svg
|
||||
className={`spinner ${className ?? ""}`}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
role={title ? "img" : "presentation"}
|
||||
aria-label={title}
|
||||
aria-hidden={title ? undefined : true}
|
||||
>
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
fill="none"
|
||||
stroke={trackColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Animated arc — strokeDasharray ~= 25% of the circumference (2π·9 ≈ 56.5);
|
||||
* we use 14 56.5-14 = 14 / 42.5 to draw a 14-unit arc. */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="14 42.5"
|
||||
transform="rotate(-90 12 12)"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="-90 12 12"
|
||||
to="270 12 12"
|
||||
dur="0.85s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
89
apps/web/components/editor/StatusChip.tsx
Normal file
89
apps/web/components/editor/StatusChip.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// StatusChip — the single visual idiom for any "this thing has a state"
|
||||
// signal in the workspace. Replaces the five ad-hoc chips that grew over
|
||||
// time: term-badge-new / term-badge-deprecated / finding-sev / finding-code /
|
||||
// req-flag / EntityColumns ReviewBadge.
|
||||
//
|
||||
// Variants are picked from a tiny vocabulary so the visual rhythm is
|
||||
// consistent across surfaces: review state (suggested / accepted /
|
||||
// deprecated / dismissed / resolved), severity (low / medium / high), or a
|
||||
// validation code (S1, M2, X3, …) rendered as `code`.
|
||||
//
|
||||
// Usage:
|
||||
// <StatusChip variant="suggested" /> // "NEW"
|
||||
// <StatusChip variant="deprecated" /> // "DEPRECATED"
|
||||
// <StatusChip variant="severity" value="high" />
|
||||
// <StatusChip variant="code" value="X3" />
|
||||
// <StatusChip variant="confidence" value={0.87} />
|
||||
// <StatusChip variant="warn" label="unsupported" />
|
||||
//
|
||||
// CSS lives in styles/base.css under .status-chip; tone is one of:
|
||||
// accent — review-pending / NEW / suggestion
|
||||
// muted — neutral / deprecated / code
|
||||
// warn — risky / high-severity / unsupported
|
||||
// ok — confirmed / resolved
|
||||
// info — informational / confidence
|
||||
|
||||
"use client";
|
||||
|
||||
export type StatusTone = "accent" | "muted" | "warn" | "ok" | "info";
|
||||
|
||||
export type StatusChipProps =
|
||||
| { variant: "suggested"; title?: string }
|
||||
| { variant: "accepted"; title?: string }
|
||||
| { variant: "deprecated"; title?: string }
|
||||
| { variant: "dismissed"; title?: string }
|
||||
| { variant: "resolved"; title?: string }
|
||||
| { variant: "severity"; value: "low" | "medium" | "high"; title?: string }
|
||||
| { variant: "code"; value: string; title?: string }
|
||||
| { variant: "confidence"; value: number; title?: string }
|
||||
| { variant: "warn"; label: string; title?: string }
|
||||
| { variant: "ok"; label: string; title?: string }
|
||||
| { variant: "muted"; label: string; title?: string };
|
||||
|
||||
/** Single-axis style picker. Every visual decision flows through this fn so
|
||||
* designers can tweak one place. */
|
||||
function styleFor(props: StatusChipProps): { tone: StatusTone; label: string } {
|
||||
switch (props.variant) {
|
||||
case "suggested":
|
||||
return { tone: "accent", label: "NEW" };
|
||||
case "accepted":
|
||||
return { tone: "ok", label: "KEPT" };
|
||||
case "deprecated":
|
||||
return { tone: "muted", label: "DEPRECATED" };
|
||||
case "dismissed":
|
||||
return { tone: "muted", label: "DISMISSED" };
|
||||
case "resolved":
|
||||
return { tone: "ok", label: "RESOLVED" };
|
||||
case "severity":
|
||||
return {
|
||||
tone: props.value === "high" ? "warn" : props.value === "medium" ? "info" : "muted",
|
||||
label: props.value.toUpperCase(),
|
||||
};
|
||||
case "code":
|
||||
return { tone: "muted", label: props.value };
|
||||
case "confidence":
|
||||
return {
|
||||
tone: props.value >= 0.75 ? "ok" : props.value >= 0.4 ? "info" : "muted",
|
||||
label: `${Math.round(props.value * 100)}%`,
|
||||
};
|
||||
case "warn":
|
||||
return { tone: "warn", label: props.label };
|
||||
case "ok":
|
||||
return { tone: "ok", label: props.label };
|
||||
case "muted":
|
||||
return { tone: "muted", label: props.label };
|
||||
}
|
||||
}
|
||||
|
||||
export function StatusChip(props: StatusChipProps) {
|
||||
const { tone, label } = styleFor(props);
|
||||
return (
|
||||
<span
|
||||
className={`status-chip status-chip-${tone}`}
|
||||
title={props.title}
|
||||
aria-label={props.title ?? label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,44 @@
|
||||
// Top-level brand row: name + breadcrumbs on the left, sync pill + avatar on the right.
|
||||
// Ported from docs/design-source/socrata/project/editor-shell.jsx (TopBar).
|
||||
// Top-level brand row + Analyze All action (the pivot's primary global verb).
|
||||
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||
import { useAnalysis } from "../../lib/workspace/analysisStore";
|
||||
|
||||
interface TopBarProps {
|
||||
data: FixtureData;
|
||||
}
|
||||
|
||||
export function TopBar({ data }: TopBarProps) {
|
||||
const { analyze, inFlight } = useAnalysis();
|
||||
const running = inFlight.size > 0;
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<div className="brand">
|
||||
<Link href="/" className="brand" title="Back to all projects" style={{ textDecoration: "none", color: "inherit" }}>
|
||||
<span className="brand-name">Socrata</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="breadcrumbs">
|
||||
<span className="bc-sep">/</span>
|
||||
<span className="bc-item">{data.project.scope}</span>
|
||||
<span className="bc-sep">/</span>
|
||||
<span className="bc-item bc-active">{data.project.name}</span>
|
||||
<span className="bc-branch">
|
||||
<span className="bc-branch-glyph">⎇</span> {data.project.branch}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<div className="sync-pill">
|
||||
<span className="sync-dot" /> model in sync · {data.project.lastSync}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="topbar-analyze-all"
|
||||
onClick={() => void analyze("all")}
|
||||
disabled={running}
|
||||
title="Run all analyses against the current document"
|
||||
>
|
||||
{inFlight.has("all") ? "Analyzing all…" : running ? "Analyzing…" : "Analyze All"}
|
||||
</button>
|
||||
<div className="topbar-divider" />
|
||||
<Link href="/seed" className="topbar-new" title="Start a new idea">
|
||||
+ new idea
|
||||
</Link>
|
||||
<div className="topbar-divider" />
|
||||
<div className="avatar">MC</div>
|
||||
</div>
|
||||
|
||||
994
apps/web/components/editor/columns/EntityColumns.tsx
Normal file
994
apps/web/components/editor/columns/EntityColumns.tsx
Normal file
@@ -0,0 +1,994 @@
|
||||
// Detail-column panes for the Finder-style stack.
|
||||
//
|
||||
// Each column takes its own subject id (termId, blockId, etc.) and renders
|
||||
// in the same `.pane` shell as the section panes, so resize / close / header
|
||||
// behavior is consistent across the stack.
|
||||
//
|
||||
// Click handlers inside these columns call `pushFrom(index, ...)` to drill
|
||||
// further (e.g. clicking a linked block on a term column pushes a block
|
||||
// column to its right). The `index` prop is the column's own position in
|
||||
// the stack — so child pushes can truncate everything to its right.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import { StatusChip } from "../StatusChip";
|
||||
import { useAnalysis, type ClientTerm } from "../../../lib/workspace/analysisStore";
|
||||
import { useModelStore } from "../../../lib/sync/ModelStore";
|
||||
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
|
||||
import { ContextualSocratesThread } from "../../socrates/ContextualSocratesThread";
|
||||
import { PromoteToolbar } from "../PromoteToolbar";
|
||||
import {
|
||||
decideElement,
|
||||
removeBlock,
|
||||
removeAssociation,
|
||||
removeConstraint,
|
||||
removeRequirement,
|
||||
type ReviewableElementKind,
|
||||
} from "../../../lib/sync/ops";
|
||||
import type { ReviewStatus, SysMLModel } from "../../../lib/sysml/model";
|
||||
|
||||
// ─── Term column ────────────────────────────────────────────────────────
|
||||
|
||||
interface TermColumnProps {
|
||||
termId: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TermColumn({ termId, index, onClose }: TermColumnProps) {
|
||||
const { getTerm, terms, requirements, findings } = useAnalysis();
|
||||
const { model } = useModelStore();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
|
||||
const term = getTerm(termId);
|
||||
if (!term) {
|
||||
return (
|
||||
<PaneFrame title="Concept" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This concept no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const parents = buildParentChain(term, terms);
|
||||
const children = terms.filter(t => t.parentId === term.id);
|
||||
const linkedBlock = term.linkedBlockId
|
||||
? model.blocks.find(b => b.id === term.linkedBlockId) ?? null
|
||||
: null;
|
||||
const linkedAssociations = model.associations.filter(a => a.linkedTermId === term.id);
|
||||
const linkedConstraints = model.constraints.filter(c => c.linkedTermId === term.id);
|
||||
const linkedReqsFromModel = model.requirements.filter(r => r.linkedTermId === term.id);
|
||||
const linkedReqsFromAnalyze = requirements.filter(r => r.linkedTermId === term.id);
|
||||
const relatedFindings = linkedBlock
|
||||
? findings.filter(f => f.linkedElementIds.includes(linkedBlock.id))
|
||||
: findings.filter(f => f.linkedElementIds.includes(`term:${term.id}`));
|
||||
|
||||
const hasFormalism =
|
||||
!!linkedBlock ||
|
||||
linkedAssociations.length > 0 ||
|
||||
linkedConstraints.length > 0 ||
|
||||
linkedReqsFromModel.length > 0 ||
|
||||
linkedReqsFromAnalyze.length > 0;
|
||||
|
||||
return (
|
||||
<PaneFrame
|
||||
title={term.label}
|
||||
subtitle="concept"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
<DefinitionSection term={term} />
|
||||
|
||||
{(parents.length > 0 || children.length > 0 || term.synonyms.length > 0) && (
|
||||
<Section label="Hierarchy">
|
||||
{parents.length > 0 && (
|
||||
<Row label="Parents">
|
||||
{parents.map((p, i) => (
|
||||
<span key={p.id}>
|
||||
{i > 0 ? " › " : null}
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index - 1, { kind: "term", id: p.id })}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
{children.length > 0 && (
|
||||
<Row label="Children">
|
||||
{children.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: c.id })}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
{term.synonyms.length > 0 && (
|
||||
<Row label="Synonyms">
|
||||
{term.synonyms.map(s => (
|
||||
<span key={s} className="termdetail-synonym">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section label="Promote to formal element">
|
||||
<PromoteToolbar term={term} />
|
||||
</Section>
|
||||
|
||||
<Section label="Formalisms">
|
||||
{!hasFormalism ? (
|
||||
<p className="termdetail-empty">
|
||||
Not yet formalized. Promote it above to make it a block, association, constraint, or requirement.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="termdetail-formalism-list">
|
||||
{linkedBlock && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "block", id: linkedBlock.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">{linkedBlock.kind}</span>
|
||||
<span className="termdetail-formalism-label">{linkedBlock.label}</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{linkedAssociations.map(a => (
|
||||
<li key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "association", id: a.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">{a.kind}</span>
|
||||
<span className="termdetail-formalism-label">
|
||||
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
|
||||
{labelOf(model, a.toBlockId)}
|
||||
</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{linkedConstraints.map(c => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "constraint", id: c.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">constraint</span>
|
||||
<span className="termdetail-formalism-label">
|
||||
{c.label}
|
||||
{c.expression ? <> — <code>{c.expression}</code></> : null}
|
||||
</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{linkedReqsFromModel.map(r => (
|
||||
<li key={`m-${r.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "requirement", id: r.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">{r.tag}</span>
|
||||
<span className="termdetail-formalism-label">{r.text}</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{linkedReqsFromAnalyze
|
||||
.filter(r => !linkedReqsFromModel.some(m => m.tag === r.tag))
|
||||
.map(r => (
|
||||
<li key={`a-${r.id}`}>
|
||||
<div className="termdetail-formalism termdetail-formalism-static">
|
||||
<span className="termdetail-formalism-kind">{r.tag}</span>
|
||||
<span className="termdetail-formalism-label">{r.text}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{relatedFindings.length > 0 && (
|
||||
<Section label={`Findings (${relatedFindings.length})`}>
|
||||
<ul className="termdetail-findings">
|
||||
{relatedFindings.map(f => (
|
||||
<li key={f.id} className={`termdetail-finding termdetail-finding-${f.kind}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-finding-btn"
|
||||
onClick={() => pushFrom(index, { kind: "finding", id: f.id })}
|
||||
>
|
||||
<span className="termdetail-finding-kind">{f.kind}</span>
|
||||
<span className="termdetail-finding-text">{f.text}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Block column ───────────────────────────────────────────────────────
|
||||
|
||||
export function BlockColumn({
|
||||
blockId,
|
||||
index,
|
||||
onClose,
|
||||
}: {
|
||||
blockId: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { model, apply } = useModelStore();
|
||||
const { findings } = useAnalysis();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
const block = model.blocks.find(b => b.id === blockId);
|
||||
|
||||
if (!block) {
|
||||
return (
|
||||
<PaneFrame title="Block" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This block no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const associations = model.associations.filter(
|
||||
a => a.fromBlockId === block.id || a.toBlockId === block.id
|
||||
);
|
||||
const requirements = model.requirements.filter(r =>
|
||||
r.relations.some(rel => rel.kind === "satisfy" && rel.blockId === block.id)
|
||||
);
|
||||
const relatedFindings = findings.filter(f => f.linkedElementIds.includes(block.id));
|
||||
|
||||
return (
|
||||
<PaneFrame
|
||||
title={block.label}
|
||||
subtitle={block.kind === "block" ? "block" : `block · ${block.kind}`}
|
||||
right={
|
||||
<ReviewBadge status={block.reviewStatus}>
|
||||
<DecideButtons
|
||||
kind="block"
|
||||
id={block.id}
|
||||
status={block.reviewStatus}
|
||||
onKeep={() => apply([decideElement({ kind: "block", id: block.id })])}
|
||||
onDiscard={() => apply([removeBlock(block.id)])}
|
||||
/>
|
||||
</ReviewBadge>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
{block.linkedTermId && (
|
||||
<Section label="Concept">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: block.linkedTermId! })}
|
||||
>
|
||||
↪ open concept
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
{block.properties.length > 0 && (
|
||||
<Section label="Properties">
|
||||
<ul className="block-prop-list">
|
||||
{block.properties.map(p => (
|
||||
<li key={p.id} className="block-prop">
|
||||
<span className="block-prop-name">{p.name}</span>
|
||||
<span className="block-prop-type">{p.type.kind}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{associations.length > 0 && (
|
||||
<Section label="Associations">
|
||||
<ul className="termdetail-formalism-list">
|
||||
{associations.map(a => (
|
||||
<li key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "association", id: a.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">{a.kind}</span>
|
||||
<span className="termdetail-formalism-label">
|
||||
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
|
||||
{labelOf(model, a.toBlockId)}
|
||||
</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{requirements.length > 0 && (
|
||||
<Section label="Requirements satisfied">
|
||||
<ul className="termdetail-formalism-list">
|
||||
{requirements.map(r => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "requirement", id: r.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">{r.tag}</span>
|
||||
<span className="termdetail-formalism-label">{r.text}</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{relatedFindings.length > 0 && (
|
||||
<Section label={`Findings (${relatedFindings.length})`}>
|
||||
<ul className="termdetail-findings">
|
||||
{relatedFindings.map(f => (
|
||||
<li key={f.id} className={`termdetail-finding termdetail-finding-${f.kind}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-finding-btn"
|
||||
onClick={() => pushFrom(index, { kind: "finding", id: f.id })}
|
||||
>
|
||||
<span className="termdetail-finding-kind">{f.kind}</span>
|
||||
<span className="termdetail-finding-text">{f.text}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Association column ─────────────────────────────────────────────────
|
||||
|
||||
export function AssociationColumn({
|
||||
associationId,
|
||||
index,
|
||||
onClose,
|
||||
}: {
|
||||
associationId: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { model, apply } = useModelStore();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
const a = model.associations.find(x => x.id === associationId);
|
||||
if (!a) {
|
||||
return (
|
||||
<PaneFrame title="Association" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This association no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
const from = model.blocks.find(b => b.id === a.fromBlockId);
|
||||
const to = model.blocks.find(b => b.id === a.toBlockId);
|
||||
|
||||
return (
|
||||
<PaneFrame
|
||||
title={a.label || "Association"}
|
||||
subtitle={a.kind === "association" ? "association" : `association · ${a.kind}`}
|
||||
right={
|
||||
<ReviewBadge status={a.reviewStatus}>
|
||||
<DecideButtons
|
||||
kind="association"
|
||||
id={a.id}
|
||||
status={a.reviewStatus}
|
||||
onKeep={() => apply([decideElement({ kind: "association", id: a.id })])}
|
||||
onDiscard={() => apply([removeAssociation(a.id)])}
|
||||
/>
|
||||
</ReviewBadge>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
<Section label="Endpoints">
|
||||
<Row label="From">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => from && pushFrom(index, { kind: "block", id: from.id })}
|
||||
disabled={!from}
|
||||
>
|
||||
{from?.label ?? a.fromBlockId}
|
||||
</button>
|
||||
</Row>
|
||||
<Row label="To">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => to && pushFrom(index, { kind: "block", id: to.id })}
|
||||
disabled={!to}
|
||||
>
|
||||
{to?.label ?? a.toBlockId}
|
||||
</button>
|
||||
</Row>
|
||||
</Section>
|
||||
{a.linkedTermId && (
|
||||
<Section label="Concept">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: a.linkedTermId! })}
|
||||
>
|
||||
↪ open concept
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Constraint column ──────────────────────────────────────────────────
|
||||
|
||||
export function ConstraintColumn({
|
||||
constraintId,
|
||||
index,
|
||||
onClose,
|
||||
}: {
|
||||
constraintId: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { model, apply } = useModelStore();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
const c = model.constraints.find(x => x.id === constraintId);
|
||||
if (!c) {
|
||||
return (
|
||||
<PaneFrame title="Constraint" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This constraint no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PaneFrame
|
||||
title={c.label}
|
||||
subtitle="constraint"
|
||||
right={
|
||||
<ReviewBadge status={c.reviewStatus}>
|
||||
<DecideButtons
|
||||
kind="constraint"
|
||||
id={c.id}
|
||||
status={c.reviewStatus}
|
||||
onKeep={() => apply([decideElement({ kind: "constraint", id: c.id })])}
|
||||
onDiscard={() => apply([removeConstraint(c.id)])}
|
||||
/>
|
||||
</ReviewBadge>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
{c.expression && (
|
||||
<Section label="Expression">
|
||||
<code className="constraint-expr">{c.expression}</code>
|
||||
</Section>
|
||||
)}
|
||||
{c.appliesTo.length > 0 && (
|
||||
<Section label="Applies to">
|
||||
<ul className="termdetail-formalism-list">
|
||||
{c.appliesTo.map(bid => {
|
||||
const b = model.blocks.find(x => x.id === bid);
|
||||
return (
|
||||
<li key={bid}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "block", id: bid })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">block</span>
|
||||
<span className="termdetail-formalism-label">{b?.label ?? bid}</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{c.linkedTermId && (
|
||||
<Section label="Concept">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: c.linkedTermId! })}
|
||||
>
|
||||
↪ open concept
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Requirement column ─────────────────────────────────────────────────
|
||||
|
||||
export function RequirementColumn({
|
||||
requirementId,
|
||||
index,
|
||||
onClose,
|
||||
}: {
|
||||
requirementId: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { model, apply } = useModelStore();
|
||||
const { requirements: analyzedReqs, getTerm, decideRequirement } = useAnalysis();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
|
||||
// Match by id in either list.
|
||||
const modelReq = model.requirements.find(r => r.id === requirementId);
|
||||
const analyzedReq = analyzedReqs.find(r => r.id === requirementId);
|
||||
|
||||
if (!modelReq && !analyzedReq) {
|
||||
return (
|
||||
<PaneFrame title="Requirement" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This requirement no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const tag = modelReq?.tag ?? analyzedReq?.tag ?? requirementId;
|
||||
const text = modelReq?.text ?? analyzedReq?.text ?? "";
|
||||
const linkedTermId = modelReq?.linkedTermId ?? analyzedReq?.linkedTermId ?? null;
|
||||
const tracedTo = modelReq?.relations
|
||||
.filter((r): r is { kind: "satisfy"; blockId: string } => r.kind === "satisfy")
|
||||
.map(r => r.blockId) ?? analyzedReq?.tracedToIds ?? [];
|
||||
const reviewStatus = modelReq?.reviewStatus;
|
||||
const analyzedStatus = analyzedReq?.status;
|
||||
|
||||
return (
|
||||
<PaneFrame
|
||||
title={tag}
|
||||
subtitle="requirement"
|
||||
right={
|
||||
modelReq ? (
|
||||
<ReviewBadge status={reviewStatus}>
|
||||
<DecideButtons
|
||||
kind="requirement"
|
||||
id={modelReq.id}
|
||||
status={reviewStatus}
|
||||
onKeep={() => apply([decideElement({ kind: "requirement", id: modelReq.id })])}
|
||||
onDiscard={() => apply([removeRequirement(modelReq.id)])}
|
||||
/>
|
||||
</ReviewBadge>
|
||||
) : analyzedReq && analyzedStatus !== "accepted" ? (
|
||||
<div className="term-decision-row">
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={() => void decideRequirement(analyzedReq.id, "keep")}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={() => void decideRequirement(analyzedReq.id, "discard")}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
<Section label="Text">
|
||||
<p className="termdetail-def">{text}</p>
|
||||
</Section>
|
||||
{linkedTermId && (
|
||||
<Section label="Concept">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: linkedTermId })}
|
||||
>
|
||||
{getTerm(linkedTermId)?.label ?? "↪ open concept"}
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
{tracedTo.length > 0 && (
|
||||
<Section label="Traced to">
|
||||
<ul className="termdetail-formalism-list">
|
||||
{tracedTo.map(bid => {
|
||||
const b = model.blocks.find(x => x.id === bid);
|
||||
return (
|
||||
<li key={bid}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "block", id: bid })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">block</span>
|
||||
<span className="termdetail-formalism-label">{b?.label ?? bid}</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Finding column ─────────────────────────────────────────────────────
|
||||
|
||||
export function FindingColumn({
|
||||
findingId,
|
||||
index,
|
||||
projectId,
|
||||
onClose,
|
||||
}: {
|
||||
findingId: string;
|
||||
index: number;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { findings, decideFinding, refresh } = useAnalysis();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
const f = findings.find(x => x.id === findingId);
|
||||
|
||||
if (!f) {
|
||||
return (
|
||||
<PaneFrame title="Finding" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This finding no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = f.status === "suggested" || f.status === "deprecated";
|
||||
const termAnchors = f.linkedElementIds
|
||||
.filter(s => s.startsWith("term:"))
|
||||
.map(s => s.slice("term:".length));
|
||||
const elementAnchors = f.linkedElementIds.filter(s => !s.startsWith("term:"));
|
||||
|
||||
return (
|
||||
<PaneFrame
|
||||
title={titleCase(f.kind)}
|
||||
subtitle={
|
||||
f.severity
|
||||
? `${f.kind} · ${f.severity} · conf ${(f.confidence * 100).toFixed(0)}%`
|
||||
: `${f.kind} · conf ${(f.confidence * 100).toFixed(0)}%`
|
||||
}
|
||||
right={
|
||||
isPending ? (
|
||||
<div className="term-decision-row">
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={() => void decideFinding(f.id, "keep")}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={() => void decideFinding(f.id, "discard")}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-resolve"
|
||||
onClick={() => void decideFinding(f.id, "resolve")}
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
<Section label={f.validationCode ? `${f.validationCode} — finding` : "Finding"}>
|
||||
<p className="termdetail-def">{f.text}</p>
|
||||
</Section>
|
||||
{(termAnchors.length > 0 || elementAnchors.length > 0) && (
|
||||
<Section label="Anchors">
|
||||
{termAnchors.map(tid => (
|
||||
<button
|
||||
key={tid}
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: tid })}
|
||||
>
|
||||
↪ concept
|
||||
</button>
|
||||
))}
|
||||
{elementAnchors.map(eid => (
|
||||
<button
|
||||
key={eid}
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "block", id: eid })}
|
||||
>
|
||||
↪ {eid}
|
||||
</button>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
<Section label="Discussion">
|
||||
<ContextualSocratesThread
|
||||
projectId={projectId}
|
||||
findingId={f.id}
|
||||
findingText={f.text}
|
||||
onResolved={() => {
|
||||
void refresh();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Definition section for TermColumn. Read state by default; click anywhere
|
||||
* on the definition (or the empty placeholder) to enter edit mode. Save
|
||||
* with Cmd/Ctrl-Enter or by clicking Save; cancel with Esc. When the user
|
||||
* has authored the definition (definitionPinned), shows a "pinned" chip
|
||||
* in the header and a "Reset to AI suggestion" link in edit mode that
|
||||
* clears the text + the pin.
|
||||
*/
|
||||
function DefinitionSection({ term }: { term: ClientTerm }) {
|
||||
const { setTermDefinition } = useAnalysis();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(term.definition ?? "");
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// When the user opens the editor, sync the draft with the latest text and
|
||||
// focus the textarea. Selecting nothing keeps the cursor at the end.
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
setDraft(term.definition ?? "");
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
});
|
||||
// term.id intentionally excluded from deps — re-running on every term
|
||||
// change would clobber an in-flight edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editing]);
|
||||
|
||||
const commit = () => {
|
||||
setEditing(false);
|
||||
const next = draft.trim();
|
||||
const cur = (term.definition ?? "").trim();
|
||||
if (next === cur) return;
|
||||
void setTermDefinition(term.id, next.length > 0 ? next : null);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setEditing(false);
|
||||
setDraft(term.definition ?? "");
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setEditing(false);
|
||||
setDraft("");
|
||||
void setTermDefinition(term.id, null);
|
||||
};
|
||||
|
||||
const headerRight = term.definitionPinned ? (
|
||||
<StatusChip variant="muted" label="pinned" title="You authored this definition. Future Analyze runs will leave it alone." />
|
||||
) : null;
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Section label="Definition" right={headerRight}>
|
||||
<div className="termdetail-def-edit">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="termdetail-def-textarea"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
placeholder="A short noun-phrase definition…"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="termdetail-def-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-def-btn termdetail-def-btn-primary"
|
||||
onMouseDown={e => {
|
||||
// Don't lose focus before commit — onBlur would fire and
|
||||
// commit a possibly-stale value race.
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-def-btn"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{term.definitionPinned ? (
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-def-link"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
}}
|
||||
title="Clear the definition and let the next Analyze pass refill it from prose"
|
||||
>
|
||||
Reset to AI suggestion
|
||||
</button>
|
||||
) : null}
|
||||
<span className="termdetail-def-hint">⌘↵ to save · Esc to cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section label="Definition" right={headerRight}>
|
||||
{term.definition ? (
|
||||
<p
|
||||
className="termdetail-def termdetail-def-clickable"
|
||||
onClick={() => setEditing(true)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{term.definition}
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-empty termdetail-def-clickable termdetail-def-empty-btn"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Click to write a definition
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children, right }: { label: string; children: ReactNode; right?: ReactNode }) {
|
||||
return (
|
||||
<section className="termdetail-section">
|
||||
<header className="termdetail-section-head">
|
||||
<h3 className="termdetail-section-h">{label}</h3>
|
||||
{right ? <div className="termdetail-section-right">{right}</div> : null}
|
||||
</header>
|
||||
<div className="termdetail-section-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="termdetail-row">
|
||||
<span className="termdetail-row-label">{label}</span>
|
||||
<span className="termdetail-row-body">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewBadge({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status?: ReviewStatus;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
if (status === "suggested" || status === "deprecated") {
|
||||
return (
|
||||
<div className="column-review-row">
|
||||
<StatusChip variant={status} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function DecideButtons({
|
||||
status,
|
||||
onKeep,
|
||||
onDiscard,
|
||||
}: {
|
||||
kind: ReviewableElementKind;
|
||||
id: string;
|
||||
status?: ReviewStatus;
|
||||
onKeep: () => void;
|
||||
onDiscard: () => void;
|
||||
}) {
|
||||
if (status !== "suggested" && status !== "deprecated") return null;
|
||||
return (
|
||||
<span className="term-decision-row">
|
||||
<button type="button" className="term-decide term-decide-keep" onClick={onKeep}>
|
||||
Keep
|
||||
</button>
|
||||
<button type="button" className="term-decide term-decide-discard" onClick={onDiscard}>
|
||||
Discard
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function buildParentChain(
|
||||
term: { id: string; parentId: string | null },
|
||||
terms: Array<{ id: string; parentId: string | null; label: string }>
|
||||
): Array<{ id: string; label: string }> {
|
||||
const byId = new Map(terms.map(t => [t.id, t]));
|
||||
const chain: Array<{ id: string; label: string }> = [];
|
||||
let cur = term.parentId ? byId.get(term.parentId) : null;
|
||||
const seen = new Set<string>();
|
||||
while (cur && !seen.has(cur.id)) {
|
||||
seen.add(cur.id);
|
||||
chain.unshift({ id: cur.id, label: cur.label });
|
||||
cur = cur.parentId ? byId.get(cur.parentId) : null;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function labelOf(model: SysMLModel, id: string): string {
|
||||
return model.blocks.find(b => b.id === id)?.label ?? id;
|
||||
}
|
||||
|
||||
function titleCase(s: string): string {
|
||||
return s.length ? s[0].toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
320
apps/web/components/editor/sections/ConceptsPane.tsx
Normal file
320
apps/web/components/editor/sections/ConceptsPane.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
// Concepts pane — unified view of taxonomy + glossary. The two layers share a
|
||||
// single TaxonomyTerm table, so what looked like two panes was always one
|
||||
// dataset rendered two ways. This pane gives the user a Tree/Alphabetical
|
||||
// toggle and a single Analyze action that refreshes both layers.
|
||||
//
|
||||
// Re-running Analyze does NOT replace; it produces a *review*. Each row
|
||||
// carries one of three statuses:
|
||||
// accepted — confirmed, surfaces normally
|
||||
// suggested — analyzer added in last run, awaits keep/discard
|
||||
// deprecated — analyzer didn't see in last run, awaits keep/discard
|
||||
// Pending statuses show a NEW / DEPRECATED badge plus inline Keep / Discard
|
||||
// buttons. A Pending filter narrows the list to just the changes awaiting
|
||||
// review when there are many.
|
||||
//
|
||||
// Each row is also draggable onto the Model canvas (MIME
|
||||
// `application/x-socrata-term`), and clicking a row opens TermDetail.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import { PaneViewTabs } from "../PaneControls";
|
||||
import { PaneEmpty } from "../PaneEmpty";
|
||||
import { PaneDrawer } from "../PaneDrawer";
|
||||
import { StatusChip } from "../StatusChip";
|
||||
import { useAnalysis, type ClientTerm } from "../../../lib/workspace/analysisStore";
|
||||
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
|
||||
|
||||
type ViewMode = "tree" | "alpha";
|
||||
|
||||
interface ConceptsPaneProps {
|
||||
/** Position of this pane in the column stack (0 = root). */
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConceptsPane({ index, onClose }: ConceptsPaneProps) {
|
||||
const { terms, analyze, inFlight } = useAnalysis();
|
||||
const [mode, setMode] = useState<ViewMode>("tree");
|
||||
const running = inFlight.has("concepts") || inFlight.has("all");
|
||||
|
||||
const kept = useMemo(() => terms.filter(t => t.status === "accepted"), [terms]);
|
||||
const pending = useMemo(() => terms.filter(t => t.status !== "accepted"), [terms]);
|
||||
const definedCount = kept.filter(t => t.definition && t.definition.length > 0).length;
|
||||
|
||||
const subtitle =
|
||||
pending.length > 0
|
||||
? `${kept.length} kept · ${definedCount} defined · ${pending.length} pending`
|
||||
: `${kept.length} terms · ${definedCount} defined`;
|
||||
|
||||
const right = (
|
||||
<div className="pane-controls">
|
||||
<PaneViewTabs<ViewMode>
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
ariaLabel="Concepts view mode"
|
||||
tabs={[
|
||||
{ value: "tree", label: "Tree", title: "Hierarchical view" },
|
||||
{ value: "alpha", label: "A–Z", title: "Alphabetical view with definitions" },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="pane-action"
|
||||
onClick={() => void analyze("concepts")}
|
||||
disabled={running}
|
||||
>
|
||||
{running ? "Analyzing…" : "Analyze"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneFrame title="Concepts" subtitle={subtitle} right={right} onClose={onClose}>
|
||||
<div className="pane-drawer-stack">
|
||||
<div className="pane-drawer-main">
|
||||
{kept.length === 0 && pending.length === 0 ? (
|
||||
<PaneEmpty
|
||||
title="No concepts yet"
|
||||
hint={
|
||||
<>
|
||||
Concepts are extracted from your prose. Write your idea in the editor on the right,
|
||||
then run <strong>Analyze</strong>.
|
||||
</>
|
||||
}
|
||||
action={{
|
||||
label: running ? "Analyzing…" : "Run Analyze →",
|
||||
onClick: () => void analyze("concepts"),
|
||||
disabled: running,
|
||||
}}
|
||||
/>
|
||||
) : kept.length === 0 ? (
|
||||
<PaneEmpty
|
||||
title="Nothing kept yet"
|
||||
hint="Review the pending suggestions below to start building your concept list."
|
||||
/>
|
||||
) : mode === "tree" ? (
|
||||
<TreeView terms={kept} parentIndex={index} />
|
||||
) : (
|
||||
<AlphaView terms={kept} parentIndex={index} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<PaneDrawer
|
||||
title="Pending review"
|
||||
count={pending.length}
|
||||
tone="pending"
|
||||
defaultOpen
|
||||
>
|
||||
<AlphaView terms={pending} parentIndex={index} />
|
||||
</PaneDrawer>
|
||||
) : null}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
term: ClientTerm;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
function buildTree(terms: ClientTerm[]): TreeNode[] {
|
||||
const byId = new Map<string, TreeNode>(terms.map(t => [t.id, { term: t, children: [] }]));
|
||||
const roots: TreeNode[] = [];
|
||||
for (const t of terms) {
|
||||
const node = byId.get(t.id)!;
|
||||
if (t.parentId && byId.has(t.parentId)) {
|
||||
byId.get(t.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function TreeView({ terms, parentIndex }: { terms: ClientTerm[]; parentIndex: number }) {
|
||||
const tree = useMemo(() => buildTree(terms), [terms]);
|
||||
return (
|
||||
<ul className="term-list">
|
||||
{tree.map(node => (
|
||||
<TermNode key={node.term.id} node={node} depth={0} parentIndex={parentIndex} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function AlphaView({ terms, parentIndex }: { terms: ClientTerm[]; parentIndex: number }) {
|
||||
const sorted = useMemo(() => [...terms].sort((a, b) => a.label.localeCompare(b.label)), [terms]);
|
||||
const { pushFrom, columns } = useOpenPanes();
|
||||
const activeId =
|
||||
columns[parentIndex + 1]?.kind === "term" ? columns[parentIndex + 1]!.id : null;
|
||||
return (
|
||||
<ul className="term-list">
|
||||
{sorted.map(t => (
|
||||
<li key={t.id}>
|
||||
<ConceptCard
|
||||
term={t}
|
||||
active={activeId === t.id}
|
||||
onOpen={() => pushFrom(parentIndex, { kind: "term", id: t.id })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** The shared visual unit for both Tree and A–Z views in Concepts. The
|
||||
* leading column is ALWAYS the chevron slot — Tree passes a real toggle
|
||||
* button, A–Z passes nothing and we render an empty placeholder of the
|
||||
* same width. That keeps the cards visually identical at every depth and
|
||||
* across views; only the chevron's behavior differs. */
|
||||
function ConceptCard({
|
||||
term,
|
||||
active,
|
||||
onOpen,
|
||||
chevron,
|
||||
}: {
|
||||
term: ClientTerm;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
chevron?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`term-tile term-status-${term.status} ${active ? "row-active" : ""}`}
|
||||
draggable
|
||||
onDragStart={e => dragTerm(e, term)}
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{chevron}
|
||||
<div className="term-tile-content">
|
||||
<div className="term-tile-head">
|
||||
<span className="term-label">{term.label}</span>
|
||||
<StatusBadge term={term} />
|
||||
</div>
|
||||
{term.definition ? (
|
||||
<div className="term-def">{term.definition}</div>
|
||||
) : (
|
||||
<div className="term-def term-def-empty">(no definition yet)</div>
|
||||
)}
|
||||
<DecisionRow term={term} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TermNode({
|
||||
node,
|
||||
depth,
|
||||
parentIndex,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
parentIndex: number;
|
||||
}) {
|
||||
const { pushFrom, columns } = useOpenPanes();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const t = node.term;
|
||||
const hasChildren = node.children.length > 0;
|
||||
const activeId =
|
||||
columns[parentIndex + 1]?.kind === "term" ? columns[parentIndex + 1]!.id : null;
|
||||
const open = activeId === t.id;
|
||||
|
||||
// Only parents get a chevron; leaves render flush-left (matches A–Z view).
|
||||
const chevron = hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="term-tile-chevron"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setExpanded(v => !v);
|
||||
}}
|
||||
aria-label={expanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{expanded ? "▾" : "▸"}
|
||||
</button>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<li className="term-tree-node" style={{ "--tree-depth": depth } as React.CSSProperties}>
|
||||
<ConceptCard
|
||||
term={t}
|
||||
active={open}
|
||||
onOpen={() => pushFrom(parentIndex, { kind: "term", id: t.id })}
|
||||
chevron={chevron}
|
||||
/>
|
||||
{hasChildren && expanded ? (
|
||||
<ul className="term-list term-list-children">
|
||||
{node.children.map(c => (
|
||||
<TermNode key={c.term.id} node={c} depth={depth + 1} parentIndex={parentIndex} />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ term }: { term: ClientTerm }) {
|
||||
if (term.status === "suggested") {
|
||||
return <StatusChip variant="suggested" title="Added by latest analyze" />;
|
||||
}
|
||||
if (term.status === "deprecated") {
|
||||
return <StatusChip variant="deprecated" title="Analyzer didn't see this in the latest run" />;
|
||||
}
|
||||
if (term.linkedBlockId) {
|
||||
return <span className="term-linked-dot" title="Has a linked block">●</span>;
|
||||
}
|
||||
return <span className="term-drag-hint">drag to canvas</span>;
|
||||
}
|
||||
|
||||
function DecisionRow({ term }: { term: ClientTerm }) {
|
||||
const { decideTerm } = useAnalysis();
|
||||
if (term.status === "accepted") return null;
|
||||
|
||||
const isSuggested = term.status === "suggested";
|
||||
return (
|
||||
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideTerm(term.id, "keep");
|
||||
}}
|
||||
title={isSuggested ? "Accept this suggestion" : "Pin this term despite the analyzer dropping it"}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideTerm(term.id, "discard");
|
||||
}}
|
||||
title={isSuggested ? "Reject this suggestion" : "Remove this term"}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function dragTerm(e: React.DragEvent, t: ClientTerm) {
|
||||
e.dataTransfer.setData(
|
||||
"application/x-socrata-term",
|
||||
JSON.stringify({ termId: t.id, label: t.label, definition: t.definition })
|
||||
);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
231
apps/web/components/editor/sections/FindingsPane.tsx
Normal file
231
apps/web/components/editor/sections/FindingsPane.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
// Findings pane — three groups of rows for one kind (assumption / risk /
|
||||
// inconsistency):
|
||||
//
|
||||
// 1. Kept — status="accepted". Top of the pane, scrollable.
|
||||
// 2. Pending — status in {suggested, deprecated}. Drawer below kept.
|
||||
// 3. Discarded — status in {dismissed, resolved}. Collapsed drawer at the
|
||||
// bottom, hidden when empty.
|
||||
//
|
||||
// Clicking a row pushes a FindingColumn to the right (full detail + Socrates
|
||||
// thread + decision actions). Decisions also work inline from the row.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import { PaneEmpty } from "../PaneEmpty";
|
||||
import { PaneDrawer } from "../PaneDrawer";
|
||||
import { StatusChip } from "../StatusChip";
|
||||
import { useAnalysis, type ClientFinding } from "../../../lib/workspace/analysisStore";
|
||||
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
|
||||
|
||||
interface FindingsPaneProps {
|
||||
kind: "assumption" | "risk" | "inconsistency";
|
||||
index: number;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TITLES: Record<FindingsPaneProps["kind"], string> = {
|
||||
assumption: "Assumptions",
|
||||
risk: "Risks",
|
||||
inconsistency: "Inconsistencies",
|
||||
};
|
||||
|
||||
const SECTION_TO_ANALYZE = {
|
||||
assumption: "assumptions",
|
||||
risk: "risks",
|
||||
inconsistency: "inconsistencies",
|
||||
} as const;
|
||||
|
||||
const PENDING_STATUSES = new Set(["suggested", "deprecated"]);
|
||||
const DISCARDED_STATUSES = new Set(["dismissed", "resolved"]);
|
||||
|
||||
export function FindingsPane({ kind, index, onClose }: FindingsPaneProps) {
|
||||
const { findings, analyze, inFlight } = useAnalysis();
|
||||
const { pushFrom, columns } = useOpenPanes();
|
||||
|
||||
const items = useMemo(() => findings.filter(f => f.kind === kind), [findings, kind]);
|
||||
const kept = useMemo(() => items.filter(f => f.status === "accepted"), [items]);
|
||||
const pending = useMemo(() => items.filter(f => PENDING_STATUSES.has(f.status)), [items]);
|
||||
const discarded = useMemo(() => items.filter(f => DISCARDED_STATUSES.has(f.status)), [items]);
|
||||
|
||||
const section = SECTION_TO_ANALYZE[kind];
|
||||
const running = inFlight.has(section) || inFlight.has("all");
|
||||
|
||||
const activeId =
|
||||
columns[index + 1]?.kind === "finding" ? columns[index + 1]!.id : null;
|
||||
|
||||
const subtitle =
|
||||
pending.length > 0
|
||||
? `${kept.length} kept · ${pending.length} pending`
|
||||
: `${kept.length} kept`;
|
||||
|
||||
const right = (
|
||||
<div className="pane-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="pane-action"
|
||||
onClick={() => void analyze(section)}
|
||||
disabled={running}
|
||||
>
|
||||
{running ? "Analyzing…" : "Analyze"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRow = (f: ClientFinding) => (
|
||||
<FindingRow
|
||||
key={f.id}
|
||||
finding={f}
|
||||
active={activeId === f.id}
|
||||
onOpen={() => pushFrom(index, { kind: "finding", id: f.id })}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneFrame title={TITLES[kind]} subtitle={subtitle} right={right} onClose={onClose}>
|
||||
<div className="pane-drawer-stack">
|
||||
<div className="pane-drawer-main">
|
||||
{items.length === 0 ? (
|
||||
<PaneEmpty
|
||||
title={`No ${TITLES[kind].toLowerCase()} yet`}
|
||||
hint={`${TITLES[kind]} are detected against the current model. Run Analyze to scan it for issues.`}
|
||||
action={{
|
||||
label: running ? "Analyzing…" : "Run Analyze →",
|
||||
onClick: () => void analyze(section),
|
||||
disabled: running,
|
||||
}}
|
||||
/>
|
||||
) : kept.length === 0 ? (
|
||||
<PaneEmpty
|
||||
title="Nothing kept yet"
|
||||
hint="Review the pending findings below to start tracking the ones that matter."
|
||||
/>
|
||||
) : (
|
||||
<ul className="finding-list">{kept.map(renderRow)}</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<PaneDrawer title="Pending review" count={pending.length} tone="pending" defaultOpen>
|
||||
<ul className="finding-list">{pending.map(renderRow)}</ul>
|
||||
</PaneDrawer>
|
||||
) : null}
|
||||
|
||||
<PaneDrawer
|
||||
title="Discarded"
|
||||
count={discarded.length}
|
||||
tone="muted"
|
||||
hideWhenEmpty
|
||||
>
|
||||
<ul className="finding-list">{discarded.map(renderRow)}</ul>
|
||||
</PaneDrawer>
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingRow({
|
||||
finding,
|
||||
active,
|
||||
onOpen,
|
||||
}: {
|
||||
finding: ClientFinding;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { decideFinding } = useAnalysis();
|
||||
const isPending = PENDING_STATUSES.has(finding.status);
|
||||
const isDiscarded = DISCARDED_STATUSES.has(finding.status);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`finding-row finding-status-${finding.status} ${active ? "row-active" : ""}`}
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="finding-row-head finding-row-head-static">
|
||||
<span className="finding-text">{finding.text}</span>
|
||||
<span className="finding-meta">
|
||||
{finding.status === "suggested" ? <StatusChip variant="suggested" /> : null}
|
||||
{finding.status === "deprecated" ? <StatusChip variant="deprecated" /> : null}
|
||||
{finding.status === "dismissed" ? <StatusChip variant="dismissed" /> : null}
|
||||
{finding.status === "resolved" ? <StatusChip variant="resolved" /> : null}
|
||||
{finding.validationCode ? (
|
||||
<StatusChip variant="code" value={finding.validationCode} title="Validation rule" />
|
||||
) : null}
|
||||
{finding.severity ? (
|
||||
<StatusChip
|
||||
variant="severity"
|
||||
value={finding.severity as "low" | "medium" | "high"}
|
||||
title="Severity"
|
||||
/>
|
||||
) : null}
|
||||
<StatusChip
|
||||
variant="confidence"
|
||||
value={finding.confidence}
|
||||
title={`${Math.round(finding.confidence * 100)}% confidence`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideFinding(finding.id, "keep");
|
||||
}}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideFinding(finding.id, "discard");
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
{finding.status === "suggested" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-resolve"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideFinding(finding.id, "resolve");
|
||||
}}
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : isDiscarded ? (
|
||||
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideFinding(finding.id, "restore");
|
||||
}}
|
||||
title="Move back to Kept"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
340
apps/web/components/editor/sections/ModelPane.tsx
Normal file
340
apps/web/components/editor/sections/ModelPane.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
// Model pane — fully editable React Flow diagram + a Summary subtab listing
|
||||
// blocks/associations/constraints/requirements with a click-to-jump UX +
|
||||
// review controls (Keep / Discard) on analyzer-suggested or analyzer-deprecated
|
||||
// elements.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import { PaneViewTabs, PaneFilterChip } from "../PaneControls";
|
||||
import { StatusChip } from "../StatusChip";
|
||||
import { DiagramCanvas } from "../../diagram-canvas/DiagramCanvas";
|
||||
import { useModelStore } from "../../../lib/sync/ModelStore";
|
||||
import { useAnalysis } from "../../../lib/workspace/analysisStore";
|
||||
import { useEditorPaneContext } from "./paneContext";
|
||||
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
|
||||
import {
|
||||
decideElement,
|
||||
removeBlock,
|
||||
removeAssociation,
|
||||
removeConstraint,
|
||||
removeRequirement,
|
||||
type ReviewableElementKind,
|
||||
} from "../../../lib/sync/ops";
|
||||
import type { ReviewStatus, SysMLModel } from "../../../lib/sysml/model";
|
||||
|
||||
interface ModelPaneProps {
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "diagram" | "summary";
|
||||
|
||||
export function ModelPane({ index, onClose }: ModelPaneProps) {
|
||||
const [tab, setTab] = useState<Tab>("diagram");
|
||||
const [pendingOnly, setPendingOnly] = useState(false);
|
||||
const { model, issuesByElement } = useModelStore();
|
||||
const { analyze, inFlight } = useAnalysis();
|
||||
const { focusBlockId, setFocusBlockId, projectId } = useEditorPaneContext();
|
||||
const running = inFlight.has("model") || inFlight.has("all");
|
||||
|
||||
const pendingCount = countPending(model);
|
||||
|
||||
const right = (
|
||||
<div className="pane-controls">
|
||||
<PaneViewTabs<Tab>
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
ariaLabel="Model view mode"
|
||||
tabs={[
|
||||
{ value: "diagram", label: "Diagram" },
|
||||
{ value: "summary", label: "Summary" },
|
||||
]}
|
||||
/>
|
||||
<PaneFilterChip
|
||||
active={pendingOnly}
|
||||
onToggle={() => {
|
||||
// Pending now overlays the current view mode rather than replacing
|
||||
// it. If we're on Diagram and the user wants to triage pending
|
||||
// items, switching to Summary makes far more sense than rendering
|
||||
// a filtered diagram, so we still nudge to Summary on toggle-on.
|
||||
setPendingOnly(p => {
|
||||
if (!p && tab === "diagram") setTab("summary");
|
||||
return !p;
|
||||
});
|
||||
}}
|
||||
label="Pending"
|
||||
count={pendingCount}
|
||||
title="Show only suggestions and deprecated model elements"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="pane-action"
|
||||
onClick={() => void analyze("model")}
|
||||
disabled={running}
|
||||
title="Re-derive model from prose"
|
||||
>
|
||||
{running ? "Analyzing…" : "Analyze"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const subtitle =
|
||||
pendingCount > 0
|
||||
? `${model.blocks.length} blocks · ${model.associations.length} assocs · ${model.constraints.length} constraints · ${pendingCount} pending`
|
||||
: `${model.blocks.length} blocks · ${model.associations.length} assocs · ${model.constraints.length} constraints`;
|
||||
|
||||
return (
|
||||
<PaneFrame title="Model" subtitle={subtitle} right={right} onClose={onClose}>
|
||||
{tab === "diagram" && !pendingOnly ? (
|
||||
<div className="canvas-scroll canvas-scroll-diagram">
|
||||
<DiagramCanvas
|
||||
focusBlockId={focusBlockId}
|
||||
onSelect={setFocusBlockId}
|
||||
issuesByElement={issuesByElement}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ModelSummary pendingOnly={pendingOnly} parentIndex={index} />
|
||||
)}
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function countPending(model: SysMLModel): number {
|
||||
const isPending = (rs?: ReviewStatus) => rs === "suggested" || rs === "deprecated";
|
||||
return (
|
||||
model.blocks.filter(b => isPending(b.reviewStatus)).length +
|
||||
model.associations.filter(a => isPending(a.reviewStatus)).length +
|
||||
model.constraints.filter(c => isPending(c.reviewStatus)).length +
|
||||
model.requirements.filter(r => isPending(r.reviewStatus)).length
|
||||
);
|
||||
}
|
||||
|
||||
function ModelSummary({ pendingOnly, parentIndex }: { pendingOnly: boolean; parentIndex: number }) {
|
||||
const { model, apply } = useModelStore();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
|
||||
const filterPending = <T extends { reviewStatus?: ReviewStatus }>(xs: T[]): T[] =>
|
||||
pendingOnly ? xs.filter(x => x.reviewStatus === "suggested" || x.reviewStatus === "deprecated") : xs;
|
||||
|
||||
const blocks = filterPending(model.blocks);
|
||||
const associations = filterPending(model.associations);
|
||||
const constraints = filterPending(model.constraints);
|
||||
const requirements = filterPending(model.requirements);
|
||||
|
||||
const totalShown = blocks.length + associations.length + constraints.length + requirements.length;
|
||||
|
||||
if (totalShown === 0) {
|
||||
return (
|
||||
<div className="pane-empty">
|
||||
{pendingOnly ? (
|
||||
<>No pending changes. Everything is up to date.</>
|
||||
) : (
|
||||
<>
|
||||
No model yet. Click <strong>Analyze</strong> to derive one from your prose, or open the
|
||||
diagram and start dragging.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const decideKeep = (kind: ReviewableElementKind, id: string) =>
|
||||
apply([decideElement({ kind, id })]);
|
||||
const decideDiscard = (kind: ReviewableElementKind, id: string) => {
|
||||
if (kind === "block") apply([removeBlock(id)]);
|
||||
else if (kind === "association") apply([removeAssociation(id)]);
|
||||
else if (kind === "constraint") apply([removeConstraint(id)]);
|
||||
else if (kind === "requirement") apply([removeRequirement(id)]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="model-summary">
|
||||
{blocks.length > 0 && (
|
||||
<>
|
||||
<h3 className="model-summary-h">Blocks ({blocks.length})</h3>
|
||||
<ul className="model-summary-list">
|
||||
{blocks.map(b => (
|
||||
<li key={b.id}>
|
||||
<ReviewRow status={b.reviewStatus}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-summary-row"
|
||||
onClick={() => pushFrom(parentIndex, { kind: "block", id: b.id })}
|
||||
>
|
||||
<span className="model-summary-kind">{b.kind}</span>
|
||||
<span className="model-summary-label">{b.label}</span>
|
||||
</button>
|
||||
{b.linkedTermId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="model-summary-link"
|
||||
title="Open concept detail"
|
||||
onClick={() =>
|
||||
pushFrom(parentIndex, { kind: "term", id: b.linkedTermId! })
|
||||
}
|
||||
>
|
||||
↪ concept
|
||||
</button>
|
||||
) : null}
|
||||
<DecideButtons
|
||||
status={b.reviewStatus}
|
||||
onKeep={() => decideKeep("block", b.id)}
|
||||
onDiscard={() => decideDiscard("block", b.id)}
|
||||
/>
|
||||
</ReviewRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{associations.length > 0 && (
|
||||
<>
|
||||
<h3 className="model-summary-h">Associations ({associations.length})</h3>
|
||||
<ul className="model-summary-list">
|
||||
{associations.map(a => (
|
||||
<li key={a.id}>
|
||||
<ReviewRow status={a.reviewStatus}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-summary-row"
|
||||
onClick={() => pushFrom(parentIndex, { kind: "association", id: a.id })}
|
||||
>
|
||||
<span className="model-summary-kind">{a.kind}</span>
|
||||
<span className="model-summary-label">
|
||||
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
|
||||
{labelOf(model, a.toBlockId)}
|
||||
</span>
|
||||
</button>
|
||||
<DecideButtons
|
||||
status={a.reviewStatus}
|
||||
onKeep={() => decideKeep("association", a.id)}
|
||||
onDiscard={() => decideDiscard("association", a.id)}
|
||||
/>
|
||||
</ReviewRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{constraints.length > 0 && (
|
||||
<>
|
||||
<h3 className="model-summary-h">Constraints ({constraints.length})</h3>
|
||||
<ul className="model-summary-list">
|
||||
{constraints.map(c => (
|
||||
<li key={c.id}>
|
||||
<ReviewRow status={c.reviewStatus}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-summary-row"
|
||||
onClick={() => pushFrom(parentIndex, { kind: "constraint", id: c.id })}
|
||||
>
|
||||
<span className="model-summary-kind">constraint</span>
|
||||
<span className="model-summary-label">
|
||||
{c.label}
|
||||
{c.expression ? <> — <code>{c.expression}</code></> : null}
|
||||
</span>
|
||||
</button>
|
||||
<DecideButtons
|
||||
status={c.reviewStatus}
|
||||
onKeep={() => decideKeep("constraint", c.id)}
|
||||
onDiscard={() => decideDiscard("constraint", c.id)}
|
||||
/>
|
||||
</ReviewRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{requirements.length > 0 && (
|
||||
<>
|
||||
<h3 className="model-summary-h">Requirements ({requirements.length})</h3>
|
||||
<ul className="model-summary-list">
|
||||
{requirements.map(r => (
|
||||
<li key={r.id}>
|
||||
<ReviewRow status={r.reviewStatus}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-summary-row"
|
||||
onClick={() => pushFrom(parentIndex, { kind: "requirement", id: r.id })}
|
||||
>
|
||||
<span className="model-summary-kind">{r.tag}</span>
|
||||
<span className="model-summary-label">{r.text}</span>
|
||||
</button>
|
||||
<DecideButtons
|
||||
status={r.reviewStatus}
|
||||
onKeep={() => decideKeep("requirement", r.id)}
|
||||
onDiscard={() => decideDiscard("requirement", r.id)}
|
||||
/>
|
||||
</ReviewRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status?: ReviewStatus;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`model-summary-row-wrap model-status-${status ?? "accepted"}`}>
|
||||
{status === "suggested" ? <StatusChip variant="suggested" /> : null}
|
||||
{status === "deprecated" ? <StatusChip variant="deprecated" /> : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecideButtons({
|
||||
status,
|
||||
onKeep,
|
||||
onDiscard,
|
||||
}: {
|
||||
status?: ReviewStatus;
|
||||
onKeep: () => void;
|
||||
onDiscard: () => void;
|
||||
}) {
|
||||
if (status !== "suggested" && status !== "deprecated") return null;
|
||||
return (
|
||||
<span className="model-decide-row">
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onKeep();
|
||||
}}
|
||||
title={
|
||||
status === "suggested" ? "Accept this analyzer suggestion" : "Pin this element despite the analyzer dropping it"
|
||||
}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDiscard();
|
||||
}}
|
||||
title={status === "suggested" ? "Reject this suggestion" : "Remove this element"}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function labelOf(model: SysMLModel, id: string): string {
|
||||
return model.blocks.find(b => b.id === id)?.label ?? id;
|
||||
}
|
||||
212
apps/web/components/editor/sections/RequirementsPane.tsx
Normal file
212
apps/web/components/editor/sections/RequirementsPane.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
// Requirements pane — list of extracted requirements with traceability +
|
||||
// unsupported flags. Click a requirement's traced block → focus that block
|
||||
// in Model. Re-running Analyze MERGES; new and deprecated requirements
|
||||
// surface for the user to keep or discard.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import { PaneEmpty } from "../PaneEmpty";
|
||||
import { PaneDrawer } from "../PaneDrawer";
|
||||
import { StatusChip } from "../StatusChip";
|
||||
import { useAnalysis, type ClientRequirement } from "../../../lib/workspace/analysisStore";
|
||||
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
|
||||
import { useModelStore } from "../../../lib/sync/ModelStore";
|
||||
|
||||
interface RequirementsPaneProps {
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RequirementsPane({ index, onClose }: RequirementsPaneProps) {
|
||||
const { requirements, analyze, inFlight } = useAnalysis();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
const { model } = useModelStore();
|
||||
const running = inFlight.has("requirements") || inFlight.has("all");
|
||||
|
||||
const kept = useMemo(
|
||||
() => requirements.filter(r => r.status === "accepted"),
|
||||
[requirements]
|
||||
);
|
||||
const pending = useMemo(
|
||||
() => requirements.filter(r => r.status !== "accepted"),
|
||||
[requirements]
|
||||
);
|
||||
const unsupportedCount = kept.filter(r => r.unsupported).length;
|
||||
|
||||
const labelOf = (id: string) => model.blocks.find(b => b.id === id)?.label ?? id;
|
||||
|
||||
const subtitle =
|
||||
requirements.length === 0
|
||||
? "no requirements yet"
|
||||
: pending.length > 0
|
||||
? `${kept.length} kept · ${pending.length} pending`
|
||||
: unsupportedCount > 0
|
||||
? `${kept.length} total · ${unsupportedCount} unsupported`
|
||||
: `${kept.length} total · all traced`;
|
||||
|
||||
const right = (
|
||||
<div className="pane-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="pane-action"
|
||||
onClick={() => void analyze("requirements")}
|
||||
disabled={running}
|
||||
>
|
||||
{running ? "Analyzing…" : "Analyze"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRow = (r: ClientRequirement) => (
|
||||
<RequirementItem
|
||||
key={r.id}
|
||||
req={r}
|
||||
labelOf={labelOf}
|
||||
onOpenSelf={() => pushFrom(index, { kind: "requirement", id: r.id })}
|
||||
onJumpToBlock={id => pushFrom(index, { kind: "block", id })}
|
||||
onJumpToTerm={id => pushFrom(index, { kind: "term", id })}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneFrame title="Requirements" subtitle={subtitle} right={right} onClose={onClose}>
|
||||
<div className="pane-drawer-stack">
|
||||
<div className="pane-drawer-main">
|
||||
{kept.length === 0 && pending.length === 0 ? (
|
||||
<PaneEmpty
|
||||
title="No requirements yet"
|
||||
hint={
|
||||
<>
|
||||
Requirements are extracted from sentences in your prose that say the system
|
||||
<em> must / should / needs to</em>. Run <strong>Analyze</strong> to scan.
|
||||
</>
|
||||
}
|
||||
action={{
|
||||
label: running ? "Analyzing…" : "Run Analyze →",
|
||||
onClick: () => void analyze("requirements"),
|
||||
disabled: running,
|
||||
}}
|
||||
/>
|
||||
) : kept.length === 0 ? (
|
||||
<PaneEmpty
|
||||
title="Nothing kept yet"
|
||||
hint="Review the pending requirements below."
|
||||
/>
|
||||
) : (
|
||||
<ul className="req-list">{kept.map(renderRow)}</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<PaneDrawer
|
||||
title="Pending review"
|
||||
count={pending.length}
|
||||
tone="pending"
|
||||
defaultOpen
|
||||
>
|
||||
<ul className="req-list">{pending.map(renderRow)}</ul>
|
||||
</PaneDrawer>
|
||||
) : null}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
interface RequirementItemProps {
|
||||
req: ClientRequirement;
|
||||
labelOf: (id: string) => string;
|
||||
onOpenSelf: () => void;
|
||||
onJumpToBlock: (id: string) => void;
|
||||
onJumpToTerm: (id: string) => void;
|
||||
}
|
||||
|
||||
function RequirementItem({ req, labelOf, onOpenSelf, onJumpToBlock, onJumpToTerm }: RequirementItemProps) {
|
||||
const { decideRequirement } = useAnalysis();
|
||||
return (
|
||||
<li
|
||||
className={`req-row req-status-${req.status} ${req.unsupported ? "req-row-unsupported" : ""}`}
|
||||
onClick={onOpenSelf}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpenSelf();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="req-row-head">
|
||||
<span className="req-tag">{req.tag}</span>
|
||||
<span className="req-row-meta">
|
||||
<ReqStatusBadge status={req.status} />
|
||||
{req.unsupported ? <StatusChip variant="warn" label="unsupported" /> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="req-text">{req.text}</div>
|
||||
{req.linkedTermId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="req-term-link"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onJumpToTerm(req.linkedTermId!);
|
||||
}}
|
||||
title="Open concept detail"
|
||||
>
|
||||
↪ concept
|
||||
</button>
|
||||
) : null}
|
||||
{req.tracedToIds.length > 0 ? (
|
||||
<div className="req-traced" onClick={e => e.stopPropagation()}>
|
||||
Traced to:{" "}
|
||||
{req.tracedToIds.map((id, i) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className="req-traced-link"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onJumpToBlock(id);
|
||||
}}
|
||||
>
|
||||
{labelOf(id)}
|
||||
{i < req.tracedToIds.length - 1 ? ", " : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{req.status !== "accepted" ? (
|
||||
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideRequirement(req.id, "keep");
|
||||
}}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideRequirement(req.id, "discard");
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ReqStatusBadge({ status }: { status: ClientRequirement["status"] }) {
|
||||
if (status === "suggested") return <StatusChip variant="suggested" />;
|
||||
if (status === "deprecated") return <StatusChip variant="deprecated" />;
|
||||
return null;
|
||||
}
|
||||
22
apps/web/components/editor/sections/TextCanvasPane.tsx
Normal file
22
apps/web/components/editor/sections/TextCanvasPane.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// Pinned text-editor pane. Always present in the workspace; not closable.
|
||||
|
||||
"use client";
|
||||
|
||||
import { TextCanvas } from "../../text-canvas/TextCanvas";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import type { FixtureData } from "../../../lib/fixtures/aristotle";
|
||||
|
||||
interface TextCanvasPaneProps {
|
||||
data: FixtureData;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function TextCanvasPane({ data, projectId }: TextCanvasPaneProps) {
|
||||
return (
|
||||
<PaneFrame title="Narrative" subtitle="Your document" closable={false}>
|
||||
<div className="canvas-scroll">
|
||||
<TextCanvas data={data} projectId={projectId} />
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
37
apps/web/components/editor/sections/paneContext.tsx
Normal file
37
apps/web/components/editor/sections/paneContext.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// Shared context for cross-pane focus state and projectId plumbing.
|
||||
//
|
||||
// `focusBlockId` drives diagram selection / chip hover styling. The richer
|
||||
// term/finding/etc detail navigation now lives in the column-stack
|
||||
// (openPanesStore), so this context is intentionally minimal.
|
||||
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
interface PaneContextValue {
|
||||
projectId: string;
|
||||
focusBlockId: string | null;
|
||||
setFocusBlockId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<PaneContextValue | null>(null);
|
||||
|
||||
interface ProviderProps {
|
||||
projectId: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function EditorPaneContextProvider({ projectId, children }: ProviderProps) {
|
||||
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
||||
const value = useMemo<PaneContextValue>(
|
||||
() => ({ projectId, focusBlockId, setFocusBlockId }),
|
||||
[projectId, focusBlockId]
|
||||
);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useEditorPaneContext(): PaneContextValue {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useEditorPaneContext must be inside <EditorPaneContextProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,74 +1,166 @@
|
||||
// Seed onboarding screen — two-column layout: emerging-seed rail (left) + Socrates conversation (right).
|
||||
// Ported from docs/design-source/socrata/project/seed-screen.jsx.
|
||||
// Live seed interview screen.
|
||||
//
|
||||
// Layout retained from the M1 port: emerging-seed rail (left) + Socrates
|
||||
// conversation (right). The rail now reflects the live `draft` extracted
|
||||
// from the conversation; the right side is a real chat with the LM Studio
|
||||
// (or Anthropic) gateway via /api/seed/turn. When Socrates flags ready (or
|
||||
// the user clicks "Generate") we POST /api/seed/finalize, which generates
|
||||
// the SysMLModel + creates the project, then we router-push to the editor.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sigil } from "../socrates/Sigil";
|
||||
|
||||
interface SeedField {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
inferred?: boolean;
|
||||
}
|
||||
|
||||
interface ThreadTurn {
|
||||
who: "socrates" | "user";
|
||||
interface InterviewTurn {
|
||||
role: "socrates" | "user";
|
||||
text: string;
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
const fields: SeedField[] = [
|
||||
{
|
||||
key: "problem",
|
||||
label: "Problem",
|
||||
value: "First-year STEM students disengage in the long tail between lectures and office hours; a self-efficacy gap forms quickly.",
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
label: "Target user",
|
||||
value: "Undergraduates at large public universities, weeks 3–10 of an intro course.",
|
||||
},
|
||||
{
|
||||
key: "outcome",
|
||||
label: "Desired outcome",
|
||||
value: "Students re-engage with material via a low-stakes thinking partner — without producing solutions.",
|
||||
},
|
||||
{
|
||||
key: "hypothesis",
|
||||
label: "Initial hypothesis",
|
||||
value: "Students will adopt a tool that explicitly refuses to solve their homework.",
|
||||
inferred: true,
|
||||
},
|
||||
{
|
||||
key: "constraint",
|
||||
label: "Constraint",
|
||||
value: "FERPA tenancy, P50 < 1.2s.",
|
||||
inferred: true,
|
||||
},
|
||||
];
|
||||
interface SeedDraft {
|
||||
title: string;
|
||||
problem: string;
|
||||
targetUser: string;
|
||||
desiredOutcome: string;
|
||||
initialHypothesis?: string;
|
||||
constraints?: string[];
|
||||
}
|
||||
|
||||
const thread: ThreadTurn[] = [
|
||||
{
|
||||
who: "socrates",
|
||||
text: "Welcome. I'm Socrates. Before we model anything, let me understand what you're really proposing. In one sentence — what is the smallest, most honest version of the problem?",
|
||||
},
|
||||
{
|
||||
who: "user",
|
||||
text: "Students disengage between lectures because they have nobody to think with at 11pm.",
|
||||
},
|
||||
{
|
||||
who: "socrates",
|
||||
text: "Good. Two follow-ups. First, who specifically — and why now? Second, when you say 'think with', do you mean a tutor that explains, or a partner that asks? These are quite different products.",
|
||||
},
|
||||
{
|
||||
who: "user",
|
||||
text: "Public-university undergrads, weeks 3–10. A partner that asks. The market is saturated with explainers.",
|
||||
},
|
||||
{
|
||||
who: "socrates",
|
||||
text: "Then the central tension is restraint: a tool that holds its tongue. Most LLM products are rewarded for being helpful. Yours will be rewarded for being patient. Should I draft this as a Constraint on the model — refusal_policy : single-valued — and surface it for your review?",
|
||||
},
|
||||
];
|
||||
const EMPTY_DRAFT: SeedDraft = {
|
||||
title: "",
|
||||
problem: "",
|
||||
targetUser: "",
|
||||
desiredOutcome: "",
|
||||
};
|
||||
|
||||
export function SeedScreen() {
|
||||
const router = useRouter();
|
||||
const [history, setHistory] = useState<InterviewTurn[]>([]);
|
||||
const [draft, setDraft] = useState<SeedDraft>(EMPTY_DRAFT);
|
||||
const [confidence, setConfidence] = useState(0);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [meta, setMeta] = useState<{ provider?: string; model?: string }>({});
|
||||
const threadEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const openedRef = useRef(false);
|
||||
|
||||
// On mount, get Socrates' opening question.
|
||||
useEffect(() => {
|
||||
if (openedRef.current) return;
|
||||
openedRef.current = true;
|
||||
void sendImpl("", true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
threadEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [history]);
|
||||
|
||||
const sendImpl = useCallback(
|
||||
async (text: string, isOpening = false) => {
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
const historyToSend: InterviewTurn[] = [...history];
|
||||
|
||||
const userTurn: InterviewTurn | null = isOpening ? null : { role: "user", text };
|
||||
const pendingTurn: InterviewTurn = {
|
||||
role: "socrates",
|
||||
text: "thinking…",
|
||||
pending: true,
|
||||
};
|
||||
|
||||
setHistory(curr => [...curr, ...(userTurn ? [userTurn] : []), pendingTurn]);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/seed/turn", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
history: historyToSend,
|
||||
userText: text,
|
||||
draft,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
assistant: { text: string };
|
||||
draft: SeedDraft;
|
||||
confidence: number;
|
||||
ready: boolean;
|
||||
meta?: { provider?: string; model?: string };
|
||||
};
|
||||
|
||||
setHistory(curr =>
|
||||
curr.map(t =>
|
||||
t === pendingTurn ? { role: "socrates", text: data.assistant.text } : t
|
||||
)
|
||||
);
|
||||
setDraft(data.draft);
|
||||
setConfidence(data.confidence);
|
||||
setReady(data.ready);
|
||||
if (data.meta) setMeta({ provider: data.meta.provider, model: data.meta.model });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
setHistory(curr =>
|
||||
curr.map(t =>
|
||||
t === pendingTurn ? { role: "socrates", text: `⚠ ${msg}`, pending: false } : t
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[history, draft]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
setInput("");
|
||||
await sendImpl(text);
|
||||
}, [input, sending, sendImpl]);
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
if (generating) return;
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/seed/finalize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ draft }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { projectId: string };
|
||||
router.push(`/editor/${data.projectId}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [draft, generating, router]);
|
||||
|
||||
const filledFieldsCount =
|
||||
[draft.problem, draft.targetUser, draft.desiredOutcome].filter(Boolean).length +
|
||||
(draft.initialHypothesis ? 1 : 0) +
|
||||
((draft.constraints?.length ?? 0) > 0 ? 1 : 0);
|
||||
const canGenerate = !!draft.problem && !!draft.targetUser && !!draft.desiredOutcome;
|
||||
const confidencePct = Math.round(confidence * 100);
|
||||
|
||||
return (
|
||||
<div className="seed-screen">
|
||||
<header className="seed-top">
|
||||
@@ -76,110 +168,147 @@ export function SeedScreen() {
|
||||
<Sigil size={28} />
|
||||
<span className="seed-brand">Socrata</span>
|
||||
<span className="seed-pip">·</span>
|
||||
<span className="seed-step">Seed · forming</span>
|
||||
<span className="seed-step">Seed · {ready ? "ready" : "forming"}</span>
|
||||
{meta.model && (
|
||||
<span className="seed-meta">via {meta.provider} · {meta.model.split("/").pop()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="seed-top-right">
|
||||
<span className="seed-mode-pill seed-mode-active">Interview</span>
|
||||
<span className="seed-mode-pill">Form</span>
|
||||
<a href="/" className="seed-mode-pill" style={{ textDecoration: "none" }}>← back</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="seed-body">
|
||||
{/* Left: emerging seed */}
|
||||
<section className="seed-left">
|
||||
<div className="seed-section-label">Emerging seed</div>
|
||||
<div className="seed-fields">
|
||||
{fields.map(f => (
|
||||
<div key={f.key} className={`seed-field ${f.inferred ? "seed-field-inferred" : ""}`}>
|
||||
<div className="seed-field-label">
|
||||
{f.label}
|
||||
{f.inferred && <span className="seed-conf">inferred · 0.74</span>}
|
||||
<Field label="Title" value={draft.title} />
|
||||
<Field label="Problem" value={draft.problem} />
|
||||
<Field label="Target user" value={draft.targetUser} />
|
||||
<Field label="Desired outcome" value={draft.desiredOutcome} />
|
||||
{draft.initialHypothesis && <Field label="Initial hypothesis" value={draft.initialHypothesis} inferred />}
|
||||
{draft.constraints && draft.constraints.length > 0 && (
|
||||
<div className="seed-field seed-field-inferred">
|
||||
<div className="seed-field-label">Constraints<span className="seed-conf">{draft.constraints.length}</span></div>
|
||||
<div className="seed-field-value">
|
||||
<ul style={{ margin: 0, paddingLeft: 14 }}>
|
||||
{draft.constraints.map((c, i) => <li key={i}>{c}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="seed-field-value">{f.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="seed-section-label seed-section-label-2">Initial model · drafting</div>
|
||||
<div className="seed-mini-graph">
|
||||
<div className="mini-block mini-block-1">
|
||||
<span className="mini-stereo">«block»</span>
|
||||
<span className="mini-name">Student</span>
|
||||
<span className="mini-prop">self_efficacy</span>
|
||||
</div>
|
||||
<div className="mini-edge" />
|
||||
<div className="mini-block mini-block-2 mini-block-focus">
|
||||
<span className="mini-stereo">«block»</span>
|
||||
<span className="mini-name">Aristotle</span>
|
||||
<span className="mini-prop">refusal_policy</span>
|
||||
<span className="mini-prop">interaction_style</span>
|
||||
</div>
|
||||
<div className="mini-edge mini-edge-down" />
|
||||
<div className="mini-block mini-block-3">
|
||||
<span className="mini-stereo">«constraint»</span>
|
||||
<span className="mini-name">FERPA boundary</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="seed-confidence">
|
||||
<div className="seed-confidence-row">
|
||||
<span>Model confidence</span>
|
||||
<span>0.62</span>
|
||||
<span>Draft confidence</span>
|
||||
<span>{confidencePct}%</span>
|
||||
</div>
|
||||
<div className="seed-confidence-bar">
|
||||
<div className="seed-confidence-fill" style={{ width: "62%" }} />
|
||||
<div className="seed-confidence-fill" style={{ width: `${confidencePct}%` }} />
|
||||
</div>
|
||||
<div className="seed-confidence-hint">
|
||||
Three more clarifying questions should bring this above 0.80.
|
||||
{ready
|
||||
? "Socrates says you're ready — click Generate to create the project."
|
||||
: `${filledFieldsCount} of 5 fields filled · keep answering to firm up the draft.`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<button
|
||||
className="seed-btn seed-btn-primary"
|
||||
type="button"
|
||||
onClick={generate}
|
||||
disabled={!canGenerate || generating}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{generating ? "Generating model…" : ready ? "Generate model & open editor" : canGenerate ? "Generate (early)" : "Generate (need more answers)"}
|
||||
</button>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: "6px 8px",
|
||||
background: "var(--warn-soft)",
|
||||
color: "var(--warn-strong)",
|
||||
borderRadius: 4,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}>
|
||||
⚠ {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right: Socrates conversation */}
|
||||
<section className="seed-right">
|
||||
<div className="seed-thread">
|
||||
{thread.map((m, i) => (
|
||||
<div key={i} className={`seed-bubble seed-bubble-${m.who}`}>
|
||||
{m.who === "socrates" && (
|
||||
{history.map((m, i) => (
|
||||
<div key={i} className={`seed-bubble seed-bubble-${m.role}`}>
|
||||
{m.role === "socrates" && (
|
||||
<div className="seed-bubble-avatar">
|
||||
<Sigil size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="seed-bubble-body">
|
||||
<div className="seed-bubble-who">{m.who === "socrates" ? "Socrates" : "You"}</div>
|
||||
<div className="seed-bubble-text">{m.text}</div>
|
||||
<div className="seed-bubble-who">{m.role === "socrates" ? "Socrates" : "You"}</div>
|
||||
<div
|
||||
className="seed-bubble-text"
|
||||
style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="seed-bubble seed-bubble-socrates seed-bubble-typing">
|
||||
<div className="seed-bubble-avatar">
|
||||
<Sigil size={32} />
|
||||
</div>
|
||||
<div className="seed-bubble-body">
|
||||
<div className="seed-bubble-who">Socrates</div>
|
||||
<div className="seed-typing">
|
||||
<span /><span /><span /> drafting next question
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={threadEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="seed-input-row">
|
||||
<form
|
||||
className="seed-input-row"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="seed-input">
|
||||
<span className="seed-input-prompt">›</span>
|
||||
<span className="seed-input-text">
|
||||
Public-university undergrads, weeks 3–10. A partner that asks. The market is saturated with explainers.
|
||||
</span>
|
||||
<span className="seed-input-caret" />
|
||||
<input
|
||||
className="seed-input-field"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
placeholder={sending ? "Socrates is thinking…" : ready ? "Want to keep refining? Ask again." : "Reply to Socrates…"}
|
||||
disabled={sending || generating}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="seed-input-actions">
|
||||
<button className="seed-btn" type="button">Save draft</button>
|
||||
<button className="seed-btn seed-btn-primary" type="button">Send · ⌘↵</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="seed-btn seed-btn-primary"
|
||||
disabled={sending || generating || !input.trim()}
|
||||
>
|
||||
Send · ⌘↵
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, inferred }: { label: string; value: string; inferred?: boolean }) {
|
||||
if (!value) {
|
||||
return (
|
||||
<div className="seed-field" style={{ opacity: 0.45 }}>
|
||||
<div className="seed-field-label">{label}</div>
|
||||
<div className="seed-field-value" style={{ fontStyle: "italic", color: "var(--muted)" }}>—</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`seed-field ${inferred ? "seed-field-inferred" : ""}`}>
|
||||
<div className="seed-field-label">{label}</div>
|
||||
<div className="seed-field-value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
195
apps/web/components/socrates/ContextualSocratesThread.tsx
Normal file
195
apps/web/components/socrates/ContextualSocratesThread.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
// Inline mini-Socrates thread anchored to a single finding. Replaces the
|
||||
// global SocratesDock for the conversational surface.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sigil } from "./Sigil";
|
||||
|
||||
interface ApiMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | string;
|
||||
text: string;
|
||||
options?: Array<{ n: number; label: string; sub?: string }>;
|
||||
}
|
||||
|
||||
interface ContextualSocratesThreadProps {
|
||||
projectId: string;
|
||||
findingId: string;
|
||||
findingText: string;
|
||||
onResolved?: () => void;
|
||||
}
|
||||
|
||||
export function ContextualSocratesThread({ projectId, findingId, onResolved }: ContextualSocratesThreadProps) {
|
||||
const [messages, setMessages] = useState<ApiMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const endRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const url = `/api/projects/${encodeURIComponent(projectId)}/findings/${encodeURIComponent(findingId)}/socrates`;
|
||||
|
||||
// Initial load + open the conversation if empty.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`load: ${res.status}`);
|
||||
const body = (await res.json()) as { messages?: ApiMessage[] };
|
||||
if (cancelled) return;
|
||||
const initial = body.messages ?? [];
|
||||
setMessages(initial);
|
||||
setLoaded(true);
|
||||
|
||||
if (initial.length === 0) {
|
||||
await sendImpl("");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ContextualSocratesThread] load failed:", err);
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [messages]);
|
||||
|
||||
const sendImpl = useCallback(
|
||||
async (text: string) => {
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`send: ${res.status}`);
|
||||
const body = (await res.json()) as {
|
||||
assistant: { id: string; turn: { text: string; options?: Array<{ n: number; label: string; sub?: string }> } };
|
||||
user: { id: string };
|
||||
};
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
if (text.trim().length > 0) {
|
||||
next.push({ id: body.user.id, role: "user", text });
|
||||
}
|
||||
next.push({
|
||||
id: body.assistant.id,
|
||||
role: "assistant",
|
||||
text: body.assistant.turn.text,
|
||||
options: body.assistant.turn.options,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ContextualSocratesThread] send failed:", err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[url]
|
||||
);
|
||||
|
||||
const onSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
setInput("");
|
||||
await sendImpl(text);
|
||||
}, [input, sending, sendImpl]);
|
||||
|
||||
const onResolve = useCallback(async () => {
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "resolved" }),
|
||||
});
|
||||
onResolved?.();
|
||||
} catch (err) {
|
||||
console.error("[ContextualSocratesThread] resolve failed:", err);
|
||||
}
|
||||
}, [url, onResolved]);
|
||||
|
||||
const onDismiss = useCallback(async () => {
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "dismissed" }),
|
||||
});
|
||||
onResolved?.();
|
||||
} catch (err) {
|
||||
console.error("[ContextualSocratesThread] dismiss failed:", err);
|
||||
}
|
||||
}, [url, onResolved]);
|
||||
|
||||
return (
|
||||
<div className="ctx-thread">
|
||||
<div className="ctx-thread-messages">
|
||||
{!loaded ? (
|
||||
<div className="ctx-thread-loading">…</div>
|
||||
) : (
|
||||
messages.map(m => (
|
||||
<div key={m.id} className={`ctx-bubble ctx-bubble-${m.role}`}>
|
||||
{m.role === "assistant" ? <Sigil size={18} /> : null}
|
||||
<div className="ctx-bubble-body">
|
||||
<div className="ctx-bubble-text">{m.text}</div>
|
||||
{m.options && m.options.length > 0 ? (
|
||||
<div className="ctx-bubble-options">
|
||||
{m.options.map(o => (
|
||||
<button
|
||||
key={o.n}
|
||||
type="button"
|
||||
className="ctx-option"
|
||||
onClick={() => setInput(prev => (prev ? `${prev} ${o.label}` : o.label))}
|
||||
>
|
||||
<span className="ctx-option-n">{o.n}.</span>
|
||||
<span className="ctx-option-label">{o.label}</span>
|
||||
{o.sub ? <span className="ctx-option-sub">{o.sub}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
<div className="ctx-thread-input-row">
|
||||
<textarea
|
||||
className="ctx-thread-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void onSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Reply to Socrates…"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="ctx-thread-actions">
|
||||
<button type="button" className="ctx-send" onClick={onSend} disabled={sending || !input.trim()}>
|
||||
⌘↵
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ctx-thread-resolve">
|
||||
<button type="button" className="ctx-resolve-btn" onClick={onResolve}>
|
||||
Mark resolved
|
||||
</button>
|
||||
<button type="button" className="ctx-dismiss-btn" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
// 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}]`;
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
// Active-thread dock with Sigil header, conversation bubbles, numbered options.
|
||||
//
|
||||
// M6: live thread loaded from /api/projects/[id]/socrates. The reply input
|
||||
// POSTs each user turn and renders Socrates' response when it arrives.
|
||||
// Numbered options pre-fill the input when clicked / pressed (1–3).
|
||||
|
||||
"use client";
|
||||
|
||||
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" | "proposal" | "system";
|
||||
text: string;
|
||||
options?: Array<{ n: number; label: string; sub?: string }>;
|
||||
pending?: boolean;
|
||||
proposal?: ProposalPayload;
|
||||
}
|
||||
|
||||
interface SocratesDockProps {
|
||||
projectId: string;
|
||||
presence: SocratesPresence;
|
||||
density: Density;
|
||||
}
|
||||
|
||||
interface ApiMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
options?: Array<{ n: number; label: string; sub?: string }>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`);
|
||||
if (!res.ok) throw new Error(`load thread: ${res.status}`);
|
||||
const data = (await res.json()) as { messages?: ApiMessage[] };
|
||||
if (cancelled) return;
|
||||
const initial: DockMessage[] = (data.messages ?? []).map(m => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
text: m.text,
|
||||
options: m.options,
|
||||
}));
|
||||
setMessages(initial);
|
||||
setLoaded(true);
|
||||
|
||||
if (initial.length === 0 && !cancelled) {
|
||||
// Trigger Socrates' opening turn
|
||||
await sendImpl("", true);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
// Auto-scroll on new turns
|
||||
useEffect(() => {
|
||||
threadEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [messages]);
|
||||
|
||||
const sendImpl = useCallback(
|
||||
async (text: string, isOpening = false) => {
|
||||
setSending(true);
|
||||
try {
|
||||
// Add the user bubble locally (optimistic) — unless this is the opening
|
||||
if (!isOpening) {
|
||||
setMessages(curr => [
|
||||
...curr,
|
||||
{ id: `tmp-u-${Date.now()}`, role: "user", text },
|
||||
]);
|
||||
}
|
||||
// Show a pending Socrates bubble
|
||||
const pendingId = `tmp-a-${Date.now()}`;
|
||||
setMessages(curr => [
|
||||
...curr,
|
||||
{ id: pendingId, role: "assistant", text: "thinking…", pending: true },
|
||||
]);
|
||||
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const a = data.assistant as { id: string; text: string; options?: Array<{ n: number; label: string; sub?: string }> };
|
||||
if (data.meta) setMeta({ provider: data.meta.provider, model: data.meta.model });
|
||||
|
||||
// Replace the pending bubble with the real one
|
||||
setMessages(curr => curr.map(m =>
|
||||
m.id === pendingId
|
||||
? { id: a.id, role: "assistant", text: a.text, options: a.options }
|
||||
: m
|
||||
));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setMessages(curr => curr.map(m =>
|
||||
m.pending ? { ...m, text: `⚠ ${msg}`, pending: false } : m
|
||||
));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
setInput("");
|
||||
await sendImpl(text);
|
||||
}, [input, sending, sendImpl]);
|
||||
|
||||
const pickOption = useCallback(async (option: { n: number; label: string; sub?: string }) => {
|
||||
if (sending) return;
|
||||
const text = `[${option.n}] ${option.label}${option.sub ? ` — ${option.sub}` : ""}`;
|
||||
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) => {
|
||||
// Don't hijack number keys when typing in any input/contenteditable
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
|
||||
if (sending) return;
|
||||
const last = [...messages].reverse().find(m => m.role === "assistant" && m.options?.length);
|
||||
if (!last?.options) return;
|
||||
const n = parseInt(e.key, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > last.options.length) return;
|
||||
e.preventDefault();
|
||||
const opt = last.options.find(o => o.n === n);
|
||||
if (opt) void pickOption(opt);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [messages, pickOption, sending]);
|
||||
|
||||
if (presence === "subtle") {
|
||||
return (
|
||||
<div className="dock dock-subtle" title={meta.model ? `Σ via ${meta.model}` : "Σ Socrates"}>
|
||||
<Sigil size={36} />
|
||||
{messages.filter(m => m.role === "assistant").length > 0 && (
|
||||
<div className="dock-subtle-count">{messages.filter(m => m.role === "assistant").length}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`dock ${presence === "prominent" ? "dock-prominent" : "dock-default"}`}>
|
||||
<header className="dock-header">
|
||||
<Sigil size={28} />
|
||||
<div className="dock-header-text">
|
||||
<div className="dock-name">Socrates</div>
|
||||
<div className="dock-status">
|
||||
<span className="dock-dot" />
|
||||
{meta.provider ? `${meta.provider}${meta.model ? ` · ${meta.model.split("/").pop()}` : ""}` : "ready"}
|
||||
</div>
|
||||
</div>
|
||||
<button className="dock-header-action" title="New thread (coming soon)" type="button" disabled>
|
||||
+
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="dock-thread-wrap">
|
||||
<div className="dock-section-label dock-section-label-inline">Active thread</div>
|
||||
<div className="dock-thread">
|
||||
{!loaded && (
|
||||
<div className="bubble bubble-assistant">
|
||||
<span className="bubble-sigil">Σ</span>
|
||||
<span className="bubble-body">
|
||||
<span className="bubble-text" style={{ opacity: 0.6 }}>loading…</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={threadEndRef} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="dock-input-wrap">
|
||||
<form
|
||||
className="dock-input"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
<span className="dock-input-prompt">›</span>
|
||||
<input
|
||||
className="dock-input-field"
|
||||
placeholder={sending ? "Socrates is thinking…" : "Reply to Socrates…"}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
disabled={sending}
|
||||
/>
|
||||
<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"
|
||||
disabled={sending || !input.trim()}
|
||||
title="Send (⌘↵)"
|
||||
>
|
||||
⌘↵
|
||||
</button>
|
||||
</form>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||||
}
|
||||
168
apps/web/components/text-canvas/ChipSuggestionExtension.ts
Normal file
168
apps/web/components/text-canvas/ChipSuggestionExtension.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// TipTap/ProseMirror plugin: dotted-underline decoration on text spans whose
|
||||
// surface form matches a taxonomy term label or synonym. Reads the term list
|
||||
// from the editor's storage (set by TextCanvas via `editor.storage.terms`).
|
||||
//
|
||||
// Click → convert the underlined span into a chip via the editor's
|
||||
// insertChip command. Hover → tooltip handled by CSS title attribute (we
|
||||
// stash the definition there so we don't need a portal).
|
||||
//
|
||||
// Decorations only apply over plain text, never inside a chip node (atomic).
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
interface TermLite {
|
||||
id: string;
|
||||
label: string;
|
||||
definition: string | null;
|
||||
synonyms: string[];
|
||||
linkedBlockId: string | null;
|
||||
}
|
||||
|
||||
const KEY = new PluginKey("chip-suggestion");
|
||||
|
||||
export const ChipSuggestionExtension = Extension.create({
|
||||
name: "chipSuggestion",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: KEY,
|
||||
state: {
|
||||
init(_, state) {
|
||||
const terms = readTerms(editor);
|
||||
return buildDecorations(state.doc, terms);
|
||||
},
|
||||
apply(tr, oldSet, _oldState, newState) {
|
||||
// Always rebuild on doc change OR when the editor signals that the
|
||||
// term list might have changed (force-update meta, dispatched by
|
||||
// TextCanvas after analyze runs).
|
||||
if (tr.docChanged || tr.getMeta("force-update")) {
|
||||
const terms = readTerms(editor);
|
||||
return buildDecorations(newState.doc, terms);
|
||||
}
|
||||
return oldSet.map(tr.mapping, tr.doc);
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return KEY.getState(state);
|
||||
},
|
||||
handleClick(view, _pos, ev) {
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (!target) return false;
|
||||
const span = target.closest(".chip-suggestion") as HTMLElement | null;
|
||||
if (!span) return false;
|
||||
const from = parseInt(span.dataset.from ?? "", 10);
|
||||
const to = parseInt(span.dataset.to ?? "", 10);
|
||||
const termId = span.dataset.termId ?? "";
|
||||
const label = span.dataset.label ?? span.innerText;
|
||||
if (Number.isNaN(from) || Number.isNaN(to) || !termId) return false;
|
||||
|
||||
// Replace the span with a chip node.
|
||||
const { tr } = view.state;
|
||||
const chipType = view.state.schema.nodes.chip;
|
||||
if (!chipType) return false;
|
||||
tr.replaceWith(
|
||||
from,
|
||||
to,
|
||||
chipType.create({ kind: "block", refId: termId, label })
|
||||
);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
function readTerms(editor: { storage: unknown }): TermLite[] {
|
||||
const storage = editor.storage as Record<string, unknown>;
|
||||
const raw = storage?.terms;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return (raw as TermLite[]).filter(t => t && typeof t.label === "string");
|
||||
}
|
||||
|
||||
interface MatchSpec {
|
||||
pattern: RegExp;
|
||||
term: TermLite;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
function buildPatterns(terms: TermLite[]): MatchSpec[] {
|
||||
const specs: MatchSpec[] = [];
|
||||
for (const t of terms) {
|
||||
const surfaces = dedupe([t.label, ...t.synonyms]).filter(s => s.trim().length >= 2);
|
||||
for (const s of surfaces) {
|
||||
specs.push({
|
||||
pattern: new RegExp(`\\b${escapeRegex(s)}\\b`, "gi"),
|
||||
term: t,
|
||||
surface: s,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Match longer phrases first so "Socratic Tutor" wins over "Tutor".
|
||||
specs.sort((a, b) => b.surface.length - a.surface.length);
|
||||
return specs;
|
||||
}
|
||||
|
||||
function buildDecorations(doc: import("@tiptap/pm/model").Node, terms: TermLite[]): DecorationSet {
|
||||
if (terms.length === 0) return DecorationSet.empty;
|
||||
const specs = buildPatterns(terms);
|
||||
const decos: Decoration[] = [];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText || !node.text) return;
|
||||
// Skip text nodes that are inside a chip — chips are atomic, but be safe.
|
||||
const text = node.text;
|
||||
const occupied: Array<[number, number]> = []; // [start, end) within the text node
|
||||
for (const spec of specs) {
|
||||
spec.pattern.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = spec.pattern.exec(text)) !== null) {
|
||||
const start = m.index;
|
||||
const end = start + m[0].length;
|
||||
if (overlaps(occupied, start, end)) continue;
|
||||
occupied.push([start, end]);
|
||||
const from = pos + start;
|
||||
const to = pos + end;
|
||||
decos.push(
|
||||
Decoration.inline(from, to, {
|
||||
class: "chip-suggestion",
|
||||
"data-term-id": spec.term.id,
|
||||
"data-label": spec.term.label,
|
||||
"data-from": String(from),
|
||||
"data-to": String(to),
|
||||
title: spec.term.definition ?? "",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decos);
|
||||
}
|
||||
|
||||
function overlaps(ranges: Array<[number, number]>, a: number, b: number): boolean {
|
||||
for (const [s, e] of ranges) if (a < e && b > s) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function dedupe(xs: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const x of xs) {
|
||||
const k = x.trim().toLowerCase();
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(x.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import type { ChipKind } from "../../lib/fixtures/aristotle";
|
||||
import type { MarkupStyle } from "./Chip";
|
||||
import { useChipFocus } from "./FocusContext";
|
||||
import { useModelStore } from "../../lib/sync/ModelStore";
|
||||
import { useAnalysis } from "../../lib/workspace/analysisStore";
|
||||
import { useOpenPanes } from "../../lib/workspace/openPanesStore";
|
||||
import { updateBlock, updateRequirement, updateAssociation } from "../../lib/sync/ops";
|
||||
|
||||
const KIND_LABEL: Record<ChipKind, string> = {
|
||||
@@ -42,6 +44,8 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
|
||||
|
||||
const { focusBlockId, setFocusBlockId } = useChipFocus();
|
||||
const { model, apply } = useModelStore();
|
||||
const { getTerm } = useAnalysis();
|
||||
const { setStack } = useOpenPanes();
|
||||
|
||||
// Resolve the live label from the model, falling back to the node's stored
|
||||
// label (e.g. for chips created via slash-menu before they're bound).
|
||||
@@ -88,7 +92,18 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
|
||||
e.preventDefault();
|
||||
if (refId) setIsEditing(true);
|
||||
},
|
||||
onClick: () => setFocusBlockId(refId),
|
||||
onClick: () => {
|
||||
// Single-click signals local diagram focus AND, if the chip refers
|
||||
// to a term, opens that term as a Concepts → term column chain so
|
||||
// the user lands in a coherent place.
|
||||
setFocusBlockId(refId);
|
||||
if (kind === "block" && getTerm(refId)) {
|
||||
setStack([
|
||||
{ kind: "section", id: "concepts" },
|
||||
{ kind: "term", id: refId },
|
||||
]);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// TipTap extension wrapping the slash-menu suggestion plugin.
|
||||
|
||||
"use client";
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { slashSuggestion } from "./slashSuggestion";
|
||||
|
||||
export const SlashExtension = Extension.create({
|
||||
name: "slashMenu",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...slashSuggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
// Slash menu — appears when the user types `/`. Shows the four chip kinds.
|
||||
// On selection, inserts a chip with a placeholder label that the user can
|
||||
// then rename inline.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useImperativeHandle, useState, forwardRef } from "react";
|
||||
import type { ChipKind } from "../../lib/fixtures/aristotle";
|
||||
|
||||
export interface SlashItem {
|
||||
kind: ChipKind;
|
||||
label: string;
|
||||
hint: string;
|
||||
glyph: string;
|
||||
}
|
||||
|
||||
export const SLASH_ITEMS: SlashItem[] = [
|
||||
{ kind: "block", label: "Block", hint: "an entity in the system", glyph: "▢" },
|
||||
{ kind: "property", label: "Property", hint: "an attribute of a block", glyph: "·" },
|
||||
{ kind: "association", label: "Association", hint: "a relationship", glyph: "→" },
|
||||
{ kind: "requirement", label: "Requirement", hint: "a stated goal (REQ-NNN)", glyph: "§" },
|
||||
];
|
||||
|
||||
export interface SlashMenuHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
interface SlashMenuProps {
|
||||
query: string;
|
||||
command: (item: SlashItem) => void;
|
||||
}
|
||||
|
||||
export const SlashMenu = forwardRef<SlashMenuHandle, SlashMenuProps>(function SlashMenu(
|
||||
{ query, command },
|
||||
ref
|
||||
) {
|
||||
const filtered = SLASH_ITEMS.filter(
|
||||
item =>
|
||||
query.length === 0 ||
|
||||
item.kind.toLowerCase().startsWith(query.toLowerCase()) ||
|
||||
item.label.toLowerCase().startsWith(query.toLowerCase())
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [query]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
if (filtered.length === 0) return false;
|
||||
if (event.key === "ArrowUp") {
|
||||
setActiveIndex(prev => (prev - 1 + filtered.length) % filtered.length);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
setActiveIndex(prev => (prev + 1) % filtered.length);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
const choice = filtered[activeIndex];
|
||||
if (choice) {
|
||||
command(choice);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Number-key shortcut: 1–4 picks the corresponding item
|
||||
const num = parseInt(event.key, 10);
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= filtered.length) {
|
||||
const choice = filtered[num - 1];
|
||||
if (choice) {
|
||||
command(choice);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="slash-menu slash-menu-empty">
|
||||
<span className="slash-menu-empty-text">no matches for “{query}”</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="slash-menu">
|
||||
{filtered.map((item, idx) => (
|
||||
<button
|
||||
key={item.kind}
|
||||
type="button"
|
||||
className={`slash-menu-item ${idx === activeIndex ? "slash-menu-item-active" : ""}`}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onMouseDown={e => {
|
||||
// mousedown so the click registers before the editor blurs
|
||||
e.preventDefault();
|
||||
command(item);
|
||||
}}
|
||||
>
|
||||
<span className={`slash-menu-glyph slash-menu-glyph-${item.kind}`}>{item.glyph}</span>
|
||||
<span className="slash-menu-label">{item.label}</span>
|
||||
<span className="slash-menu-hint">{item.hint}</span>
|
||||
<span className="slash-menu-key">{idx + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,78 +1,129 @@
|
||||
// TipTap-backed narrative editor.
|
||||
// Renders the same prose surface as the static port, but typing actually works
|
||||
// and chips can be inserted via the slash menu (`/block`, `/property`, etc.).
|
||||
//
|
||||
// After the pivot the text editor is the canonical surface. It loads its doc
|
||||
// from /api/projects/[id]/document and saves on a 600ms debounce. Chips remain
|
||||
// the rendered primitive but are no longer inserted via slash menu — the
|
||||
// ChipSuggestionDecorator (added separately) suggests chip-ifying terms that
|
||||
// match the project's taxonomy.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChipNode } from "./ChipNode";
|
||||
import { SlashExtension } from "./SlashExtension";
|
||||
import { ChipFocusContext } from "./FocusContext";
|
||||
import { fixtureToDoc } from "./fixtureToDoc";
|
||||
import type { Density } from "../socrates/SocratesDock";
|
||||
import { ChipSuggestionExtension } from "./ChipSuggestionExtension";
|
||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||
import type { MarkupStyle } from "./Chip";
|
||||
import { useAnalysis } from "../../lib/workspace/analysisStore";
|
||||
|
||||
export type Density = "comfortable" | "compact";
|
||||
|
||||
interface TextCanvasProps {
|
||||
data: FixtureData;
|
||||
density: Density;
|
||||
markupStyle: MarkupStyle;
|
||||
focusBlockId: string | null;
|
||||
setFocusBlockId: (id: string | null) => void;
|
||||
projectId: string;
|
||||
density?: Density;
|
||||
markupStyle?: MarkupStyle;
|
||||
}
|
||||
|
||||
export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusBlockId }: TextCanvasProps) {
|
||||
export function TextCanvas({ data, projectId, density = "comfortable", markupStyle = "color" }: TextCanvasProps) {
|
||||
const padY = density === "compact" ? 10 : 18;
|
||||
const padX = density === "compact" ? 22 : 36;
|
||||
const focusValue = useMemo(
|
||||
() => ({ focusBlockId, setFocusBlockId }),
|
||||
[focusBlockId, setFocusBlockId]
|
||||
);
|
||||
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
||||
const [initialDoc, setInitialDoc] = useState<unknown | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const focusValue = useMemo(() => ({ focusBlockId, setFocusBlockId }), [focusBlockId, setFocusBlockId]);
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2] },
|
||||
// Drop features we don't need yet
|
||||
codeBlock: false,
|
||||
blockquote: false,
|
||||
horizontalRule: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
strike: false,
|
||||
code: false,
|
||||
link: false,
|
||||
}),
|
||||
ChipNode,
|
||||
SlashExtension,
|
||||
],
|
||||
content: fixtureToDoc(data),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "text-canvas tiptap",
|
||||
style: `padding: ${padY}px ${padX}px;`,
|
||||
const { terms } = useAnalysis();
|
||||
|
||||
// Load the saved doc once on mount; fall back to the fixture if there is none.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/document`);
|
||||
if (!res.ok) throw new Error(`load doc: ${res.status}`);
|
||||
const body = (await res.json()) as { doc: unknown };
|
||||
if (cancelled) return;
|
||||
setInitialDoc(body.doc ?? fixtureToDoc(data));
|
||||
} catch {
|
||||
if (!cancelled) setInitialDoc(fixtureToDoc(data));
|
||||
} finally {
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, data]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2] },
|
||||
codeBlock: false,
|
||||
blockquote: false,
|
||||
horizontalRule: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
strike: false,
|
||||
code: false,
|
||||
link: false,
|
||||
}),
|
||||
ChipNode,
|
||||
ChipSuggestionExtension,
|
||||
],
|
||||
content: initialDoc ?? null,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "text-canvas tiptap",
|
||||
style: `padding: ${padY}px ${padX}px;`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
[initialDoc]
|
||||
);
|
||||
|
||||
// Push the markup style into the editor so the ChipView NodeView can read it.
|
||||
// Push markup style + terms into the editor storage so node-views read them.
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
(editor.storage as unknown as Record<string, unknown>).markupStyle = markupStyle;
|
||||
// Force a re-render of all chip node views so they pick up the new style.
|
||||
const storage = editor.storage as unknown as Record<string, unknown>;
|
||||
storage.markupStyle = markupStyle;
|
||||
storage.terms = terms;
|
||||
editor.view.dispatch(editor.state.tr.setMeta("force-update", true));
|
||||
}, [editor, markupStyle]);
|
||||
}, [editor, markupStyle, terms]);
|
||||
|
||||
if (!editor) {
|
||||
// Debounced save on document change.
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const onUpdate = () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
const doc = editor.getJSON();
|
||||
void fetch(`/api/projects/${encodeURIComponent(projectId)}/document`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ doc }),
|
||||
}).catch(err => console.error("[TextCanvas] save failed:", err));
|
||||
}, 600);
|
||||
};
|
||||
editor.on("update", onUpdate);
|
||||
return () => {
|
||||
editor.off("update", onUpdate);
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, [editor, projectId]);
|
||||
|
||||
if (!loaded || !editor) {
|
||||
return (
|
||||
<div className="text-canvas" style={{ padding: `${padY}px ${padX}px` }}>
|
||||
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
loading editor…
|
||||
</div>
|
||||
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>loading editor…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,15 +131,6 @@ export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusB
|
||||
return (
|
||||
<ChipFocusContext.Provider value={focusValue}>
|
||||
<EditorContent editor={editor} />
|
||||
<div className="margin-note" style={{ margin: `0 ${padX}px ${padY}px ${padX}px`, maxWidth: 720 }}>
|
||||
<span className="margin-note-glyph">Σ</span>
|
||||
<span>
|
||||
<span className="margin-note-who">Socrates · margin</span>
|
||||
<span className="margin-note-text">
|
||||
“Refuses to produce solutions” is a strong constraint. Have you decided what counts as a “solution” vs. a “scaffold”? This boundary will determine whether the refusal policy is enforceable.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</ChipFocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// Suggestion plugin config that wires the slash menu into TipTap.
|
||||
// Renders SlashMenu in a fixed-position floating panel near the caret.
|
||||
|
||||
"use client";
|
||||
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { createElement, createRef } from "react";
|
||||
import { SlashMenu, SLASH_ITEMS, type SlashItem, type SlashMenuHandle } from "./SlashMenu";
|
||||
|
||||
export const slashSuggestion: Omit<SuggestionOptions<SlashItem, SlashItem>, "editor"> = {
|
||||
char: "/",
|
||||
startOfLine: false,
|
||||
allowSpaces: false,
|
||||
|
||||
items: ({ query }) =>
|
||||
SLASH_ITEMS.filter(
|
||||
item =>
|
||||
query.length === 0 ||
|
||||
item.kind.toLowerCase().startsWith(query.toLowerCase()) ||
|
||||
item.label.toLowerCase().startsWith(query.toLowerCase())
|
||||
),
|
||||
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: SlashItem }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertChip({
|
||||
kind: props.kind,
|
||||
refId: null,
|
||||
label: props.kind === "requirement" ? "REQ-001" : "untitled",
|
||||
})
|
||||
.run();
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
const handleRef = createRef<SlashMenuHandle>();
|
||||
|
||||
function position(rect: DOMRect | null) {
|
||||
if (!container || !rect) return;
|
||||
container.style.position = "fixed";
|
||||
container.style.top = `${rect.bottom + 6}px`;
|
||||
container.style.left = `${rect.left}px`;
|
||||
container.style.zIndex = "1000";
|
||||
}
|
||||
|
||||
function rerender(props: SuggestionProps<SlashItem>) {
|
||||
if (!root) return;
|
||||
root.render(
|
||||
createElement(SlashMenu, {
|
||||
ref: handleRef,
|
||||
query: props.query,
|
||||
command: (item: SlashItem) => props.command(item),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
onStart(props) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
rerender(props);
|
||||
position(props.clientRect?.() ?? null);
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
rerender(props);
|
||||
position(props.clientRect?.() ?? null);
|
||||
},
|
||||
|
||||
onKeyDown(props: SuggestionKeyDownProps) {
|
||||
if (props.event.key === "Escape") {
|
||||
props.event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return handleRef.current?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
if (root) root.unmount();
|
||||
if (container && container.parentNode) container.parentNode.removeChild(container);
|
||||
root = null;
|
||||
container = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
95
apps/web/lib/llm/analyze/concepts.ts
Normal file
95
apps/web/lib/llm/analyze/concepts.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Single-pass "concepts" analyzer — replaces the legacy taxonomy + glossary
|
||||
// pair (T3 of the integration plan). One LLM call emits both the term list
|
||||
// (with hierarchy + synonyms) and definitions, so the two layers can never
|
||||
// drift out of sync.
|
||||
//
|
||||
// The legacy `taxonomy` / `glossary` analyzers (analyze/taxonomy.ts and
|
||||
// analyze/glossary.ts) are still in the tree as deprecated fallbacks for one
|
||||
// release; nothing in the runtime path imports them anymore.
|
||||
|
||||
import "server-only";
|
||||
import { defaultGateway, chatJSON, type Message } from "../gateway";
|
||||
import { loadPrompt } from "../prompts";
|
||||
import type { DetectedTerm } from "../../db/repo";
|
||||
|
||||
interface RawTerm {
|
||||
label?: string;
|
||||
parentLabel?: string | null;
|
||||
synonyms?: string[];
|
||||
definition?: string;
|
||||
}
|
||||
|
||||
const conceptsJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["terms"],
|
||||
properties: {
|
||||
terms: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["label"],
|
||||
properties: {
|
||||
label: { type: "string", minLength: 1 },
|
||||
parentLabel: { type: ["string", "null"] },
|
||||
synonyms: { type: "array", items: { type: "string" } },
|
||||
definition: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface ConceptsResult {
|
||||
terms: DetectedTerm[];
|
||||
definitions: Array<{ label: string; definition: string }>;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function analyzeConcepts(documentText: string): Promise<ConceptsResult> {
|
||||
const prompt = loadPrompt("socrates/analyze-concepts.md");
|
||||
const gateway = defaultGateway();
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: prompt },
|
||||
{ role: "user", content: `Document:\n\n${documentText}` },
|
||||
];
|
||||
|
||||
const { value, result } = await chatJSON<{ terms?: RawTerm[] }>(gateway, messages, {
|
||||
temperature: 0.2,
|
||||
maxTokens: 2400,
|
||||
jsonSchema: { name: "concepts", schema: conceptsJsonSchema as Record<string, unknown> },
|
||||
jsonObjectMode: true,
|
||||
maxRepairs: 2,
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const terms: DetectedTerm[] = [];
|
||||
const definitions: Array<{ label: string; definition: string }> = [];
|
||||
for (const t of value?.terms ?? []) {
|
||||
const label = (t.label ?? "").trim();
|
||||
if (!label) continue;
|
||||
const key = label.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
terms.push({
|
||||
label,
|
||||
parentLabel: t.parentLabel?.trim() || null,
|
||||
synonyms: Array.isArray(t.synonyms) ? t.synonyms.filter(s => typeof s === "string") : [],
|
||||
});
|
||||
const def = (t.definition ?? "").trim();
|
||||
if (def) definitions.push({ label, definition: def });
|
||||
}
|
||||
|
||||
return {
|
||||
terms,
|
||||
definitions,
|
||||
inputTokens: result.inputTokens,
|
||||
outputTokens: result.outputTokens,
|
||||
provider: gateway.provider,
|
||||
model: gateway.model,
|
||||
};
|
||||
}
|
||||
81
apps/web/lib/llm/analyze/glossary.ts
Normal file
81
apps/web/lib/llm/analyze/glossary.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// Analyze pass: write definitions for taxonomy terms grounded in the prose.
|
||||
|
||||
import "server-only";
|
||||
import { defaultGateway, chatJSON, type Message } from "../gateway";
|
||||
import { loadPrompt } from "../prompts";
|
||||
|
||||
const glossaryJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["definitions"],
|
||||
properties: {
|
||||
definitions: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["label", "definition"],
|
||||
properties: {
|
||||
label: { type: "string", minLength: 1 },
|
||||
definition: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface GlossaryResult {
|
||||
defs: Array<{ label: string; definition: string }>;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function analyzeGlossary(
|
||||
documentText: string,
|
||||
terms: Array<{ label: string; parentLabel?: string | null; synonyms?: string[] }>
|
||||
): Promise<GlossaryResult> {
|
||||
if (terms.length === 0) {
|
||||
return { defs: [], inputTokens: 0, outputTokens: 0, provider: "n/a", model: "n/a" };
|
||||
}
|
||||
|
||||
const prompt = loadPrompt("socrates/analyze-glossary.md");
|
||||
const gateway = defaultGateway();
|
||||
const userPayload =
|
||||
`Document:\n\n${documentText}\n\n---\n\n` +
|
||||
`Terms:\n\`\`\`json\n${JSON.stringify(terms, null, 2)}\n\`\`\``;
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: prompt },
|
||||
{ role: "user", content: userPayload },
|
||||
];
|
||||
|
||||
const { value, result } = await chatJSON<{ definitions?: Array<{ label?: string; definition?: string }> }>(
|
||||
gateway,
|
||||
messages,
|
||||
{
|
||||
temperature: 0.2,
|
||||
maxTokens: 1800,
|
||||
jsonSchema: { name: "glossary", schema: glossaryJsonSchema as Record<string, unknown> },
|
||||
jsonObjectMode: true,
|
||||
maxRepairs: 2,
|
||||
}
|
||||
);
|
||||
|
||||
const defs: Array<{ label: string; definition: string }> = [];
|
||||
for (const d of value?.definitions ?? []) {
|
||||
const label = (d.label ?? "").trim();
|
||||
const def = (d.definition ?? "").trim();
|
||||
if (!label || !def) continue;
|
||||
defs.push({ label, definition: def });
|
||||
}
|
||||
|
||||
return {
|
||||
defs,
|
||||
inputTokens: result.inputTokens,
|
||||
outputTokens: result.outputTokens,
|
||||
provider: gateway.provider,
|
||||
model: gateway.model,
|
||||
};
|
||||
}
|
||||
496
apps/web/lib/llm/analyze/model.ts
Normal file
496
apps/web/lib/llm/analyze/model.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
// Analyze pass: derive (or refresh) the SysML model from prose + taxonomy.
|
||||
//
|
||||
// Differs from generateModel.ts (the seed-time generator) in two ways:
|
||||
// 1. Inputs are prose + the current taxonomy term list, not a SeedDraft.
|
||||
// 2. Re-runs *merge* with the existing model: blocks already linked to a
|
||||
// taxonomy term are preserved (id, position, properties) so the user's
|
||||
// manual canvas work isn't blown away on every Analyze.
|
||||
|
||||
import "server-only";
|
||||
import { defaultGateway, chatJSON, type Message } from "../gateway";
|
||||
import { loadPrompt } from "../prompts";
|
||||
import type {
|
||||
SysMLModel,
|
||||
Block,
|
||||
Association,
|
||||
Constraint,
|
||||
Requirement,
|
||||
PropertyType,
|
||||
ReviewStatus,
|
||||
} from "../../sysml/model";
|
||||
|
||||
interface LeanProperty {
|
||||
name: string;
|
||||
type: { kind: "string" | "number" | "boolean" | "enum"; values?: string[] };
|
||||
}
|
||||
interface LeanBlock {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "system" | "actor" | "block";
|
||||
linkedTermLabel?: string;
|
||||
properties?: LeanProperty[];
|
||||
confidence: number;
|
||||
}
|
||||
interface LeanAssoc {
|
||||
id: string;
|
||||
fromBlockId: string;
|
||||
toBlockId: string;
|
||||
label?: string;
|
||||
kind: "association" | "composition" | "aggregation" | "generalization" | "constraintApplies";
|
||||
/** Optional concept-name for the relationship itself (T2 integration). */
|
||||
linkedTermLabel?: string;
|
||||
confidence: number;
|
||||
}
|
||||
interface LeanConstraint {
|
||||
id: string;
|
||||
label: string;
|
||||
expression?: string;
|
||||
appliesTo?: string[];
|
||||
/** Optional concept-name the constraint enforces. */
|
||||
linkedTermLabel?: string;
|
||||
confidence: number;
|
||||
}
|
||||
interface LeanRequirement {
|
||||
id: string;
|
||||
tag: string;
|
||||
text: string;
|
||||
satisfiedBy?: string[];
|
||||
/** Optional concept this requirement is "about." */
|
||||
linkedTermLabel?: string;
|
||||
confidence: number;
|
||||
}
|
||||
interface LeanModel {
|
||||
systemOfInterestId?: string;
|
||||
blocks: LeanBlock[];
|
||||
associations?: LeanAssoc[];
|
||||
constraints?: LeanConstraint[];
|
||||
requirements?: LeanRequirement[];
|
||||
overallConfidence?: number;
|
||||
}
|
||||
|
||||
const modelJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["blocks"],
|
||||
properties: {
|
||||
systemOfInterestId: { type: "string" },
|
||||
blocks: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "label", "kind", "confidence"],
|
||||
properties: {
|
||||
id: { type: "string", minLength: 1 },
|
||||
label: { type: "string", minLength: 1 },
|
||||
kind: { type: "string", enum: ["system", "actor", "block"] },
|
||||
linkedTermLabel: { type: "string" },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
properties: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["name", "type"],
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1 },
|
||||
type: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["kind"],
|
||||
properties: {
|
||||
kind: { type: "string", enum: ["string", "number", "boolean", "enum"] },
|
||||
values: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
associations: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "fromBlockId", "toBlockId", "kind", "confidence"],
|
||||
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", "constraintApplies"],
|
||||
},
|
||||
linkedTermLabel: { type: "string" },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
constraints: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "label", "confidence"],
|
||||
properties: {
|
||||
id: { type: "string", minLength: 1 },
|
||||
label: { type: "string", minLength: 1 },
|
||||
expression: { type: "string" },
|
||||
appliesTo: { type: "array", items: { type: "string" } },
|
||||
linkedTermLabel: { type: "string" },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
requirements: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "tag", "text", "confidence"],
|
||||
properties: {
|
||||
id: { type: "string", minLength: 1 },
|
||||
tag: { type: "string", minLength: 1 },
|
||||
text: { type: "string", minLength: 1 },
|
||||
satisfiedBy: { type: "array", items: { type: "string" } },
|
||||
linkedTermLabel: { type: "string" },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
overallConfidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface AnalyzeModelResult {
|
||||
model: SysMLModel;
|
||||
termIdToBlockId: Map<string, string>;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
provider: string;
|
||||
model_name: string;
|
||||
}
|
||||
|
||||
export async function analyzeModelFromProse(
|
||||
documentText: string,
|
||||
terms: Array<{ id: string; label: string; linkedBlockId: string | null }>,
|
||||
current: SysMLModel
|
||||
): Promise<AnalyzeModelResult> {
|
||||
const character = loadPrompt("socrates/character.md");
|
||||
const generate = loadPrompt("socrates/analyze-model.md");
|
||||
|
||||
const userPayload =
|
||||
`Document:\n\n${documentText}\n\n---\n\n` +
|
||||
`Taxonomy:\n\`\`\`json\n${JSON.stringify(terms.map(t => t.label), null, 2)}\n\`\`\`\n` +
|
||||
`Return only the JSON object conforming to the schema.`;
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: `${character}\n\n---\n\n${generate}` },
|
||||
{ role: "user", content: userPayload },
|
||||
];
|
||||
|
||||
const gateway = defaultGateway();
|
||||
const { value, result } = await chatJSON<LeanModel>(gateway, messages, {
|
||||
temperature: 0.3,
|
||||
maxTokens: 2400,
|
||||
jsonSchema: { name: "sysml_model", schema: modelJsonSchema as Record<string, unknown> },
|
||||
jsonObjectMode: true,
|
||||
maxRepairs: 2,
|
||||
});
|
||||
|
||||
const merged = mergeIntoCurrent(value, current, terms);
|
||||
|
||||
return {
|
||||
...merged,
|
||||
inputTokens: result.inputTokens,
|
||||
outputTokens: result.outputTokens,
|
||||
provider: gateway.provider,
|
||||
model_name: gateway.model,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Lean → canonical, merged with current model (review-aware) ─────────
|
||||
//
|
||||
// Re-running the analyzer is non-destructive. For each element kind we
|
||||
// match against current by a stable key:
|
||||
// block — id (preserved via linkedTermId chain) OR linkedTermId
|
||||
// association — `${kind}|${fromBlockId}|${toBlockId}`
|
||||
// constraint — `${linkedTermId ?? "label:"+normalize(label)}`
|
||||
// requirement — `${linkedTermId ?? "text:"+normalize(text)}`
|
||||
//
|
||||
// New incoming → reviewStatus = "suggested".
|
||||
// Matched + currently deprecated → flips back to "suggested".
|
||||
// Existing not in incoming + not pinned + not currently suggested → "deprecated".
|
||||
// Existing not in incoming + status was "suggested" → quietly dropped.
|
||||
// Pinned elements left alone regardless.
|
||||
|
||||
function mergeIntoCurrent(
|
||||
lean: LeanModel | null | undefined,
|
||||
current: SysMLModel,
|
||||
terms: Array<{ id: string; label: string; linkedBlockId: string | null }>
|
||||
): { model: SysMLModel; termIdToBlockId: Map<string, string> } {
|
||||
if (!lean || !Array.isArray(lean.blocks)) {
|
||||
return { model: current, termIdToBlockId: new Map() };
|
||||
}
|
||||
|
||||
const termByLabel = new Map<string, { id: string; linkedBlockId: string | null }>();
|
||||
for (const t of terms) termByLabel.set(t.label.toLowerCase(), { id: t.id, linkedBlockId: t.linkedBlockId });
|
||||
const resolveTermId = (label: string | undefined): string | undefined => {
|
||||
if (!label) return undefined;
|
||||
return termByLabel.get(label.toLowerCase())?.id;
|
||||
};
|
||||
|
||||
// ─── Blocks ────────────────────────────────────────────────────────────
|
||||
|
||||
const existingByLinkedTerm = new Map<string, Block>();
|
||||
const existingById = new Map<string, Block>();
|
||||
for (const b of current.blocks) {
|
||||
existingById.set(b.id, b);
|
||||
if (b.linkedTermId) existingByLinkedTerm.set(b.linkedTermId, b);
|
||||
}
|
||||
|
||||
const blocks: Block[] = [];
|
||||
const leanIdToCanonical = new Map<string, string>();
|
||||
const termIdToBlockId = new Map<string, string>();
|
||||
const matchedBlockIds = new Set<string>();
|
||||
|
||||
for (const b of lean.blocks) {
|
||||
const linkedTerm = b.linkedTermLabel ? termByLabel.get(b.linkedTermLabel.toLowerCase()) : undefined;
|
||||
|
||||
let existing: Block | undefined;
|
||||
if (linkedTerm) existing = existingByLinkedTerm.get(linkedTerm.id);
|
||||
if (!existing && existingById.has(b.id)) existing = existingById.get(b.id);
|
||||
|
||||
let canonicalId = b.id;
|
||||
let stereotypes: string[] = [b.kind];
|
||||
let propertiesSource = b.properties ?? [];
|
||||
let nextStatus: ReviewStatus = "suggested";
|
||||
let pinned = false;
|
||||
|
||||
if (existing) {
|
||||
canonicalId = existing.id;
|
||||
stereotypes = existing.stereotypes;
|
||||
propertiesSource = mergeProperties(existing.properties, b.properties ?? []);
|
||||
pinned = !!existing.reviewPinned;
|
||||
// Existing accepted stays accepted; existing deprecated flips back to
|
||||
// suggested (analyzer changed its mind); existing suggested stays
|
||||
// suggested.
|
||||
const cur: ReviewStatus = (existing.reviewStatus ?? "accepted") as ReviewStatus;
|
||||
nextStatus = cur === "deprecated" ? "suggested" : cur;
|
||||
matchedBlockIds.add(existing.id);
|
||||
}
|
||||
|
||||
if (linkedTerm) termIdToBlockId.set(linkedTerm.id, canonicalId);
|
||||
leanIdToCanonical.set(b.id, canonicalId);
|
||||
|
||||
blocks.push({
|
||||
id: canonicalId,
|
||||
label: b.label,
|
||||
kind: b.kind,
|
||||
stereotypes,
|
||||
properties: propertiesSource.map((p, i) => ({
|
||||
id: `${canonicalId}_p${i + 1}`,
|
||||
name: p.name,
|
||||
type: expandPropertyType(p.type),
|
||||
multiplicity: "0..1" as const,
|
||||
})),
|
||||
linkedTermId: linkedTerm?.id,
|
||||
reviewStatus: nextStatus,
|
||||
reviewPinned: pinned,
|
||||
});
|
||||
}
|
||||
|
||||
// Existing blocks the analyzer didn't mention.
|
||||
for (const b of current.blocks) {
|
||||
if (matchedBlockIds.has(b.id)) continue;
|
||||
const cur: ReviewStatus = (b.reviewStatus ?? "accepted") as ReviewStatus;
|
||||
if (cur === "suggested") continue; // never reviewed + dropped → drop.
|
||||
if (b.reviewPinned) {
|
||||
blocks.push(b);
|
||||
continue;
|
||||
}
|
||||
blocks.push({ ...b, reviewStatus: "deprecated" });
|
||||
}
|
||||
|
||||
// ─── Associations ─────────────────────────────────────────────────────
|
||||
|
||||
const associations = mergeListByKey<Association, LeanAssoc>({
|
||||
existing: current.associations,
|
||||
incoming: lean.associations ?? [],
|
||||
keyExisting: a => `${a.kind}|${a.fromBlockId}|${a.toBlockId}`,
|
||||
keyIncoming: a =>
|
||||
`${a.kind}|${leanIdToCanonical.get(a.fromBlockId) ?? a.fromBlockId}|${
|
||||
leanIdToCanonical.get(a.toBlockId) ?? a.toBlockId
|
||||
}`,
|
||||
fromIncoming: a => ({
|
||||
id: a.id,
|
||||
fromBlockId: leanIdToCanonical.get(a.fromBlockId) ?? a.fromBlockId,
|
||||
toBlockId: leanIdToCanonical.get(a.toBlockId) ?? a.toBlockId,
|
||||
label: a.label ?? "",
|
||||
kind: a.kind,
|
||||
linkedTermId: resolveTermId(a.linkedTermLabel),
|
||||
}),
|
||||
mergeFields: (existing, incoming) => ({
|
||||
...existing,
|
||||
label: incoming.label ?? existing.label,
|
||||
// linkedTermId: prefer fresh suggestion, fall back to existing.
|
||||
linkedTermId:
|
||||
resolveTermId(incoming.linkedTermLabel) ?? existing.linkedTermId,
|
||||
}),
|
||||
});
|
||||
|
||||
// ─── Constraints ──────────────────────────────────────────────────────
|
||||
|
||||
const constraints = mergeListByKey<Constraint, LeanConstraint>({
|
||||
existing: current.constraints,
|
||||
incoming: lean.constraints ?? [],
|
||||
keyExisting: c => c.linkedTermId ? `term:${c.linkedTermId}` : `label:${normalizeText(c.label)}`,
|
||||
keyIncoming: c => {
|
||||
const tid = resolveTermId(c.linkedTermLabel);
|
||||
return tid ? `term:${tid}` : `label:${normalizeText(c.label)}`;
|
||||
},
|
||||
fromIncoming: c => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
expression: c.expression ?? "",
|
||||
appliesTo: (c.appliesTo ?? []).map(x => leanIdToCanonical.get(x) ?? x),
|
||||
linkedTermId: resolveTermId(c.linkedTermLabel),
|
||||
}),
|
||||
mergeFields: (existing, incoming) => ({
|
||||
...existing,
|
||||
label: incoming.label,
|
||||
expression: incoming.expression ?? existing.expression,
|
||||
appliesTo: (incoming.appliesTo ?? []).map(x => leanIdToCanonical.get(x) ?? x),
|
||||
linkedTermId: resolveTermId(incoming.linkedTermLabel) ?? existing.linkedTermId,
|
||||
}),
|
||||
});
|
||||
|
||||
// ─── Requirements ─────────────────────────────────────────────────────
|
||||
|
||||
const requirements = mergeListByKey<Requirement, LeanRequirement>({
|
||||
existing: current.requirements,
|
||||
incoming: lean.requirements ?? [],
|
||||
keyExisting: r => r.linkedTermId ? `term:${r.linkedTermId}` : `text:${normalizeText(r.text)}`,
|
||||
keyIncoming: r => {
|
||||
const tid = resolveTermId(r.linkedTermLabel);
|
||||
return tid ? `term:${tid}` : `text:${normalizeText(r.text)}`;
|
||||
},
|
||||
fromIncoming: r => ({
|
||||
id: r.id,
|
||||
tag: r.tag,
|
||||
text: r.text,
|
||||
relations: (r.satisfiedBy ?? []).map(blockId => ({
|
||||
kind: "satisfy" as const,
|
||||
blockId: leanIdToCanonical.get(blockId) ?? blockId,
|
||||
})),
|
||||
linkedTermId: resolveTermId(r.linkedTermLabel),
|
||||
}),
|
||||
mergeFields: (existing, incoming) => ({
|
||||
...existing,
|
||||
tag: incoming.tag || existing.tag,
|
||||
text: incoming.text || existing.text,
|
||||
relations: (incoming.satisfiedBy ?? []).map(blockId => ({
|
||||
kind: "satisfy" as const,
|
||||
blockId: leanIdToCanonical.get(blockId) ?? blockId,
|
||||
})),
|
||||
linkedTermId: resolveTermId(incoming.linkedTermLabel) ?? existing.linkedTermId,
|
||||
}),
|
||||
});
|
||||
|
||||
let soiId = lean.systemOfInterestId ? leanIdToCanonical.get(lean.systemOfInterestId) ?? lean.systemOfInterestId : undefined;
|
||||
if (!soiId) {
|
||||
const systems = blocks.filter(b => b.kind === "system");
|
||||
if (systems.length === 1) soiId = systems[0]!.id;
|
||||
}
|
||||
|
||||
return { model: { systemOfInterestId: soiId, blocks, associations, constraints, requirements }, termIdToBlockId };
|
||||
}
|
||||
|
||||
// ─── Generic merge-with-review helper ──────────────────────────────────
|
||||
|
||||
interface ReviewableElement {
|
||||
reviewStatus?: ReviewStatus;
|
||||
reviewPinned?: boolean;
|
||||
}
|
||||
|
||||
interface MergeArgs<E extends ReviewableElement, I> {
|
||||
existing: E[];
|
||||
incoming: I[];
|
||||
keyExisting: (e: E) => string;
|
||||
keyIncoming: (i: I) => string;
|
||||
fromIncoming: (i: I) => E;
|
||||
mergeFields: (existing: E, incoming: I) => E;
|
||||
}
|
||||
|
||||
function mergeListByKey<E extends ReviewableElement, I>(args: MergeArgs<E, I>): E[] {
|
||||
const existingByKey = new Map<string, E>();
|
||||
for (const e of args.existing) existingByKey.set(args.keyExisting(e), e);
|
||||
|
||||
const incomingByKey = new Map<string, I>();
|
||||
for (const i of args.incoming) {
|
||||
const k = args.keyIncoming(i);
|
||||
if (!incomingByKey.has(k)) incomingByKey.set(k, i);
|
||||
}
|
||||
|
||||
const out: E[] = [];
|
||||
const matched = new Set<string>();
|
||||
|
||||
// Phase 1 — incoming.
|
||||
for (const [key, inc] of incomingByKey.entries()) {
|
||||
const prior = existingByKey.get(key);
|
||||
if (!prior) {
|
||||
const created = args.fromIncoming(inc);
|
||||
out.push({ ...created, reviewStatus: "suggested", reviewPinned: false });
|
||||
} else {
|
||||
const merged = args.mergeFields(prior, inc);
|
||||
const cur: ReviewStatus = (prior.reviewStatus ?? "accepted") as ReviewStatus;
|
||||
const nextStatus: ReviewStatus = cur === "deprecated" ? "suggested" : cur;
|
||||
out.push({ ...merged, reviewStatus: nextStatus, reviewPinned: !!prior.reviewPinned });
|
||||
matched.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2 — existing not in incoming.
|
||||
for (const e of args.existing) {
|
||||
const k = args.keyExisting(e);
|
||||
if (matched.has(k)) continue;
|
||||
const cur: ReviewStatus = (e.reviewStatus ?? "accepted") as ReviewStatus;
|
||||
if (cur === "suggested") continue; // unreviewed + dropped → drop.
|
||||
if (e.reviewPinned) {
|
||||
out.push(e);
|
||||
continue;
|
||||
}
|
||||
out.push({ ...e, reviewStatus: "deprecated" });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeText(s: string): string {
|
||||
return (s ?? "").trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function mergeProperties(
|
||||
current: { name: string }[],
|
||||
incoming: LeanProperty[]
|
||||
): LeanProperty[] {
|
||||
const have = new Set(current.map(p => p.name.toLowerCase()));
|
||||
const merged: LeanProperty[] = current.map(p => ({
|
||||
name: p.name,
|
||||
type: { kind: "string" }, // type re-expanded in caller
|
||||
}));
|
||||
for (const p of incoming) {
|
||||
if (!have.has(p.name.toLowerCase())) merged.push(p);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function expandPropertyType(type: { kind: string; values?: string[] }): PropertyType {
|
||||
if (type.kind === "enum") return { kind: "enum", values: type.values ?? [] };
|
||||
if (type.kind === "number") return { kind: "number" };
|
||||
if (type.kind === "boolean") return { kind: "boolean" };
|
||||
return { kind: "string" };
|
||||
}
|
||||
52
apps/web/lib/llm/analyze/proseText.ts
Normal file
52
apps/web/lib/llm/analyze/proseText.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Convert a ProseMirror JSON document into a plain-text string for LLM input.
|
||||
// Headings get markdown-style "##"; chip nodes render as their label so the
|
||||
// analyzer sees the same surface forms a human would.
|
||||
|
||||
import "server-only";
|
||||
|
||||
interface PMNode {
|
||||
type: string;
|
||||
text?: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: PMNode[];
|
||||
}
|
||||
|
||||
export function proseDocToPlainText(doc: unknown): string {
|
||||
if (!doc || typeof doc !== "object") return "";
|
||||
const root = doc as PMNode;
|
||||
return walk(root, 0).trim();
|
||||
}
|
||||
|
||||
function walk(node: PMNode, depth: number): string {
|
||||
if (!node) return "";
|
||||
if (node.type === "text") return node.text ?? "";
|
||||
if (node.type === "chip") {
|
||||
const label = (node.attrs?.label as string | undefined) ?? "";
|
||||
return label;
|
||||
}
|
||||
|
||||
const children = (node.content ?? []).map(c => walk(c, depth + 1)).join("");
|
||||
|
||||
switch (node.type) {
|
||||
case "heading": {
|
||||
const level = typeof node.attrs?.level === "number" ? (node.attrs.level as number) : 1;
|
||||
const hash = "#".repeat(Math.max(1, Math.min(level, 6)));
|
||||
return `\n\n${hash} ${children}\n\n`;
|
||||
}
|
||||
case "paragraph":
|
||||
return `${children}\n\n`;
|
||||
case "bulletList":
|
||||
case "bullet_list":
|
||||
case "orderedList":
|
||||
case "ordered_list":
|
||||
return `${children}\n`;
|
||||
case "listItem":
|
||||
case "list_item":
|
||||
return `- ${children.trim()}\n`;
|
||||
case "hardBreak":
|
||||
case "hard_break":
|
||||
return "\n";
|
||||
default:
|
||||
return children;
|
||||
}
|
||||
}
|
||||
104
apps/web/lib/llm/analyze/requirements.ts
Normal file
104
apps/web/lib/llm/analyze/requirements.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Analyze pass: extract requirements from prose with traceability hints.
|
||||
|
||||
import "server-only";
|
||||
import { defaultGateway, chatJSON, type Message } from "../gateway";
|
||||
import { loadPrompt } from "../prompts";
|
||||
import type { DetectedRequirement } from "../../db/repo";
|
||||
|
||||
const reqsJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["requirements"],
|
||||
properties: {
|
||||
requirements: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["tag", "text"],
|
||||
properties: {
|
||||
tag: { type: "string", minLength: 1 },
|
||||
text: { type: "string", minLength: 1 },
|
||||
tracedToLabels: { type: "array", items: { type: "string" } },
|
||||
/// Optional concept the requirement is conceptually "about." Lets the
|
||||
/// requirement render a back-link to the term in TermDetail even when
|
||||
/// the term has no formalized block yet.
|
||||
linkedTermLabel: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface RequirementsResult {
|
||||
reqs: DetectedRequirement[];
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function analyzeRequirements(
|
||||
documentText: string,
|
||||
terms: Array<{ id: string; label: string; linkedBlockId: string | null }>
|
||||
): Promise<RequirementsResult> {
|
||||
const prompt = loadPrompt("socrates/analyze-requirements.md");
|
||||
const gateway = defaultGateway();
|
||||
const userPayload =
|
||||
`Document:\n\n${documentText}\n\n---\n\n` +
|
||||
`Available term labels:\n${JSON.stringify(terms.map(t => t.label))}`;
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: prompt },
|
||||
{ role: "user", content: userPayload },
|
||||
];
|
||||
|
||||
const { value, result } = await chatJSON<{
|
||||
requirements?: Array<{
|
||||
tag?: string;
|
||||
text?: string;
|
||||
tracedToLabels?: string[];
|
||||
linkedTermLabel?: string;
|
||||
}>;
|
||||
}>(gateway, messages, {
|
||||
temperature: 0.2,
|
||||
maxTokens: 1500,
|
||||
jsonSchema: { name: "requirements", schema: reqsJsonSchema as Record<string, unknown> },
|
||||
jsonObjectMode: true,
|
||||
maxRepairs: 2,
|
||||
});
|
||||
|
||||
// Map term labels → block ids via the term-to-block links + label → termId.
|
||||
const labelToBlock = new Map<string, string>();
|
||||
const labelToTermId = new Map<string, string>();
|
||||
for (const t of terms) {
|
||||
if (t.linkedBlockId) labelToBlock.set(t.label.toLowerCase(), t.linkedBlockId);
|
||||
labelToTermId.set(t.label.toLowerCase(), t.id);
|
||||
}
|
||||
|
||||
const reqs: DetectedRequirement[] = [];
|
||||
for (const r of value?.requirements ?? []) {
|
||||
const tag = (r.tag ?? "").trim();
|
||||
const text = (r.text ?? "").trim();
|
||||
if (!tag || !text) continue;
|
||||
const blockIds = (r.tracedToLabels ?? [])
|
||||
.map(l => labelToBlock.get(l.toLowerCase()))
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const linkedTermId = r.linkedTermLabel ? labelToTermId.get(r.linkedTermLabel.toLowerCase()) : undefined;
|
||||
reqs.push({
|
||||
tag,
|
||||
text,
|
||||
tracedToIds: blockIds,
|
||||
unsupported: blockIds.length === 0,
|
||||
linkedTermId: linkedTermId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
reqs,
|
||||
inputTokens: result.inputTokens,
|
||||
outputTokens: result.outputTokens,
|
||||
provider: gateway.provider,
|
||||
model: gateway.model,
|
||||
};
|
||||
}
|
||||
206
apps/web/lib/llm/analyze/runAll.ts
Normal file
206
apps/web/lib/llm/analyze/runAll.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
// Analyze orchestrator. Runs the requested sections sequentially. Sections
|
||||
// are independent: a partial failure in one doesn't abort the rest. Each
|
||||
// section writes an AnalysisRun row for telemetry / "last-run" timestamps.
|
||||
//
|
||||
// Order matters when "all" is selected:
|
||||
// 1. concepts (taxonomy + glossary in one pass — others depend on its output)
|
||||
// 2. model (uses the term list, can populate Block/Assoc/Constraint/Req
|
||||
// linkedTermIds)
|
||||
// 3. requirements (uses term-to-block links from model)
|
||||
// 4. assumptions, risks, inconsistencies (run on the refreshed model)
|
||||
//
|
||||
// Per-section runs skip the dependency steps and use whatever's already in
|
||||
// the DB. That keeps "I just want to refresh concepts" cheap.
|
||||
|
||||
import "server-only";
|
||||
import {
|
||||
loadDocument,
|
||||
loadProject,
|
||||
replaceModel,
|
||||
startAnalysisRun,
|
||||
finishAnalysisRun,
|
||||
mergeTaxonomySuggestion,
|
||||
applyGlossaryDefinitions,
|
||||
listTerms,
|
||||
mergeRequirementsSuggestion,
|
||||
mergeFindingsSuggestion,
|
||||
linkTermToBlock,
|
||||
type AnalysisSection,
|
||||
} from "../../db/repo";
|
||||
import { proseDocToPlainText } from "./proseText";
|
||||
import { analyzeConcepts } from "./concepts";
|
||||
import { analyzeRequirements } from "./requirements";
|
||||
import { analyzeModelFromProse } from "./model";
|
||||
import { detectFindings, type Finding } from "../detect";
|
||||
import { crossValidate } from "../../sysml/crossValidate";
|
||||
|
||||
export type RunSection = AnalysisSection;
|
||||
|
||||
export interface SectionOutcome {
|
||||
section: RunSection;
|
||||
status: "succeeded" | "failed" | "skipped";
|
||||
message?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
||||
export interface AnalyzeRunResult {
|
||||
outcomes: SectionOutcome[];
|
||||
modelVersion: number;
|
||||
}
|
||||
|
||||
const ALL: RunSection[] = [
|
||||
"concepts",
|
||||
"model",
|
||||
"requirements",
|
||||
"assumptions",
|
||||
"risks",
|
||||
"inconsistencies",
|
||||
];
|
||||
|
||||
export async function runAnalyze(projectId: string, requested: RunSection): Promise<AnalyzeRunResult> {
|
||||
const sections: RunSection[] = requested === "all" ? ALL : [requested];
|
||||
|
||||
const docRow = await loadDocument(projectId);
|
||||
const documentText = docRow ? proseDocToPlainText(docRow.doc) : "";
|
||||
if (!documentText.trim()) {
|
||||
return {
|
||||
outcomes: sections.map(s => ({ section: s, status: "skipped", message: "Empty document" })),
|
||||
modelVersion: (await loadProject(projectId)).version,
|
||||
};
|
||||
}
|
||||
|
||||
const outcomes: SectionOutcome[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
const { version: modelVersion } = await loadProject(projectId);
|
||||
const runId = await startAnalysisRun(projectId, section, modelVersion);
|
||||
try {
|
||||
const out = await runSection(projectId, section, documentText);
|
||||
await finishAnalysisRun(runId, {
|
||||
status: "succeeded",
|
||||
inputTokens: out.inputTokens,
|
||||
outputTokens: out.outputTokens,
|
||||
});
|
||||
outcomes.push({ section, status: "succeeded", inputTokens: out.inputTokens, outputTokens: out.outputTokens });
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
console.error(`[analyze] ${section} failed:`, message);
|
||||
await finishAnalysisRun(runId, { status: "failed", errorMessage: message });
|
||||
outcomes.push({ section, status: "failed", message });
|
||||
}
|
||||
}
|
||||
|
||||
const finalVersion = (await loadProject(projectId)).version;
|
||||
return { outcomes, modelVersion: finalVersion };
|
||||
}
|
||||
|
||||
// ─── Per-section runners ─────────────────────────────────────────────────
|
||||
|
||||
interface RunOut {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
async function runSection(projectId: string, section: RunSection, documentText: string): Promise<RunOut> {
|
||||
switch (section) {
|
||||
case "concepts":
|
||||
return runConcepts(projectId, documentText);
|
||||
case "model":
|
||||
return runModel(projectId, documentText);
|
||||
case "requirements":
|
||||
return runRequirements(projectId, documentText);
|
||||
case "assumptions":
|
||||
case "risks":
|
||||
case "inconsistencies":
|
||||
return runFindings(projectId);
|
||||
case "all":
|
||||
throw new Error("'all' must be expanded by the caller");
|
||||
}
|
||||
}
|
||||
|
||||
async function runConcepts(projectId: string, documentText: string): Promise<RunOut> {
|
||||
const { version } = await loadProject(projectId);
|
||||
const { terms, definitions, inputTokens, outputTokens } = await analyzeConcepts(documentText);
|
||||
// Merge instead of replace — analyzer suggestions surface as a *review*.
|
||||
// The user keeps or discards each pending change; accepted state survives.
|
||||
await mergeTaxonomySuggestion(projectId, terms, version);
|
||||
if (definitions.length > 0) {
|
||||
// Definitions still apply only to "accepted" terms whose definition is
|
||||
// empty; mergeTaxonomySuggestion's gentle-update path keeps user-edited
|
||||
// definitions, but the dedicated patch is harmless for new terms.
|
||||
await applyGlossaryDefinitions(projectId, definitions);
|
||||
}
|
||||
return { inputTokens, outputTokens };
|
||||
}
|
||||
|
||||
async function runModel(projectId: string, documentText: string): Promise<RunOut> {
|
||||
const { model: current } = await loadProject(projectId);
|
||||
const terms = await listTerms(projectId);
|
||||
const { model, termIdToBlockId, inputTokens, outputTokens } = await analyzeModelFromProse(
|
||||
documentText,
|
||||
terms.map(t => ({ id: t.id, label: t.label, linkedBlockId: t.linkedBlockId })),
|
||||
current
|
||||
);
|
||||
|
||||
await replaceModel(projectId, model, "Analyze: model refresh");
|
||||
|
||||
// Update term → block linkage based on what the analyzer linked.
|
||||
for (const [termId, blockId] of termIdToBlockId.entries()) {
|
||||
await linkTermToBlock(termId, blockId);
|
||||
}
|
||||
|
||||
return { inputTokens, outputTokens };
|
||||
}
|
||||
|
||||
async function runRequirements(projectId: string, documentText: string): Promise<RunOut> {
|
||||
const { version } = await loadProject(projectId);
|
||||
const terms = await listTerms(projectId);
|
||||
const { reqs, inputTokens, outputTokens } = await analyzeRequirements(
|
||||
documentText,
|
||||
terms.map(t => ({ id: t.id, label: t.label, linkedBlockId: t.linkedBlockId }))
|
||||
);
|
||||
await mergeRequirementsSuggestion(projectId, reqs, version);
|
||||
return { inputTokens, outputTokens };
|
||||
}
|
||||
|
||||
async function runFindings(projectId: string): Promise<RunOut> {
|
||||
const { model, version } = await loadProject(projectId);
|
||||
const result = await detectFindings(model);
|
||||
|
||||
// Cross-layer validation (T2): glue rules between terms and ontology.
|
||||
// Surfaced as inconsistency-kind findings keyed by validationCode = X*.
|
||||
const terms = await listTerms(projectId);
|
||||
const cross = crossValidate({
|
||||
model,
|
||||
terms: terms.map(t => ({ id: t.id, label: t.label, linkedBlockId: t.linkedBlockId })),
|
||||
// X4 needs prose-occurrence counts; left undefined here so it stays
|
||||
// off until we wire prose extraction. X1–X3 still fire.
|
||||
});
|
||||
const crossFindings: Finding[] = cross.map(issue => ({
|
||||
kind: "inconsistency",
|
||||
text: issue.message,
|
||||
linkedElementIds: anchorIds(issue.anchor),
|
||||
confidence: 1.0,
|
||||
severity: issue.severity === "warning" ? "medium" : "low",
|
||||
validationCode: issue.code,
|
||||
}));
|
||||
|
||||
await mergeFindingsSuggestion(
|
||||
projectId,
|
||||
[...result.findings, ...crossFindings],
|
||||
version,
|
||||
result.provider,
|
||||
result.model
|
||||
);
|
||||
return { inputTokens: result.inputTokens, outputTokens: result.outputTokens };
|
||||
}
|
||||
|
||||
function anchorIds(a: import("../../sysml/validate").IssueAnchor): string[] {
|
||||
if (a.kind === "model") return [];
|
||||
if (a.kind === "property") return [a.blockId];
|
||||
// T3: prefix term anchors with `term:` so consumers (FindingsPane,
|
||||
// TermDetail) can route them to the concept popover instead of the model.
|
||||
if (a.kind === "term") return [`term:${a.id}`];
|
||||
return [a.id];
|
||||
}
|
||||
81
apps/web/lib/llm/analyze/taxonomy.ts
Normal file
81
apps/web/lib/llm/analyze/taxonomy.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// Analyze pass: extract taxonomy terms from prose.
|
||||
|
||||
import "server-only";
|
||||
import { defaultGateway, chatJSON, type Message } from "../gateway";
|
||||
import { loadPrompt } from "../prompts";
|
||||
import type { DetectedTerm } from "../../db/repo";
|
||||
|
||||
interface RawTerm {
|
||||
label?: string;
|
||||
parentLabel?: string | null;
|
||||
synonyms?: string[];
|
||||
}
|
||||
|
||||
const taxonomyJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["terms"],
|
||||
properties: {
|
||||
terms: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["label"],
|
||||
properties: {
|
||||
label: { type: "string", minLength: 1 },
|
||||
parentLabel: { type: ["string", "null"] },
|
||||
synonyms: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface TaxonomyResult {
|
||||
terms: DetectedTerm[];
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function analyzeTaxonomy(documentText: string): Promise<TaxonomyResult> {
|
||||
const prompt = loadPrompt("socrates/analyze-taxonomy.md");
|
||||
const gateway = defaultGateway();
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: prompt },
|
||||
{ role: "user", content: `Document:\n\n${documentText}` },
|
||||
];
|
||||
|
||||
const { value, result } = await chatJSON<{ terms?: RawTerm[] }>(gateway, messages, {
|
||||
temperature: 0.2,
|
||||
maxTokens: 1500,
|
||||
jsonSchema: { name: "taxonomy", schema: taxonomyJsonSchema as Record<string, unknown> },
|
||||
jsonObjectMode: true,
|
||||
maxRepairs: 2,
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const terms: DetectedTerm[] = [];
|
||||
for (const t of value?.terms ?? []) {
|
||||
const label = (t.label ?? "").trim();
|
||||
if (!label) continue;
|
||||
const key = label.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
terms.push({
|
||||
label,
|
||||
parentLabel: t.parentLabel?.trim() || null,
|
||||
synonyms: Array.isArray(t.synonyms) ? t.synonyms.filter(s => typeof s === "string") : [],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
terms,
|
||||
inputTokens: result.inputTokens,
|
||||
outputTokens: result.outputTokens,
|
||||
provider: gateway.provider,
|
||||
model: gateway.model,
|
||||
};
|
||||
}
|
||||
275
apps/web/lib/llm/generateModel.ts
Normal file
275
apps/web/lib/llm/generateModel.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
// Seed → SysMLModel via the Phase-0-validated generate prompt.
|
||||
//
|
||||
// Returns a validated, expanded SysMLModel with property ids, multiplicities,
|
||||
// stereotypes filled in. The LLM emits a "lean" shape; we expand it to match
|
||||
// the canonical lib/sysml/model.ts types.
|
||||
|
||||
import "server-only";
|
||||
import { defaultGateway, chatJSON, type Message } from "./gateway";
|
||||
import { loadPrompt } from "./prompts";
|
||||
import type {
|
||||
SysMLModel,
|
||||
Block,
|
||||
Association,
|
||||
Constraint,
|
||||
Requirement,
|
||||
Property,
|
||||
PropertyType,
|
||||
} from "../sysml/model";
|
||||
import type { SeedDraft } from "./seedInterview";
|
||||
|
||||
// ─── LLM-facing lean shape (matches Phase 0's generate.md output) ───────
|
||||
|
||||
interface LeanProperty {
|
||||
name: string;
|
||||
type: { kind: "string" | "number" | "boolean" | "enum"; values?: string[] };
|
||||
}
|
||||
interface LeanBlock {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "system" | "actor" | "block";
|
||||
properties?: LeanProperty[];
|
||||
confidence: number;
|
||||
}
|
||||
interface LeanAssociation {
|
||||
id: string;
|
||||
fromBlockId: string;
|
||||
toBlockId: string;
|
||||
label?: string;
|
||||
kind: "association" | "composition" | "aggregation" | "generalization" | "constraintApplies";
|
||||
confidence: number;
|
||||
}
|
||||
interface LeanConstraint {
|
||||
id: string;
|
||||
label: string;
|
||||
expression?: string;
|
||||
appliesTo?: string[];
|
||||
confidence: number;
|
||||
}
|
||||
interface LeanRequirement {
|
||||
id: string;
|
||||
tag: string;
|
||||
text: string;
|
||||
satisfiedBy?: string[];
|
||||
confidence: number;
|
||||
}
|
||||
interface LeanModel {
|
||||
systemOfInterestId?: string;
|
||||
blocks: LeanBlock[];
|
||||
associations?: LeanAssociation[];
|
||||
constraints?: LeanConstraint[];
|
||||
requirements?: LeanRequirement[];
|
||||
overallConfidence?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// ─── JSON Schema for response_format ─────────────────────────────────────
|
||||
|
||||
const generateJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
systemOfInterestId: { type: "string" },
|
||||
blocks: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "label", "kind", "confidence"],
|
||||
properties: {
|
||||
id: { type: "string", minLength: 1 },
|
||||
label: { type: "string", minLength: 1 },
|
||||
kind: { type: "string", enum: ["system", "actor", "block"] },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
properties: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["name", "type"],
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1 },
|
||||
type: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["kind"],
|
||||
properties: {
|
||||
kind: { type: "string", enum: ["string", "number", "boolean", "enum"] },
|
||||
values: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
associations: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "fromBlockId", "toBlockId", "kind", "confidence"],
|
||||
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", "constraintApplies"] },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
constraints: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "label", "confidence"],
|
||||
properties: {
|
||||
id: { type: "string", minLength: 1 },
|
||||
label: { type: "string", minLength: 1 },
|
||||
expression: { type: "string" },
|
||||
appliesTo: { type: "array", items: { type: "string" } },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
requirements: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "tag", "text", "confidence"],
|
||||
properties: {
|
||||
id: { type: "string", minLength: 1 },
|
||||
tag: { type: "string", minLength: 1 },
|
||||
text: { type: "string", minLength: 1 },
|
||||
satisfiedBy: { type: "array", items: { type: "string" } },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
overallConfidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
notes: { type: "string" },
|
||||
},
|
||||
required: ["blocks"],
|
||||
} as const;
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface GenerateModelResult {
|
||||
model: SysMLModel;
|
||||
overallConfidence: number;
|
||||
notes?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
provider: string;
|
||||
model_name: string;
|
||||
}
|
||||
|
||||
export async function generateModelFromSeed(seed: SeedDraft): Promise<GenerateModelResult> {
|
||||
const character = loadPrompt("socrates/character.md");
|
||||
const generate = loadPrompt("socrates/generate.md");
|
||||
|
||||
const userPayload =
|
||||
`Seed payload to model:\n\`\`\`json\n${JSON.stringify(seed, null, 2)}\n\`\`\`\n` +
|
||||
`Return only the JSON object conforming to the schema. No prose preamble, no code fences.`;
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: `${character}\n\n---\n\n${generate}` },
|
||||
{ role: "user", content: userPayload },
|
||||
];
|
||||
|
||||
const gateway = defaultGateway();
|
||||
const { value, result } = await chatJSON<LeanModel>(gateway, messages, {
|
||||
temperature: 0.3,
|
||||
maxTokens: 2048,
|
||||
jsonSchema: { name: "sysml_model", schema: generateJsonSchema as Record<string, unknown> },
|
||||
jsonObjectMode: true,
|
||||
maxRepairs: 2,
|
||||
});
|
||||
|
||||
const model = expand(value);
|
||||
|
||||
return {
|
||||
model,
|
||||
overallConfidence: clamp01(value?.overallConfidence ?? 0.5),
|
||||
notes: value?.notes,
|
||||
inputTokens: result.inputTokens,
|
||||
outputTokens: result.outputTokens,
|
||||
provider: gateway.provider,
|
||||
model_name: gateway.model,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Lean → canonical SysMLModel ────────────────────────────────────────
|
||||
|
||||
function expand(lean: LeanModel | null | undefined): SysMLModel {
|
||||
if (!lean || !Array.isArray(lean.blocks)) {
|
||||
return { blocks: [], associations: [], constraints: [], requirements: [] };
|
||||
}
|
||||
|
||||
const blocks: Block[] = lean.blocks.map(b => ({
|
||||
id: b.id,
|
||||
label: b.label,
|
||||
kind: b.kind,
|
||||
stereotypes: [b.kind],
|
||||
properties: (b.properties ?? []).map((p, i) => ({
|
||||
id: `${b.id}_p${i + 1}`,
|
||||
name: p.name,
|
||||
type: expandPropertyType(p.type),
|
||||
multiplicity: "0..1" as const,
|
||||
})),
|
||||
}));
|
||||
|
||||
const associations: Association[] = (lean.associations ?? []).map(a => ({
|
||||
id: a.id,
|
||||
fromBlockId: a.fromBlockId,
|
||||
toBlockId: a.toBlockId,
|
||||
label: a.label ?? "",
|
||||
kind: a.kind,
|
||||
}));
|
||||
|
||||
const constraints: Constraint[] = (lean.constraints ?? []).map(c => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
expression: c.expression ?? "",
|
||||
appliesTo: c.appliesTo ?? [],
|
||||
}));
|
||||
|
||||
const requirements: Requirement[] = (lean.requirements ?? []).map(r => ({
|
||||
id: r.id,
|
||||
tag: r.tag,
|
||||
text: r.text,
|
||||
relations: (r.satisfiedBy ?? []).map(blockId => ({ kind: "satisfy" as const, blockId })),
|
||||
}));
|
||||
|
||||
// Resolve SoI: prefer the LLM-supplied id if it exists; otherwise the unique kind:'system' block.
|
||||
let soiId = lean.systemOfInterestId;
|
||||
if (!soiId) {
|
||||
const systems = blocks.filter(b => b.kind === "system");
|
||||
if (systems.length === 1) soiId = systems[0]!.id;
|
||||
}
|
||||
|
||||
return {
|
||||
systemOfInterestId: soiId,
|
||||
blocks,
|
||||
associations,
|
||||
constraints,
|
||||
requirements,
|
||||
};
|
||||
}
|
||||
|
||||
function expandPropertyType(type: { kind: string; values?: string[] }): PropertyType {
|
||||
if (type.kind === "enum") return { kind: "enum", values: type.values ?? [] };
|
||||
if (type.kind === "number") return { kind: "number" };
|
||||
if (type.kind === "boolean") return { kind: "boolean" };
|
||||
return { kind: "string" };
|
||||
}
|
||||
function clamp01(n: number): number {
|
||||
if (Number.isNaN(n)) return 0;
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
// Suppress unused-import warning in some TS configs.
|
||||
type _Property = Property;
|
||||
56
apps/web/lib/llm/prompts/socrates/analyze-concepts.md
Normal file
56
apps/web/lib/llm/prompts/socrates/analyze-concepts.md
Normal file
@@ -0,0 +1,56 @@
|
||||
You are extracting **concepts** from a product-thinking document — a piece of structured prose written by a product manager describing an idea. The output combines what we used to call "taxonomy" (terms + hierarchy) and "glossary" (definitions) into one pass: a single emit keeps definitions in lockstep with the hierarchy.
|
||||
|
||||
## Goal
|
||||
|
||||
Identify the **distinct concepts** the document refers to — entities, actors, artifacts, processes, attributes — arrange them into a parent/child hierarchy when one is clearly implied, and write a short definition for each.
|
||||
|
||||
## What counts as a term
|
||||
|
||||
- A noun phrase that names a recurring concept ("Tutor", "Lesson Plan", "Skill Tree").
|
||||
- An actor or stakeholder ("Student", "Parent", "Curriculum Designer").
|
||||
- A domain artifact ("Quiz", "Progress Report").
|
||||
- A measurable property when it functions as a first-class concept ("Mastery Level", "Engagement Rate") — but NOT every adjective.
|
||||
|
||||
## What does NOT count
|
||||
|
||||
- Generic English words ("user", "system", "thing") unless the document uses them with a specific meaning.
|
||||
- Adjectives, adverbs, verbs, or transient phrases.
|
||||
- Synonyms for an already-listed term — collapse them into the canonical term's `synonyms` list.
|
||||
|
||||
## Hierarchy rules
|
||||
|
||||
- Use `parentLabel` only when the document explicitly says or strongly implies the child is-a-kind-of the parent (subset, specialization), or part-of-and-defining-feature-of.
|
||||
- Do NOT invent hierarchies that aren't in the document.
|
||||
- A term may have no parent. Most should.
|
||||
|
||||
## Definition rules
|
||||
|
||||
- 1–2 sentences, ≤ 35 words.
|
||||
- Phrased as a noun-phrase definition, not a sentence about the term ("A student-facing agent that …", not "The Tutor is …").
|
||||
- Use only what the document actually says or strongly implies. Do not import outside knowledge.
|
||||
- If the document does not give enough to define the term, return an empty string for that term — do not guess.
|
||||
|
||||
## Output
|
||||
|
||||
Return a single JSON object with this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"terms": [
|
||||
{
|
||||
"label": "Tutor",
|
||||
"parentLabel": null,
|
||||
"synonyms": ["AI tutor", "tutor agent"],
|
||||
"definition": "A student-facing agent that guides a learner through Socratic questioning toward a curriculum goal."
|
||||
},
|
||||
{
|
||||
"label": "Socratic Tutor",
|
||||
"parentLabel": "Tutor",
|
||||
"synonyms": [],
|
||||
"definition": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Return ONLY the JSON object. No prose. No code fences.
|
||||
28
apps/web/lib/llm/prompts/socrates/analyze-glossary.md
Normal file
28
apps/web/lib/llm/prompts/socrates/analyze-glossary.md
Normal file
@@ -0,0 +1,28 @@
|
||||
You are writing **glossary definitions** for a list of terms extracted from a product-thinking document. The user will read these definitions in a sidebar and click to jump to the first occurrence in the document.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The full prose document.
|
||||
- A list of taxonomy terms (each with an optional parent and synonyms).
|
||||
|
||||
## Output
|
||||
|
||||
For each term, write a **short, document-grounded** definition:
|
||||
|
||||
- 1–2 sentences, ≤ 35 words.
|
||||
- Phrased as a noun-phrase definition, not a sentence about the term ("A student-facing agent that …", not "The Tutor is …").
|
||||
- Use only what the document actually says or strongly implies. Do not import outside knowledge.
|
||||
- If the document does not give enough to define the term, return an empty string for that term — do not guess.
|
||||
|
||||
Return a single JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"definitions": [
|
||||
{ "label": "Tutor", "definition": "A student-facing agent that guides a learner through Socratic questioning toward a curriculum goal." },
|
||||
{ "label": "Skill Tree", "definition": "" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Return ONLY the JSON object. No prose. No code fences.
|
||||
70
apps/web/lib/llm/prompts/socrates/analyze-model.md
Normal file
70
apps/web/lib/llm/prompts/socrates/analyze-model.md
Normal file
@@ -0,0 +1,70 @@
|
||||
You are deriving a **SysML model** from a product-thinking document and a known taxonomy of terms. The output backs a visual ontology canvas the user will edit.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The prose document.
|
||||
- The taxonomy term list — each label is a candidate block.
|
||||
|
||||
## What to produce
|
||||
|
||||
A lean SysML model with **blocks** (and optionally associations, constraints, requirements). Prefer a small, accurate model over a large, speculative one.
|
||||
|
||||
### Blocks
|
||||
|
||||
- Use the taxonomy as the *primary* source of block candidates. Most blocks should correspond to a taxonomy term.
|
||||
- `kind`: `"system"` for the overall thing being designed, `"actor"` for human/external roles, `"block"` for everything else.
|
||||
- Reuse the term's exact `label` as the block label.
|
||||
- Set `linkedTermLabel` to the matching term so we can mark it as linked in the sidebar. Use the same spelling as the term list.
|
||||
- A block is allowed without a taxonomy term only when the document clearly implies it but no term was extracted (rare).
|
||||
|
||||
### Associations
|
||||
|
||||
- Only include associations the document actually describes. No speculation.
|
||||
- Verb phrase labels: `"guides"`, `"contains"`, `"reports_to"`.
|
||||
- Optional `linkedTermLabel`: when the relationship itself is named in the
|
||||
taxonomy as a concept (e.g. there's a term "Mentorship" and the association
|
||||
is "Tutor mentors Student"), set it. Most associations don't have one.
|
||||
|
||||
### Constraints
|
||||
|
||||
- Optional `linkedTermLabel`: when the constraint is a concept on its own (e.g.
|
||||
the term "Daily Cap" and the constraint is `sessions_per_day <= 3`), set it.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Optional `linkedTermLabel`: when the requirement is fundamentally *about* a
|
||||
single taxonomy term (e.g. REQ-001 is about Personalization), set it. This
|
||||
is distinct from `satisfiedBy` (which links to blocks the requirement
|
||||
formalizes against).
|
||||
|
||||
### Confidence
|
||||
|
||||
Score `confidence` ∈ [0, 1] honestly. Things straight from the prose: 0.8+. Reasonable inferences: 0.5–0.7. Speculation: leave it out.
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"systemOfInterestId": "tutor",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "tutor",
|
||||
"label": "Tutor",
|
||||
"kind": "system",
|
||||
"linkedTermLabel": "Tutor",
|
||||
"confidence": 0.95,
|
||||
"properties": [
|
||||
{ "name": "personality", "type": { "kind": "enum", "values": ["socratic", "encouraging"] } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"associations": [
|
||||
{ "id": "a1", "fromBlockId": "tutor", "toBlockId": "student", "label": "guides", "kind": "association", "confidence": 0.9 }
|
||||
],
|
||||
"constraints": [],
|
||||
"requirements": [],
|
||||
"overallConfidence": 0.8
|
||||
}
|
||||
```
|
||||
|
||||
Return ONLY the JSON object. No prose. No code fences.
|
||||
37
apps/web/lib/llm/prompts/socrates/analyze-requirements.md
Normal file
37
apps/web/lib/llm/prompts/socrates/analyze-requirements.md
Normal file
@@ -0,0 +1,37 @@
|
||||
You are extracting **requirements** from a product-thinking document. The document was written by a product manager describing what they want to build.
|
||||
|
||||
## What counts as a requirement
|
||||
|
||||
- A statement that says the system *must*, *should*, *needs to*, or otherwise commits to a behavior or property.
|
||||
- A success criterion the document explicitly names (e.g. "the user must be able to …").
|
||||
- A hard constraint phrased as a property of the system ("response time under 500ms", "free for students").
|
||||
|
||||
## What does NOT count
|
||||
|
||||
- General descriptions of the idea or domain context.
|
||||
- Aspirational vision statements without an actionable bar ("we want to change education").
|
||||
- Open questions, hypotheses, or assumptions.
|
||||
|
||||
## Output
|
||||
|
||||
Return a single JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"requirements": [
|
||||
{
|
||||
"tag": "REQ-001",
|
||||
"text": "The tutor must adapt difficulty to the learner's measured mastery within 3 turns.",
|
||||
"tracedToLabels": ["Tutor", "Mastery Level"],
|
||||
"linkedTermLabel": "Tutor"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Tags are sequential `REQ-NNN` starting at `REQ-001`.
|
||||
- `tracedToLabels` lists the **taxonomy term labels** (provided in the user payload) this requirement is about. Use exactly the spellings from the term list. Empty array is allowed if no term clearly applies — those will be flagged as unsupported.
|
||||
- `linkedTermLabel` (optional) names the **single concept the requirement is fundamentally about** — pick one from the term list when one clearly stands out. Use this for the requirement's primary anchor (the others belong in `tracedToLabels`). Omit when no single concept dominates.
|
||||
- Keep the `text` short and imperative; quote/paraphrase the document, do not invent.
|
||||
|
||||
Return ONLY the JSON object. No prose. No code fences.
|
||||
47
apps/web/lib/llm/prompts/socrates/analyze-taxonomy.md
Normal file
47
apps/web/lib/llm/prompts/socrates/analyze-taxonomy.md
Normal file
@@ -0,0 +1,47 @@
|
||||
You are extracting a **taxonomy** from a product-thinking document. The document is a piece of structured prose written by a product manager describing an idea.
|
||||
|
||||
## Goal
|
||||
|
||||
Identify the **distinct concepts** the document refers to — entities, actors, artifacts, processes, attributes — and arrange them into a parent/child hierarchy when one is clearly implied by the prose.
|
||||
|
||||
## What counts as a term
|
||||
|
||||
- A noun phrase that names a recurring concept ("Tutor", "Lesson Plan", "Skill Tree").
|
||||
- An actor or stakeholder ("Student", "Parent", "Curriculum Designer").
|
||||
- A domain artifact ("Quiz", "Progress Report").
|
||||
- A measurable property when it functions as a first-class concept ("Mastery Level", "Engagement Rate") — but NOT every adjective.
|
||||
|
||||
## What does NOT count
|
||||
|
||||
- Generic English words ("user", "system", "thing") unless the document uses them with a specific meaning.
|
||||
- Adjectives, adverbs, verbs, or transient phrases.
|
||||
- Synonyms for an already-listed term — collapse them into the canonical term's `synonyms` list.
|
||||
|
||||
## Hierarchy rules
|
||||
|
||||
- Use `parentLabel` only when the document explicitly says or strongly implies the child is-a-kind-of the parent (subset, specialization), or part-of-and-defining-feature-of.
|
||||
- Do NOT invent hierarchies that aren't in the document.
|
||||
- A term may have no parent. Most should.
|
||||
|
||||
## Output
|
||||
|
||||
Return a single JSON object with this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"terms": [
|
||||
{
|
||||
"label": "Tutor",
|
||||
"parentLabel": null,
|
||||
"synonyms": ["AI tutor", "tutor agent"]
|
||||
},
|
||||
{
|
||||
"label": "Socratic Tutor",
|
||||
"parentLabel": "Tutor",
|
||||
"synonyms": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Return ONLY the JSON object, no prose, no code fences.
|
||||
120
apps/web/lib/llm/prompts/socrates/generate.md
Normal file
120
apps/web/lib/llm/prompts/socrates/generate.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Generate a SysML-shaped product model from a seed idea
|
||||
|
||||
You are an analyst who turns a product manager's seed idea into a structured systems-engineering model.
|
||||
|
||||
## Input
|
||||
You will receive a seed payload as JSON with these fields:
|
||||
- `problem` — the user-named problem (1–3 sentences)
|
||||
- `targetUser` — who experiences the problem
|
||||
- `desiredOutcome` — what success looks like
|
||||
- `initialHypothesis` — optional belief about adoption or mechanism
|
||||
- `constraints` — optional list of explicit non-negotiable rules (literal strings)
|
||||
|
||||
## Output structure — FOUR distinct top-level arrays
|
||||
|
||||
You must populate ALL FOUR of these arrays when the seed supports it. Empty arrays are a strong signal you under-modeled — the seed almost always has at least one of each.
|
||||
|
||||
1. **`blocks`** — entities (kinds: `system`, `actor`, `block`). The thing being built and the things it interacts with or reasons about.
|
||||
2. **`associations`** — labeled relationships between blocks. Verb phrases like `consults`, `enrolled_in`, `scoped_to`.
|
||||
3. **`constraints`** — non-negotiable invariants the system must obey. Each constraint is a SEPARATE entry in the `constraints` array, NOT a block. Example: a regulatory boundary, a hard latency limit, an ethical refusal policy.
|
||||
4. **`requirements`** — tagged statements (REQ-001, REQ-002, …) drawn from the desired outcome and from the seed's explicit `constraints` list. Each requirement lists which block(s) satisfy it.
|
||||
|
||||
## Rules
|
||||
|
||||
**System of Interest (SoI):** Exactly one block has `kind: "system"`. Name it after the *thing being built*, not the problem. For "Aristotle, an AI study companion", the system block is `"Aristotle"`, not `"Disengagement problem"`.
|
||||
|
||||
**Actors:** People or external systems that interact with the SoI. `kind: "actor"`.
|
||||
|
||||
**Blocks:** Things the system reasons about that aren't actors. `kind: "block"`.
|
||||
|
||||
**Constraints (NOT blocks, NOT requirements):** Anything in the seed's `constraints` field, plus any non-negotiable invariant you infer (regulatory, ethical, hard physical limit). Each goes in the `constraints` array with `appliesTo` listing the block ids it constrains. Often `appliesTo` is just the SoI.
|
||||
|
||||
**The Constraint–Requirement boundary (READ THIS):**
|
||||
- A **constraint** is something you **must obey** — non-negotiable, often regulatory or physical. You don't choose to satisfy it; you obey it or you don't ship. Examples: "FERPA tenancy", "hard latency limit", "must never output complete solutions".
|
||||
- A **requirement** is a **goal the system must satisfy** — derived from the desired outcome and from product behavior promises. Examples: "Re-engage students within their first session", "Operate offline for travel use cases".
|
||||
|
||||
**Each item from `seed.constraints` belongs in EXACTLY ONE place — the `constraints` array.** Do NOT also output it as a requirement. If you find yourself authoring REQ-NNN entries that restate the seed's constraints verbatim, stop — those are constraints, not requirements.
|
||||
|
||||
The `requirements` array should contain things derived from `seed.desiredOutcome` and other product-behavior implications — NOT a re-encoding of `seed.constraints`.
|
||||
|
||||
**Associations:**
|
||||
- `association` — generic verb-phrase relationship (default).
|
||||
- `composition` — whole-part. Use ONLY when X is *literally part of* Y.
|
||||
- `generalization` — is-a. Rarely needed for product ideas.
|
||||
- `constraintApplies` — links a constraint to the block(s) it constrains. ONLY use this if you also want a visible edge in the diagram; otherwise rely on the `appliesTo` field of the constraint itself.
|
||||
|
||||
**Requirements:** Each gets a tag like `REQ-001`. Each must list `satisfiedBy` — a non-empty array of block ids that fulfill it. **Derive requirements from `seed.desiredOutcome`, not from `seed.constraints`** (constraints have their own array). Aim for 1–4 requirements unless the seed clearly demands more.
|
||||
|
||||
**Vague desired-outcome rule:** If `seed.desiredOutcome` is too vague to derive specific requirements (e.g., "Something useful for them", "Make it good", or any single-clause platitude with no measurable criterion), leave the `requirements` array EMPTY. Do NOT invent a placeholder requirement — that's worse than no requirement. The same vagueness signal should drive `overallConfidence` below 0.3.
|
||||
|
||||
**Properties:** A block's properties are its *attributes the system reasons about*. Keep to 1–4 per block. Types: `string`, `number`, `boolean`, or `enum` (with `values`).
|
||||
|
||||
## Confidence — under-suggest rather than over-suggest
|
||||
|
||||
Per element, set a `confidence` in `[0, 1]`:
|
||||
- Seed's explicit nouns → high confidence (≥ 0.85)
|
||||
- Inferred-but-clearly-implied → medium (0.5–0.8)
|
||||
- Speculative → low (< 0.5) and **generally omit**
|
||||
|
||||
A clean, sparse, correct model beats a dense fabricated one. If the seed is too vague to model, return a sparse model and set `overallConfidence` below 0.3.
|
||||
|
||||
## ID conventions
|
||||
|
||||
- Block ids: lowercase snake_case from labels. `"Aristotle"` → `"aristotle"`. `"Coursework Material"` → `"coursework_material"`.
|
||||
- Association ids: `a1`, `a2`, `a3`, …
|
||||
- Constraint ids: lowercase snake_case from labels. `"FERPA boundary"` → `"ferpa_boundary"`.
|
||||
- Requirement ids: lowercase tag with hyphen replaced. `REQ-001` → `"req_001"`.
|
||||
|
||||
## Worked example
|
||||
|
||||
Given a seed about a personal recipe scrapbook that pulls from cooking blogs:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemOfInterestId": "scrapbook",
|
||||
"blocks": [
|
||||
{ "id": "scrapbook", "label": "Scrapbook", "kind": "system",
|
||||
"properties": [
|
||||
{ "name": "private_collection", "type": { "kind": "boolean" } }
|
||||
],
|
||||
"confidence": 0.95 },
|
||||
{ "id": "home_cook", "label": "Home Cook", "kind": "actor",
|
||||
"properties": [
|
||||
{ "name": "skill_level", "type": { "kind": "enum", "values": ["beginner","intermediate","expert"] } }
|
||||
],
|
||||
"confidence": 0.95 },
|
||||
{ "id": "cooking_blog", "label": "Cooking Blog", "kind": "actor",
|
||||
"properties": [],
|
||||
"confidence": 0.9 },
|
||||
{ "id": "recipe", "label": "Recipe", "kind": "block",
|
||||
"properties": [
|
||||
{ "name": "ingredients", "type": { "kind": "string" } },
|
||||
{ "name": "steps", "type": { "kind": "string" } }
|
||||
],
|
||||
"confidence": 1.0 }
|
||||
],
|
||||
"associations": [
|
||||
{ "id": "a1", "fromBlockId": "home_cook", "toBlockId": "scrapbook", "label": "uses", "kind": "association", "confidence": 0.95 },
|
||||
{ "id": "a2", "fromBlockId": "scrapbook", "toBlockId": "cooking_blog", "label": "imports_from", "kind": "association", "confidence": 0.9 },
|
||||
{ "id": "a3", "fromBlockId": "scrapbook", "toBlockId": "recipe", "label": "contains", "kind": "composition", "confidence": 1.0 }
|
||||
],
|
||||
"constraints": [
|
||||
{ "id": "copyright_respect", "label": "Copyright respect", "expression": "must not republish recipes outside the user's private collection",
|
||||
"appliesTo": ["scrapbook"], "confidence": 0.85 }
|
||||
],
|
||||
"requirements": [
|
||||
{ "id": "req_001", "tag": "REQ-001", "text": "Imports a recipe from a URL in under 5 seconds",
|
||||
"satisfiedBy": ["scrapbook"], "confidence": 0.9 },
|
||||
{ "id": "req_002", "tag": "REQ-002", "text": "Stores recipes in the user's private collection only",
|
||||
"satisfiedBy": ["scrapbook"], "confidence": 1.0 }
|
||||
],
|
||||
"overallConfidence": 0.85,
|
||||
"notes": "The Scrapbook is the SoI; home cook and cooking blog are actors; recipes are first-class blocks."
|
||||
}
|
||||
```
|
||||
|
||||
Notice every array is populated. No constraints in `blocks`. Requirements name specific block satisfiers.
|
||||
|
||||
## Now generate
|
||||
|
||||
Return ONLY the JSON object for the seed you receive. No prose, no code fences. Use the four arrays — fill all of them.
|
||||
53
apps/web/lib/llm/prompts/socrates/interview.md
Normal file
53
apps/web/lib/llm/prompts/socrates/interview.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Mode: Seed interview (live, per-turn)
|
||||
|
||||
You are conducting an opening interview with a product manager who is starting a new idea inside Socrata. Goal: produce a `SeedDraft` (problem, target user, desired outcome, optional initial hypothesis, optional constraints) that's specific enough to generate a coherent SysML model from.
|
||||
|
||||
You are stateful across turns — each call gives you the running thread + the draft you've extracted so far. Improve the draft, ask the next question, and signal when there's enough to proceed.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- **Maximum 5 user turns total** before you mark the interview ready. Don't drag it out.
|
||||
- Each turn: ask exactly ONE question. Don't pile.
|
||||
- Question 1: the problem in one sentence — the smallest, most honest version.
|
||||
- Question 2: target user, with a specificity probe ("which users, why now").
|
||||
- Question 3: desired outcome — what changes when this exists.
|
||||
- Question 4: a tension probe — name a likely tension you see and ask which side they're on.
|
||||
- Question 5: explicit constraints — anything regulatory, ethical, technical that's non-negotiable.
|
||||
|
||||
## Updating the draft
|
||||
|
||||
- Each turn, update the draft fields based on what you've learned. Use the user's own register where possible.
|
||||
- Leave a field empty (`""`) until the user has actually addressed it. Don't fabricate.
|
||||
- `confidence` (0..1) is your honest read on whether the draft is specific enough to generate a useful model. Generic platitudes → low. Specific, falsifiable → high.
|
||||
|
||||
## Ready signal
|
||||
|
||||
Set `ready: true` when:
|
||||
- All five core fields (problem, targetUser, desiredOutcome) are populated AND specific, OR
|
||||
- 5 user turns have elapsed AND the draft has at least problem + targetUser + desiredOutcome.
|
||||
|
||||
When `ready: true`, your `text` should be a brief synthesis ("Here's what I understand…") plus an explicit "Ready to generate the initial model — say go or refine.", not another question.
|
||||
|
||||
## Voice
|
||||
|
||||
Per character.md. Question-led, economical, no filler. Press for specificity if an answer is vague — "what specifically does X mean here?" beats "tell me more".
|
||||
|
||||
## Output schema (strict)
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "string (1–3 sentences)",
|
||||
"draft": {
|
||||
"title": "string (a working name for the project)",
|
||||
"problem": "string",
|
||||
"targetUser": "string",
|
||||
"desiredOutcome": "string",
|
||||
"initialHypothesis": "string (optional)",
|
||||
"constraints": ["string"]
|
||||
},
|
||||
"confidence": 0.0,
|
||||
"ready": false
|
||||
}
|
||||
```
|
||||
|
||||
Return ONLY the JSON object. No prose preamble, no code fences.
|
||||
150
apps/web/lib/llm/seedInterview.ts
Normal file
150
apps/web/lib/llm/seedInterview.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// Live seed interview — per-turn handler.
|
||||
//
|
||||
// Stateless on the server: the client passes the running thread + the draft
|
||||
// extracted so far, plus the user's latest reply. We call the LLM with the
|
||||
// character + interview prompts and get back { assistant turn, updated draft,
|
||||
// confidence, ready }.
|
||||
|
||||
import "server-only";
|
||||
import { defaultGateway, chatJSON, type Message } from "./gateway";
|
||||
import { loadPrompt } from "./prompts";
|
||||
|
||||
export interface SeedDraft {
|
||||
title: string;
|
||||
problem: string;
|
||||
targetUser: string;
|
||||
desiredOutcome: string;
|
||||
initialHypothesis?: string;
|
||||
constraints?: string[];
|
||||
}
|
||||
|
||||
export interface InterviewTurn {
|
||||
role: "socrates" | "user";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface InterviewStepArgs {
|
||||
/** Running interview history. Empty array on the first call → Socrates opens. */
|
||||
history: InterviewTurn[];
|
||||
/** Latest user reply (empty string for the opening turn). */
|
||||
userText: string;
|
||||
/** Draft extracted so far. Empty on the first call. */
|
||||
draft: SeedDraft;
|
||||
}
|
||||
|
||||
export interface InterviewStepResult {
|
||||
text: string;
|
||||
draft: SeedDraft;
|
||||
confidence: number;
|
||||
ready: boolean;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
const interviewJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["text", "draft", "confidence", "ready"],
|
||||
properties: {
|
||||
text: { type: "string", minLength: 1 },
|
||||
draft: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["title", "problem", "targetUser", "desiredOutcome"],
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
problem: { type: "string" },
|
||||
targetUser: { type: "string" },
|
||||
desiredOutcome: { type: "string" },
|
||||
initialHypothesis: { type: "string" },
|
||||
constraints: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
ready: { type: "boolean" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface RawResponse {
|
||||
text?: string;
|
||||
draft?: Partial<SeedDraft>;
|
||||
confidence?: number;
|
||||
ready?: boolean;
|
||||
}
|
||||
|
||||
export async function interviewStep(args: InterviewStepArgs): Promise<InterviewStepResult> {
|
||||
const character = loadPrompt("socrates/character.md");
|
||||
const interview = loadPrompt("socrates/interview.md");
|
||||
|
||||
const userTurnsSoFar = args.history.filter(t => t.role === "user").length;
|
||||
const remaining = Math.max(0, 5 - userTurnsSoFar - (args.userText.trim().length > 0 ? 1 : 0));
|
||||
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
character,
|
||||
"---",
|
||||
interview,
|
||||
"---",
|
||||
`Draft so far (your previous extraction):\n\`\`\`json\n${JSON.stringify(args.draft, null, 2)}\n\`\`\``,
|
||||
`Turns remaining before ready signal becomes mandatory: ${remaining}`,
|
||||
].join("\n\n"),
|
||||
},
|
||||
];
|
||||
|
||||
// Replay history so the LLM has full context.
|
||||
for (const t of args.history) {
|
||||
messages.push({ role: t.role === "socrates" ? "assistant" : "user", content: t.text });
|
||||
}
|
||||
if (args.userText.trim().length > 0) {
|
||||
messages.push({ role: "user", content: args.userText });
|
||||
} else if (args.history.length === 0) {
|
||||
messages.push({ role: "user", content: "Begin the interview." });
|
||||
}
|
||||
|
||||
const gateway = defaultGateway();
|
||||
const { value, result } = await chatJSON<RawResponse>(gateway, messages, {
|
||||
temperature: 0.4,
|
||||
maxTokens: 768,
|
||||
jsonSchema: { name: "interview_step", schema: interviewJsonSchema as Record<string, unknown> },
|
||||
jsonObjectMode: true,
|
||||
maxRepairs: 2,
|
||||
});
|
||||
|
||||
// Normalize / merge — the LLM may emit a partial draft; we union with the
|
||||
// prior draft so a user backtracking doesn't wipe a previously-confirmed field.
|
||||
const incoming: Partial<SeedDraft> = value?.draft ?? {};
|
||||
const draft: SeedDraft = {
|
||||
title: nonEmpty(incoming.title) ?? args.draft.title ?? "",
|
||||
problem: nonEmpty(incoming.problem) ?? args.draft.problem ?? "",
|
||||
targetUser: nonEmpty(incoming.targetUser) ?? args.draft.targetUser ?? "",
|
||||
desiredOutcome: nonEmpty(incoming.desiredOutcome) ?? args.draft.desiredOutcome ?? "",
|
||||
initialHypothesis: nonEmpty(incoming.initialHypothesis) ?? args.draft.initialHypothesis,
|
||||
constraints: Array.isArray(incoming.constraints) ? incoming.constraints : args.draft.constraints,
|
||||
};
|
||||
|
||||
return {
|
||||
text: typeof value?.text === "string" && value.text.length > 0 ? value.text : "[empty response]",
|
||||
draft,
|
||||
confidence: clamp01(value?.confidence ?? 0),
|
||||
ready: !!value?.ready,
|
||||
inputTokens: result.inputTokens,
|
||||
outputTokens: result.outputTokens,
|
||||
provider: gateway.provider,
|
||||
model: gateway.model,
|
||||
};
|
||||
}
|
||||
|
||||
function nonEmpty(s: unknown): string | undefined {
|
||||
if (typeof s !== "string") return undefined;
|
||||
const t = s.trim();
|
||||
return t.length > 0 ? t : undefined;
|
||||
}
|
||||
|
||||
function clamp01(n: number): number {
|
||||
if (Number.isNaN(n)) return 0;
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
49
apps/web/lib/llm/seedToProse.ts
Normal file
49
apps/web/lib/llm/seedToProse.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Convert a SeedDraft into a ProseMirror JSON document the user lands on in
|
||||
// the editor. After the pivot the prose is canonical; the analyze pipeline
|
||||
// derives taxonomy / glossary / model / requirements from it on demand.
|
||||
//
|
||||
// We use a deterministic template (not an LLM) — fast, predictable, and the
|
||||
// user is going to keep editing anyway. Section structure mirrors the seed
|
||||
// fields so the analyze passes have clear hooks.
|
||||
|
||||
import type { JSONContent } from "@tiptap/react";
|
||||
import type { SeedDraft } from "./seedInterview";
|
||||
|
||||
export function seedToProseDoc(seed: SeedDraft): JSONContent {
|
||||
const content: JSONContent[] = [];
|
||||
|
||||
if (seed.title) {
|
||||
content.push({ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: seed.title }] });
|
||||
} else {
|
||||
content.push({ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Untitled idea" }] });
|
||||
}
|
||||
|
||||
if (seed.problem) {
|
||||
content.push({ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Problem" }] });
|
||||
content.push({ type: "paragraph", content: [{ type: "text", text: seed.problem }] });
|
||||
}
|
||||
|
||||
if (seed.targetUser) {
|
||||
content.push({ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Target user" }] });
|
||||
content.push({ type: "paragraph", content: [{ type: "text", text: seed.targetUser }] });
|
||||
}
|
||||
|
||||
if (seed.desiredOutcome) {
|
||||
content.push({ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Desired outcome" }] });
|
||||
content.push({ type: "paragraph", content: [{ type: "text", text: seed.desiredOutcome }] });
|
||||
}
|
||||
|
||||
if (seed.initialHypothesis) {
|
||||
content.push({ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Initial hypothesis" }] });
|
||||
content.push({ type: "paragraph", content: [{ type: "text", text: seed.initialHypothesis }] });
|
||||
}
|
||||
|
||||
if (seed.constraints && seed.constraints.length > 0) {
|
||||
content.push({ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Constraints" }] });
|
||||
for (const c of seed.constraints) {
|
||||
content.push({ type: "paragraph", content: [{ type: "text", text: `· ${c}` }] });
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "doc", content };
|
||||
}
|
||||
@@ -195,6 +195,46 @@ export async function sendUserTurn(args: SendUserTurnArgs): Promise<SendUserTurn
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Anchored threads (contextual mini-Socrates per finding) ─────────────
|
||||
|
||||
/** Find an existing thread anchored to `anchor` for this project, or create
|
||||
* one. Returns the thread id. */
|
||||
export async function getOrCreateAnchoredThread(
|
||||
projectId: string,
|
||||
anchor: string,
|
||||
title?: string
|
||||
): Promise<string> {
|
||||
const existing = await prisma.socratesThread.findFirst({
|
||||
where: { projectId, anchorElementId: anchor },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
if (existing) return existing.id;
|
||||
const created = await prisma.socratesThread.create({
|
||||
data: { projectId, anchorElementId: anchor, status: "open", title: title ?? null },
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function listAnchoredThreadMessages(threadId: string) {
|
||||
const rows = await prisma.socratesMessage.findMany({
|
||||
where: { threadId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, role: true, content: true, createdAt: true },
|
||||
});
|
||||
return rows.map(r => {
|
||||
let text = "";
|
||||
let options: SocratesOption[] | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(r.content) as { text?: string; options?: SocratesOption[] };
|
||||
text = parsed.text ?? "";
|
||||
options = parsed.options;
|
||||
} catch {
|
||||
text = r.content;
|
||||
}
|
||||
return { id: r.id, role: r.role, text, options, createdAt: r.createdAt };
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function trimModel(model: SysMLModel): unknown {
|
||||
|
||||
@@ -129,6 +129,7 @@ function anchorKey(anchor: ValidationIssue["anchor"]): string | null {
|
||||
case "constraint": return `constraint:${anchor.id}`;
|
||||
case "requirement": return `req:${anchor.id}`;
|
||||
case "property": return anchor.blockId;
|
||||
case "term": return `term:${anchor.id}`;
|
||||
case "model": return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,33 @@ function applyOne(model: SysMLModel, op: ModelOp, idMapping: Record<string, stri
|
||||
),
|
||||
};
|
||||
}
|
||||
case "decide-element": {
|
||||
const realId = idMapping[op.element.id] ?? op.element.id;
|
||||
const flip = <T extends { id: string; reviewStatus?: import("../sysml/model").ReviewStatus; reviewPinned?: boolean }>(arr: T[]): T[] =>
|
||||
arr.map(e =>
|
||||
e.id === realId
|
||||
? {
|
||||
...e,
|
||||
reviewStatus: "accepted" as const,
|
||||
reviewPinned: e.reviewStatus === "deprecated" ? true : !!e.reviewPinned,
|
||||
}
|
||||
: e
|
||||
);
|
||||
switch (op.element.kind) {
|
||||
case "block":
|
||||
assertExists(model.blocks, realId, "block");
|
||||
return { ...model, blocks: flip(model.blocks) };
|
||||
case "association":
|
||||
assertExists(model.associations, realId, "association");
|
||||
return { ...model, associations: flip(model.associations) };
|
||||
case "constraint":
|
||||
assertExists(model.constraints, realId, "constraint");
|
||||
return { ...model, constraints: flip(model.constraints) };
|
||||
case "requirement":
|
||||
assertExists(model.requirements, realId, "requirement");
|
||||
return { ...model, requirements: flip(model.requirements) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,22 @@ export interface RemoveRelationOp {
|
||||
relationIndex: number;
|
||||
}
|
||||
|
||||
// ─── Review ops ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// `decide-element` confirms an analyzer-suggested or analyzer-deprecated
|
||||
// model element. "Discard" simply uses the existing remove-* ops; for
|
||||
// "keep" we need a way to flip reviewStatus → accepted (and pin if it was
|
||||
// deprecated, so the next analyze pass doesn't re-deprecate the element).
|
||||
|
||||
export type ReviewableElementKind = "block" | "association" | "constraint" | "requirement";
|
||||
|
||||
export interface DecideElementOp {
|
||||
kind: "decide-element";
|
||||
element: { kind: ReviewableElementKind; id: string };
|
||||
/** Only "keep" right now — discard goes through remove-*. */
|
||||
decision: "keep";
|
||||
}
|
||||
|
||||
// ─── Union ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type ModelOp =
|
||||
@@ -148,7 +164,8 @@ export type ModelOp =
|
||||
| UpdateRequirementOp
|
||||
| RemoveRequirementOp
|
||||
| AddRelationOp
|
||||
| RemoveRelationOp;
|
||||
| RemoveRelationOp
|
||||
| DecideElementOp;
|
||||
|
||||
// ─── Op constructors (call sites stay readable) ─────────────────────────
|
||||
|
||||
@@ -220,6 +237,12 @@ export function removeRelation(requirementId: string, relationIndex: number): Re
|
||||
return { kind: "remove-relation", requirementId, relationIndex };
|
||||
}
|
||||
|
||||
export function decideElement(
|
||||
element: { kind: ReviewableElementKind; id: string }
|
||||
): DecideElementOp {
|
||||
return { kind: "decide-element", element, decision: "keep" };
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
let counter = 0;
|
||||
|
||||
134
apps/web/lib/sysml/crossValidate.ts
Normal file
134
apps/web/lib/sysml/crossValidate.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Cross-layer validation — checks the joins between taxonomy/glossary terms
|
||||
// and the SysML ontology. These rules run separately from the structural
|
||||
// SysML validator (validate.ts) because they need access to the term list,
|
||||
// which validate.ts deliberately doesn't know about.
|
||||
//
|
||||
// Rule codes are prefixed `X*` to distinguish them from S/M/T rules and to
|
||||
// make it easy for the FindingsPane to render them under their own group.
|
||||
//
|
||||
// Severities: X1 and X3 are warnings (the data is in a broken state and the
|
||||
// user almost certainly wants to fix it). X2 and X4 are soft hints — they
|
||||
// surface opportunities to formalize without being intrusive.
|
||||
|
||||
import type { ValidationIssue } from "./validate";
|
||||
import type { SysMLModel } from "./model";
|
||||
|
||||
export interface CrossTerm {
|
||||
id: string;
|
||||
label: string;
|
||||
linkedBlockId: string | null;
|
||||
}
|
||||
|
||||
export interface CrossValidateInput {
|
||||
model: SysMLModel;
|
||||
terms: CrossTerm[];
|
||||
/** How many times each term occurs in the prose document.
|
||||
* Optional — when absent, X4 is skipped. */
|
||||
occurrencesByTermId?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Threshold for X4: surface a "consider promoting" hint only when a term
|
||||
* appears at least this many times in prose. Tuned to avoid spam on
|
||||
* one-off mentions. */
|
||||
export const X4_OCCURRENCE_THRESHOLD = 3;
|
||||
|
||||
export function crossValidate(input: CrossValidateInput): ValidationIssue[] {
|
||||
const { model, terms, occurrencesByTermId } = input;
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
const termById = new Map(terms.map(t => [t.id, t]));
|
||||
const blockById = new Map(model.blocks.map(b => [b.id, b]));
|
||||
|
||||
// ─── X1 — element.linkedTermId points to a missing term ─────────────────
|
||||
|
||||
for (const b of model.blocks) {
|
||||
if (b.linkedTermId && !termById.has(b.linkedTermId)) {
|
||||
issues.push({
|
||||
code: "X1",
|
||||
severity: "warning",
|
||||
message: `Block "${b.label}" links to a term that no longer exists`,
|
||||
anchor: { kind: "block", id: b.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const a of model.associations) {
|
||||
if (a.linkedTermId && !termById.has(a.linkedTermId)) {
|
||||
issues.push({
|
||||
code: "X1",
|
||||
severity: "warning",
|
||||
message: `Association "${a.label || a.id}" links to a term that no longer exists`,
|
||||
anchor: { kind: "association", id: a.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const c of model.constraints) {
|
||||
if (c.linkedTermId && !termById.has(c.linkedTermId)) {
|
||||
issues.push({
|
||||
code: "X1",
|
||||
severity: "warning",
|
||||
message: `Constraint "${c.label}" links to a term that no longer exists`,
|
||||
anchor: { kind: "constraint", id: c.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const r of model.requirements) {
|
||||
if (r.linkedTermId && !termById.has(r.linkedTermId)) {
|
||||
issues.push({
|
||||
code: "X1",
|
||||
severity: "warning",
|
||||
message: `Requirement ${r.tag} links to a term that no longer exists`,
|
||||
anchor: { kind: "requirement", id: r.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── X2 — formalism has no linkedTermId (soft hint) ─────────────────────
|
||||
|
||||
for (const b of model.blocks) {
|
||||
if (!b.linkedTermId) {
|
||||
issues.push({
|
||||
code: "X2",
|
||||
severity: "soft",
|
||||
message: `Block "${b.label}" is not anchored to a concept — naming it after a term keeps the model in sync with the document`,
|
||||
anchor: { kind: "block", id: b.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── X3 — term.linkedBlockId is stale (block was deleted) ───────────────
|
||||
|
||||
for (const t of terms) {
|
||||
if (t.linkedBlockId && !blockById.has(t.linkedBlockId)) {
|
||||
issues.push({
|
||||
code: "X3",
|
||||
severity: "warning",
|
||||
message: `Concept "${t.label}" links to a block that no longer exists`,
|
||||
anchor: { kind: "term", id: t.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── X4 — prose-frequent term not formalized anywhere ───────────────────
|
||||
|
||||
if (occurrencesByTermId) {
|
||||
const formalizedTermIds = new Set<string>();
|
||||
for (const b of model.blocks) if (b.linkedTermId) formalizedTermIds.add(b.linkedTermId);
|
||||
for (const a of model.associations) if (a.linkedTermId) formalizedTermIds.add(a.linkedTermId);
|
||||
for (const c of model.constraints) if (c.linkedTermId) formalizedTermIds.add(c.linkedTermId);
|
||||
for (const r of model.requirements) if (r.linkedTermId) formalizedTermIds.add(r.linkedTermId);
|
||||
|
||||
for (const t of terms) {
|
||||
const n = occurrencesByTermId[t.id] ?? 0;
|
||||
if (n < X4_OCCURRENCE_THRESHOLD) continue;
|
||||
if (formalizedTermIds.has(t.id)) continue;
|
||||
issues.push({
|
||||
code: "X4",
|
||||
severity: "soft",
|
||||
message: `Concept "${t.label}" appears ${n}× in prose but has no formalization — consider promoting it`,
|
||||
anchor: { kind: "term", id: t.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
@@ -6,6 +6,18 @@
|
||||
|
||||
export type BlockKind = "block" | "actor" | "constraint" | "system";
|
||||
|
||||
/** Review state for AI-suggested model elements. Default (when absent) is
|
||||
* "accepted" — equivalent to a user-confirmed element.
|
||||
*
|
||||
* suggested — added by the analyzer in the latest run, awaits keep/discard
|
||||
* accepted — confirmed (user kept, or pre-existing before merge-with-review)
|
||||
* deprecated — analyzer didn't see in the latest run, awaits keep/discard
|
||||
*
|
||||
* `reviewPinned` flips on when the user explicitly keeps a deprecated
|
||||
* element so future merges leave it alone.
|
||||
*/
|
||||
export type ReviewStatus = "suggested" | "accepted" | "deprecated";
|
||||
|
||||
export type PropertyType =
|
||||
| { kind: "string" }
|
||||
| { kind: "number" }
|
||||
@@ -29,6 +41,14 @@ export interface Block {
|
||||
stereotypes: string[];
|
||||
properties: Property[];
|
||||
description?: string;
|
||||
/// If this block was created by dragging a taxonomy / glossary term onto
|
||||
/// the canvas (or matched to one by the model analyze pass), this points
|
||||
/// at the source TaxonomyTerm. Drives the "linked" indicator in sidebars.
|
||||
linkedTermId?: string;
|
||||
/** Review state. Absent = "accepted". */
|
||||
reviewStatus?: ReviewStatus;
|
||||
/** User explicitly kept despite analyzer not seeing it → don't re-deprecate. */
|
||||
reviewPinned?: boolean;
|
||||
}
|
||||
|
||||
export type AssociationKind =
|
||||
@@ -45,6 +65,12 @@ export interface Association {
|
||||
label: string;
|
||||
kind: AssociationKind;
|
||||
multiplicity?: { from: Multiplicity; to: Multiplicity };
|
||||
/// Term that conceptually names this relationship (e.g. "tutors",
|
||||
/// "evaluates"). Set by the analyze pass or by a Promote action; cleared
|
||||
/// when the term is deleted (cross-validation rule X1).
|
||||
linkedTermId?: string;
|
||||
reviewStatus?: ReviewStatus;
|
||||
reviewPinned?: boolean;
|
||||
}
|
||||
|
||||
export interface Constraint {
|
||||
@@ -52,6 +78,10 @@ export interface Constraint {
|
||||
label: string;
|
||||
expression: string;
|
||||
appliesTo: string[];
|
||||
/// Term that conceptually names this rule (e.g. "Daily Cap", "Eligibility").
|
||||
linkedTermId?: string;
|
||||
reviewStatus?: ReviewStatus;
|
||||
reviewPinned?: boolean;
|
||||
}
|
||||
|
||||
export type RequirementRelation =
|
||||
@@ -64,6 +94,12 @@ export interface Requirement {
|
||||
tag: string;
|
||||
text: string;
|
||||
relations: RequirementRelation[];
|
||||
/// Term this requirement is "about" (e.g. REQ-001 ‘personalization’ links to
|
||||
/// the term Personalization). Distinct from `relations.satisfy` which links
|
||||
/// to a block — a requirement can be about a concept that has no block yet.
|
||||
linkedTermId?: string;
|
||||
reviewStatus?: ReviewStatus;
|
||||
reviewPinned?: boolean;
|
||||
}
|
||||
|
||||
export interface SysMLModel {
|
||||
|
||||
@@ -25,6 +25,7 @@ export type IssueAnchor =
|
||||
| { kind: "constraint"; id: string }
|
||||
| { kind: "requirement"; id: string }
|
||||
| { kind: "property"; blockId: string; propertyId: string }
|
||||
| { kind: "term"; id: string }
|
||||
| { kind: "model" };
|
||||
|
||||
export interface ValidationIssue {
|
||||
|
||||
378
apps/web/lib/workspace/analysisStore.tsx
Normal file
378
apps/web/lib/workspace/analysisStore.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
// Client-side store holding the analyzer outputs (taxonomy, glossary terms,
|
||||
// requirements, findings, last-run metadata). Sidebar reads from here for
|
||||
// counts/timestamps; panes read for full content. Refetches on demand.
|
||||
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { SectionPaneId } from "./openPanesStore";
|
||||
|
||||
export type ClientTermStatus = "accepted" | "suggested" | "deprecated";
|
||||
|
||||
export interface ClientTerm {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
label: string;
|
||||
definition: string | null;
|
||||
synonyms: string[];
|
||||
linkedBlockId: string | null;
|
||||
/// Review state: "accepted" surfaces normally, "suggested" / "deprecated"
|
||||
/// are pending the user's keep/discard decision.
|
||||
status: ClientTermStatus;
|
||||
pinned: boolean;
|
||||
/// True when the user authored / edited the definition by hand. Future
|
||||
/// Analyze runs skip this term's definition while the flag is on.
|
||||
definitionPinned: boolean;
|
||||
}
|
||||
|
||||
export type ReviewStatus = "accepted" | "suggested" | "deprecated";
|
||||
|
||||
export interface ClientRequirement {
|
||||
id: string;
|
||||
tag: string;
|
||||
text: string;
|
||||
tracedToIds: string[];
|
||||
unsupported: boolean;
|
||||
/// Term this requirement is conceptually about, if any. Powers the back-link
|
||||
/// from a requirement row → TermDetail.
|
||||
linkedTermId: string | null;
|
||||
status: ReviewStatus;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export interface ClientFinding {
|
||||
id: string;
|
||||
kind: "assumption" | "risk" | "inconsistency";
|
||||
text: string;
|
||||
linkedElementIds: string[];
|
||||
confidence: number;
|
||||
severity: string | null;
|
||||
validationCode: string | null;
|
||||
/// "suggested" | "accepted" | "deprecated" | "dismissed" | "resolved" |
|
||||
/// (legacy) "open". Visible-only set is fetched from the API; dismissed and
|
||||
/// resolved are filtered server-side.
|
||||
status: string;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export interface ClientRun {
|
||||
status: "running" | "succeeded" | "failed";
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
// All sections map 1:1 to server sections after T3 (taxonomy + glossary
|
||||
// collapsed into "concepts"). Legacy values "taxonomy" / "glossary" still
|
||||
// work because the server normalizes them.
|
||||
export type AnalyzeSection = SectionPaneId | "all" | "taxonomy" | "glossary";
|
||||
|
||||
interface AnalysisStoreValue {
|
||||
terms: ClientTerm[];
|
||||
requirements: ClientRequirement[];
|
||||
findings: ClientFinding[];
|
||||
runs: Partial<Record<string, ClientRun>>;
|
||||
inFlight: Set<AnalyzeSection>;
|
||||
refresh(): Promise<void>;
|
||||
analyze(section: AnalyzeSection): Promise<void>;
|
||||
/** Lookup helper used by TermDetail. */
|
||||
getTerm(termId: string): ClientTerm | null;
|
||||
/** Apply a user decision to a pending term. Optimistically updates local
|
||||
* state; on server error refreshes to recover. */
|
||||
decideTerm(termId: string, decision: "keep" | "discard"): Promise<void>;
|
||||
/** Set a term's definition by user action. Empty string clears it AND
|
||||
* clears the pin (future Analyze fills it again from prose). */
|
||||
setTermDefinition(termId: string, definition: string | null): Promise<void>;
|
||||
decideRequirement(reqId: string, decision: "keep" | "discard"): Promise<void>;
|
||||
/** Findings support an extra "resolve" decision (semantically distinct from
|
||||
* "dismissed" — same effect on visibility, but signals "I fixed it"). */
|
||||
decideFinding(findingId: string, decision: "keep" | "discard" | "resolve" | "restore"): Promise<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AnalysisStoreValue | null>(null);
|
||||
|
||||
interface ProviderProps {
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AnalysisStoreProvider({ projectId, children }: ProviderProps) {
|
||||
const [terms, setTerms] = useState<ClientTerm[]>([]);
|
||||
const [requirements, setRequirements] = useState<ClientRequirement[]>([]);
|
||||
const [findings, setFindings] = useState<ClientFinding[]>([]);
|
||||
const [runs, setRuns] = useState<Partial<Record<string, ClientRun>>>({});
|
||||
const [inFlight, setInFlight] = useState<Set<AnalyzeSection>>(new Set());
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [t, r, f, runsRes] = await Promise.all([
|
||||
fetch(`/api/projects/${encodeURIComponent(projectId)}/taxonomy`).then(x => (x.ok ? x.json() : { terms: [] })),
|
||||
fetch(`/api/projects/${encodeURIComponent(projectId)}/requirements`).then(x => (x.ok ? x.json() : { requirements: [] })),
|
||||
fetch(`/api/projects/${encodeURIComponent(projectId)}/findings?include=all`).then(x => (x.ok ? x.json() : { findings: [] })),
|
||||
fetch(`/api/projects/${encodeURIComponent(projectId)}/runs`).then(x => (x.ok ? x.json() : { runs: {} })),
|
||||
]);
|
||||
setTerms((t.terms as ClientTerm[]) ?? []);
|
||||
setRequirements((r.requirements as ClientRequirement[]) ?? []);
|
||||
setFindings((f.findings as ClientFinding[]) ?? []);
|
||||
setRuns((runsRes.runs as Partial<Record<string, ClientRun>>) ?? {});
|
||||
} catch (err) {
|
||||
console.error("[analysisStore] refresh failed:", err);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const analyze = useCallback(
|
||||
async (section: AnalyzeSection) => {
|
||||
setInFlight(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(section);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const url =
|
||||
`/api/projects/${encodeURIComponent(projectId)}/analyze` +
|
||||
(section === "all" ? "" : `?section=${section}`);
|
||||
const res = await fetch(url, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
console.error("[analyze] HTTP", res.status, body.slice(0, 200));
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
console.error("[analyze] failed:", err);
|
||||
} finally {
|
||||
setInFlight(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(section);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[projectId, refresh]
|
||||
);
|
||||
|
||||
const getTerm = useCallback(
|
||||
(termId: string): ClientTerm | null => terms.find(t => t.id === termId) ?? null,
|
||||
[terms]
|
||||
);
|
||||
|
||||
const decideRequirement = useCallback(
|
||||
async (reqId: string, decision: "keep" | "discard") => {
|
||||
setRequirements(prev => {
|
||||
if (decision === "discard") return prev.filter(r => r.id !== reqId);
|
||||
return prev.map(r =>
|
||||
r.id === reqId
|
||||
? {
|
||||
...r,
|
||||
status: "accepted" as ReviewStatus,
|
||||
pinned: r.status === "deprecated" ? true : r.pinned,
|
||||
}
|
||||
: r
|
||||
);
|
||||
});
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/requirements/${encodeURIComponent(reqId)}/decision`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ decision }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
console.error("[analysisStore] decideRequirement failed:", err);
|
||||
await refresh();
|
||||
}
|
||||
},
|
||||
[projectId, refresh]
|
||||
);
|
||||
|
||||
const decideFinding = useCallback(
|
||||
async (findingId: string, decision: "keep" | "discard" | "resolve" | "restore") => {
|
||||
// Optimistic update by terminal status:
|
||||
// keep / restore → "accepted" (visible in Kept list)
|
||||
// discard → "dismissed" (visible in Discarded drawer)
|
||||
// resolve → "resolved" (visible in Discarded drawer)
|
||||
const nextStatus =
|
||||
decision === "keep" || decision === "restore"
|
||||
? "accepted"
|
||||
: decision === "discard"
|
||||
? "dismissed"
|
||||
: "resolved";
|
||||
setFindings(prev =>
|
||||
prev.map(f =>
|
||||
f.id === findingId
|
||||
? {
|
||||
...f,
|
||||
status: nextStatus,
|
||||
pinned:
|
||||
decision === "keep" && f.status === "deprecated" ? true : f.pinned,
|
||||
}
|
||||
: f
|
||||
)
|
||||
);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/findings/${encodeURIComponent(findingId)}/decision`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ decision }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
console.error("[analysisStore] decideFinding failed:", err);
|
||||
await refresh();
|
||||
}
|
||||
},
|
||||
[projectId, refresh]
|
||||
);
|
||||
|
||||
const setTermDefinition = useCallback(
|
||||
async (termId: string, definition: string | null) => {
|
||||
const trimmed = (definition ?? "").trim();
|
||||
// Optimistic update: pin when non-empty, clear pin when empty.
|
||||
setTerms(prev =>
|
||||
prev.map(t =>
|
||||
t.id === termId
|
||||
? {
|
||||
...t,
|
||||
definition: trimmed.length > 0 ? trimmed : null,
|
||||
definitionPinned: trimmed.length > 0,
|
||||
}
|
||||
: t
|
||||
)
|
||||
);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/terms/${encodeURIComponent(termId)}/definition`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ definition: trimmed.length > 0 ? trimmed : null }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
console.error("[analysisStore] setTermDefinition failed:", err);
|
||||
await refresh();
|
||||
}
|
||||
},
|
||||
[projectId, refresh]
|
||||
);
|
||||
|
||||
const decideTerm = useCallback(
|
||||
async (termId: string, decision: "keep" | "discard") => {
|
||||
// Optimistic update: keep → accepted (+ pinned if previously deprecated);
|
||||
// discard → drop from local state.
|
||||
setTerms(prev => {
|
||||
if (decision === "discard") return prev.filter(t => t.id !== termId);
|
||||
return prev.map(t =>
|
||||
t.id === termId
|
||||
? {
|
||||
...t,
|
||||
status: "accepted" as ClientTermStatus,
|
||||
pinned: t.status === "deprecated" ? true : t.pinned,
|
||||
}
|
||||
: t
|
||||
);
|
||||
});
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/terms/${encodeURIComponent(termId)}/decision`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ decision }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
console.error("[analysisStore] decideTerm failed:", err);
|
||||
await refresh();
|
||||
}
|
||||
},
|
||||
[projectId, refresh]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// First-load auto-analyze: if the project has a non-empty document but
|
||||
// nothing has been analyzed yet (no runs of any kind), kick off a full
|
||||
// Analyze in the background so the editor lands populated. Matches the
|
||||
// seed-finalize UX for the demo project and any imported docs.
|
||||
const bootstrappedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (bootstrappedRef.current) return;
|
||||
if (Object.keys(runs).length > 0) return;
|
||||
if (terms.length > 0 || requirements.length > 0 || findings.length > 0) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/document`);
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { doc?: unknown };
|
||||
const hasDoc = !!body.doc;
|
||||
if (!hasDoc || cancelled || bootstrappedRef.current) return;
|
||||
bootstrappedRef.current = true;
|
||||
void analyze("all");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, runs, terms.length, requirements.length, findings.length, analyze]);
|
||||
|
||||
// Poll while any AnalysisRun is still "running" (e.g. the seed-finalize
|
||||
// background pipeline). Stop polling once everything has settled.
|
||||
useEffect(() => {
|
||||
const anyRunning = Object.values(runs).some(r => r?.status === "running");
|
||||
if (!anyRunning) return;
|
||||
const handle = setInterval(() => void refresh(), 3000);
|
||||
return () => clearInterval(handle);
|
||||
}, [runs, refresh]);
|
||||
|
||||
const value = useMemo<AnalysisStoreValue>(
|
||||
() => ({
|
||||
terms,
|
||||
requirements,
|
||||
findings,
|
||||
runs,
|
||||
inFlight,
|
||||
refresh,
|
||||
analyze,
|
||||
getTerm,
|
||||
decideTerm,
|
||||
decideRequirement,
|
||||
decideFinding,
|
||||
setTermDefinition,
|
||||
}),
|
||||
[
|
||||
terms,
|
||||
requirements,
|
||||
findings,
|
||||
runs,
|
||||
inFlight,
|
||||
refresh,
|
||||
analyze,
|
||||
getTerm,
|
||||
decideTerm,
|
||||
decideRequirement,
|
||||
decideFinding,
|
||||
setTermDefinition,
|
||||
]
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useAnalysis(): AnalysisStoreValue {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useAnalysis must be inside <AnalysisStoreProvider>");
|
||||
return ctx;
|
||||
}
|
||||
236
apps/web/lib/workspace/openPanesStore.tsx
Normal file
236
apps/web/lib/workspace/openPanesStore.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
// Column-stack workspace store (Finder-style miller columns).
|
||||
//
|
||||
// State is a single ordered list `columns: Column[]` rendered left-to-right
|
||||
// in the main workspace. The first column is always a top-level section
|
||||
// (Concepts / Model / Requirements / Assumptions / Risks / Inconsistencies);
|
||||
// subsequent columns are entity details that drill down from the previous
|
||||
// column's selection.
|
||||
//
|
||||
// Invariants:
|
||||
// - Only one top-level section is open at a time. Clicking a different
|
||||
// section in the LeftSidebar replaces the entire stack.
|
||||
// - Pushing a child at parentIndex truncates everything to its right first.
|
||||
// - Resizing a column persists per (kind, id) so the user's chosen widths
|
||||
// stick across pushes.
|
||||
//
|
||||
// localStorage persistence keeps just the open section + per-column widths.
|
||||
// Detail children are deliberately ephemeral — they don't survive a reload.
|
||||
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export type SectionPaneId =
|
||||
| "concepts"
|
||||
| "model"
|
||||
| "requirements"
|
||||
| "assumptions"
|
||||
| "risks"
|
||||
| "inconsistencies";
|
||||
|
||||
export const SECTION_TITLES: Record<SectionPaneId, string> = {
|
||||
concepts: "Concepts",
|
||||
model: "Model",
|
||||
requirements: "Requirements",
|
||||
assumptions: "Assumptions",
|
||||
risks: "Risks",
|
||||
inconsistencies: "Inconsistencies",
|
||||
};
|
||||
|
||||
export type Column =
|
||||
| { kind: "section"; id: SectionPaneId }
|
||||
| { kind: "term"; id: string }
|
||||
| { kind: "block"; id: string }
|
||||
| { kind: "association"; id: string }
|
||||
| { kind: "constraint"; id: string }
|
||||
| { kind: "requirement"; id: string }
|
||||
| { kind: "finding"; id: string };
|
||||
|
||||
export function columnKey(c: Column): string {
|
||||
return `${c.kind}:${c.id}`;
|
||||
}
|
||||
|
||||
export const PANE_MIN_WIDTH = 240;
|
||||
export const PANE_MAX_WIDTH = 720;
|
||||
export const PANE_DEFAULT_WIDTH = 340;
|
||||
|
||||
interface OpenPanesValue {
|
||||
/** Ordered chain of columns rendered in the main workspace. */
|
||||
columns: Column[];
|
||||
/** True when the first column is this section (the chain is rooted here). */
|
||||
isSectionOpen(id: SectionPaneId): boolean;
|
||||
/** Sidebar click: open this section as the chain root, or close everything
|
||||
* if it's already the active root. */
|
||||
toggleSection(id: SectionPaneId): void;
|
||||
/** Force-open a section (no-op if already root). */
|
||||
openSection(id: SectionPaneId): void;
|
||||
/** Truncate everything past `parentIndex`, then push `child`. If the same
|
||||
* child already sits at parentIndex+1, this is a no-op (clicking the
|
||||
* active item shouldn't flicker). */
|
||||
pushFrom(parentIndex: number, child: Column): void;
|
||||
/** Drop columns from `index` onward. */
|
||||
closeFrom(index: number): void;
|
||||
/** Replace the entire stack (used for cross-section jumps, e.g. a chip
|
||||
* click that wants to land in Concepts → term). */
|
||||
setStack(columns: Column[]): void;
|
||||
/** Per-column width, keyed by columnKey. */
|
||||
widthFor(c: Column): number;
|
||||
setWidth(c: Column, width: number): void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<OpenPanesValue | null>(null);
|
||||
|
||||
interface PersistShape {
|
||||
rootSection?: SectionPaneId | null;
|
||||
widths?: Record<string, number>;
|
||||
/** Legacy field from the pre-stack store. Hydrated and discarded. */
|
||||
open?: string[];
|
||||
}
|
||||
|
||||
interface ProviderProps {
|
||||
projectId: string;
|
||||
initial?: SectionPaneId | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function OpenPanesProvider({ projectId, initial = null, children }: ProviderProps) {
|
||||
const storageKey = `socrata.workspace.${projectId}`;
|
||||
const [columns, setColumns] = useState<Column[]>(initial ? [{ kind: "section", id: initial }] : []);
|
||||
const [widths, setWidths] = useState<Record<string, number>>({});
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
// Hydrate from localStorage on mount.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as PersistShape | string[];
|
||||
let nextRoot: SectionPaneId | null = null;
|
||||
const cleanedWidths: Record<string, number> = {};
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
// Very old format: array of section ids. Take the first valid one.
|
||||
for (const id of parsed.map(migrateLegacyId).filter(isSectionPaneId)) {
|
||||
nextRoot = id;
|
||||
break;
|
||||
}
|
||||
} else if (parsed && typeof parsed === "object") {
|
||||
if (parsed.rootSection && isSectionPaneId(parsed.rootSection)) {
|
||||
nextRoot = parsed.rootSection;
|
||||
} else if (Array.isArray(parsed.open)) {
|
||||
// Pre-stack store kept multiple sections open; pick the first valid one.
|
||||
for (const id of parsed.open.map(migrateLegacyId).filter(isSectionPaneId)) {
|
||||
nextRoot = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parsed.widths && typeof parsed.widths === "object") {
|
||||
for (const [k, v] of Object.entries(parsed.widths)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) cleanedWidths[k] = clamp(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nextRoot) setColumns([{ kind: "section", id: nextRoot }]);
|
||||
setWidths(cleanedWidths);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setHydrated(true);
|
||||
}, [storageKey]);
|
||||
|
||||
// Persist root section + widths (NOT child columns — those are ephemeral).
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
try {
|
||||
const root = columns[0]?.kind === "section" ? columns[0].id : null;
|
||||
const payload: PersistShape = { rootSection: root, widths };
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [columns, widths, storageKey, hydrated]);
|
||||
|
||||
const isSectionOpen = useCallback(
|
||||
(id: SectionPaneId) => columns[0]?.kind === "section" && columns[0].id === id,
|
||||
[columns]
|
||||
);
|
||||
|
||||
const toggleSection = useCallback((id: SectionPaneId) => {
|
||||
setColumns(cur => {
|
||||
if (cur[0]?.kind === "section" && cur[0].id === id) return [];
|
||||
return [{ kind: "section", id }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openSection = useCallback((id: SectionPaneId) => {
|
||||
setColumns(cur => {
|
||||
if (cur[0]?.kind === "section" && cur[0].id === id) return cur;
|
||||
return [{ kind: "section", id }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pushFrom = useCallback((parentIndex: number, child: Column) => {
|
||||
setColumns(cur => {
|
||||
const head = cur.slice(0, parentIndex + 1);
|
||||
const existing = cur[parentIndex + 1];
|
||||
// Same child already there → keep it (no flicker, preserves any state).
|
||||
if (existing && existing.kind === child.kind && existing.id === child.id) {
|
||||
return cur.slice(0, parentIndex + 2);
|
||||
}
|
||||
return [...head, child];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeFrom = useCallback((index: number) => {
|
||||
setColumns(cur => cur.slice(0, Math.max(0, index)));
|
||||
}, []);
|
||||
|
||||
const setStack = useCallback((next: Column[]) => {
|
||||
setColumns(next);
|
||||
}, []);
|
||||
|
||||
const widthFor = useCallback(
|
||||
(c: Column) => widths[columnKey(c)] ?? PANE_DEFAULT_WIDTH,
|
||||
[widths]
|
||||
);
|
||||
const setWidth = useCallback((c: Column, width: number) => {
|
||||
setWidths(prev => ({ ...prev, [columnKey(c)]: clamp(width) }));
|
||||
}, []);
|
||||
|
||||
const value = useMemo<OpenPanesValue>(
|
||||
() => ({
|
||||
columns,
|
||||
isSectionOpen,
|
||||
toggleSection,
|
||||
openSection,
|
||||
pushFrom,
|
||||
closeFrom,
|
||||
setStack,
|
||||
widthFor,
|
||||
setWidth,
|
||||
}),
|
||||
[columns, isSectionOpen, toggleSection, openSection, pushFrom, closeFrom, setStack, widthFor, setWidth]
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useOpenPanes(): OpenPanesValue {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useOpenPanes must be inside <OpenPanesProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function isSectionPaneId(x: unknown): x is SectionPaneId {
|
||||
return typeof x === "string" && x in SECTION_TITLES;
|
||||
}
|
||||
|
||||
function migrateLegacyId(id: string): string {
|
||||
if (id === "taxonomy" || id === "glossary") return "concepts";
|
||||
return id;
|
||||
}
|
||||
|
||||
function clamp(w: number): number {
|
||||
return Math.max(PANE_MIN_WIDTH, Math.min(PANE_MAX_WIDTH, Math.round(w)));
|
||||
}
|
||||
3
apps/web/lib/workspace/types.ts
Normal file
3
apps/web/lib/workspace/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Small shared types used across editor surfaces.
|
||||
|
||||
export type Density = "comfortable" | "compact";
|
||||
@@ -0,0 +1,182 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"tagline" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NarrativeDocument" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"doc" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "NarrativeDocument_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TaxonomyTerm" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
"label" TEXT NOT NULL,
|
||||
"definition" TEXT,
|
||||
"synonyms" TEXT NOT NULL DEFAULT '[]',
|
||||
"linkedBlockId" TEXT,
|
||||
"modelVersion" INTEGER NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "TaxonomyTerm_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "TaxonomyTerm_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "TaxonomyTerm" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RequirementEntry" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"tag" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"tracedToIds" TEXT NOT NULL DEFAULT '[]',
|
||||
"unsupported" BOOLEAN NOT NULL DEFAULT false,
|
||||
"modelVersion" INTEGER NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "RequirementEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnalysisRun" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"section" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"startedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finishedAt" DATETIME,
|
||||
"modelVersion" INTEGER NOT NULL,
|
||||
"inputTokens" INTEGER,
|
||||
"outputTokens" INTEGER,
|
||||
"errorMessage" TEXT,
|
||||
CONSTRAINT "AnalysisRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ModelSnapshot" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"json" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ModelSnapshot_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChangelogEntry" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"ops" TEXT NOT NULL,
|
||||
"reason" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ChangelogEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SocratesThread" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"anchorElementId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"title" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SocratesThread_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SocratesMessage" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"threadId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"provider" TEXT,
|
||||
"model" TEXT,
|
||||
"inputTokens" INTEGER,
|
||||
"outputTokens" INTEGER,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "SocratesMessage_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "SocratesThread" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Finding" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"linkedElementIds" TEXT NOT NULL,
|
||||
"confidence" REAL NOT NULL,
|
||||
"severity" TEXT,
|
||||
"validationCode" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"modelVersion" INTEGER NOT NULL,
|
||||
"provider" TEXT,
|
||||
"llmModel" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Finding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ResearchFinding" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"findingId" TEXT NOT NULL,
|
||||
"query" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"title" TEXT,
|
||||
"snippet" TEXT,
|
||||
"stance" TEXT NOT NULL DEFAULT 'neutral',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ResearchFinding_findingId_fkey" FOREIGN KEY ("findingId") REFERENCES "Finding" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "NarrativeDocument_projectId_key" ON "NarrativeDocument"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaxonomyTerm_projectId_idx" ON "TaxonomyTerm"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaxonomyTerm_projectId_label_idx" ON "TaxonomyTerm"("projectId", "label");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RequirementEntry_projectId_idx" ON "RequirementEntry"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnalysisRun_projectId_section_idx" ON "AnalysisRun"("projectId", "section");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnalysisRun_projectId_startedAt_idx" ON "AnalysisRun"("projectId", "startedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ModelSnapshot_projectId_version_idx" ON "ModelSnapshot"("projectId", "version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ModelSnapshot_projectId_version_key" ON "ModelSnapshot"("projectId", "version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChangelogEntry_projectId_version_idx" ON "ChangelogEntry"("projectId", "version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SocratesThread_projectId_idx" ON "SocratesThread"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SocratesMessage_threadId_createdAt_idx" ON "SocratesMessage"("threadId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Finding_projectId_status_idx" ON "Finding"("projectId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Finding_projectId_kind_idx" ON "Finding"("projectId", "kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ResearchFinding_findingId_idx" ON "ResearchFinding"("findingId");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "RequirementEntry" ADD COLUMN "linkedTermId" TEXT;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_TaxonomyTerm" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
"label" TEXT NOT NULL,
|
||||
"definition" TEXT,
|
||||
"synonyms" TEXT NOT NULL DEFAULT '[]',
|
||||
"linkedBlockId" TEXT,
|
||||
"modelVersion" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'accepted',
|
||||
"pinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "TaxonomyTerm_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "TaxonomyTerm_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "TaxonomyTerm" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_TaxonomyTerm" ("createdAt", "definition", "id", "label", "linkedBlockId", "modelVersion", "parentId", "projectId", "synonyms") SELECT "createdAt", "definition", "id", "label", "linkedBlockId", "modelVersion", "parentId", "projectId", "synonyms" FROM "TaxonomyTerm";
|
||||
DROP TABLE "TaxonomyTerm";
|
||||
ALTER TABLE "new_TaxonomyTerm" RENAME TO "TaxonomyTerm";
|
||||
CREATE INDEX "TaxonomyTerm_projectId_idx" ON "TaxonomyTerm"("projectId");
|
||||
CREATE INDEX "TaxonomyTerm_projectId_label_idx" ON "TaxonomyTerm"("projectId", "label");
|
||||
CREATE INDEX "TaxonomyTerm_projectId_status_idx" ON "TaxonomyTerm"("projectId", "status");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Finding" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"linkedElementIds" TEXT NOT NULL,
|
||||
"confidence" REAL NOT NULL,
|
||||
"severity" TEXT,
|
||||
"validationCode" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'suggested',
|
||||
"pinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"suggestionKey" TEXT,
|
||||
"modelVersion" INTEGER NOT NULL,
|
||||
"provider" TEXT,
|
||||
"llmModel" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Finding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Finding" ("confidence", "createdAt", "id", "kind", "linkedElementIds", "llmModel", "modelVersion", "projectId", "provider", "severity", "status", "text", "validationCode") SELECT "confidence", "createdAt", "id", "kind", "linkedElementIds", "llmModel", "modelVersion", "projectId", "provider", "severity", "status", "text", "validationCode" FROM "Finding";
|
||||
DROP TABLE "Finding";
|
||||
ALTER TABLE "new_Finding" RENAME TO "Finding";
|
||||
CREATE INDEX "Finding_projectId_status_idx" ON "Finding"("projectId", "status");
|
||||
CREATE INDEX "Finding_projectId_kind_idx" ON "Finding"("projectId", "kind");
|
||||
CREATE INDEX "Finding_projectId_suggestionKey_idx" ON "Finding"("projectId", "suggestionKey");
|
||||
CREATE TABLE "new_RequirementEntry" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"tag" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"tracedToIds" TEXT NOT NULL DEFAULT '[]',
|
||||
"unsupported" BOOLEAN NOT NULL DEFAULT false,
|
||||
"modelVersion" INTEGER NOT NULL,
|
||||
"linkedTermId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'accepted',
|
||||
"pinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "RequirementEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_RequirementEntry" ("createdAt", "id", "linkedTermId", "modelVersion", "projectId", "tag", "text", "tracedToIds", "unsupported") SELECT "createdAt", "id", "linkedTermId", "modelVersion", "projectId", "tag", "text", "tracedToIds", "unsupported" FROM "RequirementEntry";
|
||||
DROP TABLE "RequirementEntry";
|
||||
ALTER TABLE "new_RequirementEntry" RENAME TO "RequirementEntry";
|
||||
CREATE INDEX "RequirementEntry_projectId_idx" ON "RequirementEntry"("projectId");
|
||||
CREATE INDEX "RequirementEntry_projectId_status_idx" ON "RequirementEntry"("projectId", "status");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_TaxonomyTerm" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
"label" TEXT NOT NULL,
|
||||
"definition" TEXT,
|
||||
"synonyms" TEXT NOT NULL DEFAULT '[]',
|
||||
"linkedBlockId" TEXT,
|
||||
"modelVersion" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'accepted',
|
||||
"pinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"definitionPinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "TaxonomyTerm_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "TaxonomyTerm_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "TaxonomyTerm" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_TaxonomyTerm" ("createdAt", "definition", "id", "label", "linkedBlockId", "modelVersion", "parentId", "pinned", "projectId", "status", "synonyms") SELECT "createdAt", "definition", "id", "label", "linkedBlockId", "modelVersion", "parentId", "pinned", "projectId", "status", "synonyms" FROM "TaxonomyTerm";
|
||||
DROP TABLE "TaxonomyTerm";
|
||||
ALTER TABLE "new_TaxonomyTerm" RENAME TO "TaxonomyTerm";
|
||||
CREATE INDEX "TaxonomyTerm_projectId_idx" ON "TaxonomyTerm"("projectId");
|
||||
CREATE INDEX "TaxonomyTerm_projectId_label_idx" ON "TaxonomyTerm"("projectId", "label");
|
||||
CREATE INDEX "TaxonomyTerm_projectId_status_idx" ON "TaxonomyTerm"("projectId", "status");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
3
apps/web/prisma/migrations/migration_lock.toml
Normal file
3
apps/web/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
@@ -27,6 +27,112 @@ model Project {
|
||||
changes ChangelogEntry[]
|
||||
threads SocratesThread[]
|
||||
findings Finding[]
|
||||
document NarrativeDocument?
|
||||
taxonomy TaxonomyTerm[]
|
||||
reqs RequirementEntry[]
|
||||
runs AnalysisRun[]
|
||||
}
|
||||
|
||||
/// Canonical prose. After the pivot, the narrative is the source of truth;
|
||||
/// taxonomy / glossary / model / findings are projections produced by the
|
||||
/// Analyze pipeline.
|
||||
model NarrativeDocument {
|
||||
id String @id @default(cuid())
|
||||
projectId String @unique
|
||||
doc String // ProseMirror JSON
|
||||
version Int @default(1)
|
||||
updatedAt DateTime @updatedAt
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
/// Taxonomy term (concept / entity) extracted by the analyze-taxonomy pass.
|
||||
/// Optionally linked to a SysML block, which surfaces the "linked" indicator
|
||||
/// in the sidebar and powers the click-to-jump action.
|
||||
model TaxonomyTerm {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
parentId String?
|
||||
label String
|
||||
definition String?
|
||||
/// JSON-encoded string[] of synonym surface forms.
|
||||
synonyms String @default("[]")
|
||||
/// SysML block id this term is currently linked to (if any).
|
||||
linkedBlockId String?
|
||||
/// Model version when this term was last refreshed.
|
||||
modelVersion Int
|
||||
/// Review state. `accepted` (default — confirmed term, surfaces normally),
|
||||
/// `suggested` (analyzer added in last run, awaits user keep/discard),
|
||||
/// `deprecated` (analyzer didn't see in last run, awaits user keep/discard).
|
||||
status String @default("accepted")
|
||||
/// User explicitly chose to keep this term despite the analyzer not seeing
|
||||
/// it. Future merges leave it alone instead of re-marking it deprecated.
|
||||
pinned Boolean @default(false)
|
||||
/// User authored or edited the definition by hand. When true, future
|
||||
/// Analyze runs will NOT replace the definition — the LLM's emit is
|
||||
/// dropped on the floor. Reset by writing an empty string back through
|
||||
/// setTermDefinition (which clears the pin).
|
||||
definitionPinned Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
parent TaxonomyTerm? @relation("TaxonomyTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children TaxonomyTerm[] @relation("TaxonomyTree")
|
||||
|
||||
@@index([projectId])
|
||||
@@index([projectId, label])
|
||||
@@index([projectId, status])
|
||||
}
|
||||
|
||||
/// Requirement extracted from prose. Traceability is a JSON list of block ids
|
||||
/// that the analyzer believes fulfill the requirement.
|
||||
model RequirementEntry {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
tag String
|
||||
text String
|
||||
/// JSON-encoded string[] of block ids.
|
||||
tracedToIds String @default("[]")
|
||||
unsupported Boolean @default(false)
|
||||
modelVersion Int
|
||||
/// Term this requirement is conceptually about (links REQ → concept).
|
||||
/// Null when the requirement isn't anchored to a specific term.
|
||||
linkedTermId String?
|
||||
/// Review state. Same triple as TaxonomyTerm — `accepted` (default,
|
||||
/// surfaces normally), `suggested` (analyzer added in last run), or
|
||||
/// `deprecated` (analyzer didn't see in last run). Pending statuses
|
||||
/// await user keep/discard.
|
||||
status String @default("accepted")
|
||||
/// User explicitly preserved → don't re-deprecate next merge.
|
||||
pinned Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([projectId])
|
||||
@@index([projectId, status])
|
||||
}
|
||||
|
||||
/// One row per Analyze invocation per section. Powers last-run timestamps
|
||||
/// and progress indicators in the sidebar.
|
||||
model AnalysisRun {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
/// "taxonomy" | "glossary" | "model" | "requirements"
|
||||
/// | "assumptions" | "risks" | "inconsistencies" | "all"
|
||||
section String
|
||||
/// "running" | "succeeded" | "failed"
|
||||
status String
|
||||
startedAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
modelVersion Int
|
||||
inputTokens Int?
|
||||
outputTokens Int?
|
||||
errorMessage String?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([projectId, section])
|
||||
@@index([projectId, startedAt])
|
||||
}
|
||||
|
||||
model ModelSnapshot {
|
||||
@@ -106,8 +212,23 @@ model Finding {
|
||||
severity String?
|
||||
/// Inconsistencies only: optional structural-rule code (S1, M2, T1, …)
|
||||
validationCode String?
|
||||
/// Lifecycle: "open" | "dismissed" | "resolved"
|
||||
status String @default("open")
|
||||
/// Lifecycle (review-aware):
|
||||
/// "suggested" — new from latest run, awaits user keep/discard
|
||||
/// "accepted" — user has explicitly kept (or carried forward); the
|
||||
/// normal "open" surfaces in the pane
|
||||
/// "deprecated"— was accepted, analyzer didn't see in latest run
|
||||
/// "dismissed" — user said never (don't resurface)
|
||||
/// "resolved" — user marked done
|
||||
/// Pre-existing rows from before merge-with-review used "open"; we
|
||||
/// continue to accept that value as a synonym for "accepted" for one
|
||||
/// release.
|
||||
status String @default("suggested")
|
||||
/// User explicitly preserved → don't re-deprecate next merge.
|
||||
pinned Boolean @default(false)
|
||||
/// Stable identity key for cross-run matching. For LLM findings:
|
||||
/// `<kind>:<normalized-text>`; for cross-validation: `<validationCode>:<elementId>`.
|
||||
/// Null on legacy rows.
|
||||
suggestionKey String?
|
||||
/// Model version this finding was detected against.
|
||||
modelVersion Int
|
||||
/// Provider + model that emitted the finding.
|
||||
@@ -120,6 +241,7 @@ model Finding {
|
||||
|
||||
@@index([projectId, status])
|
||||
@@index([projectId, kind])
|
||||
@@index([projectId, suggestionKey])
|
||||
}
|
||||
|
||||
/// Web research results from validating a finding via Tavily / similar.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,37 @@
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
|
||||
--tracking: 0;
|
||||
|
||||
/* ─── Type scale ────────────────────────────────────────────────────
|
||||
* Five sizes. Use one of these everywhere; resist adding 0.5px nudges.
|
||||
* --text-xs chips, badges, eyebrows, kind labels, mono meta
|
||||
* --text-sm secondary UI text, hints, captions, drag/help text
|
||||
* --text-md primary UI text, list rows, definitions (DEFAULT)
|
||||
* --text-lg pane titles, prominent labels, strong row titles
|
||||
* --text-xl detail-column titles
|
||||
* Plus three line-heights: tight (titles), ui (rows), prose (definitions). */
|
||||
--text-xs: 10.5px;
|
||||
--text-sm: 11.5px;
|
||||
--text-md: 12.5px;
|
||||
--text-lg: 13.5px;
|
||||
--text-xl: 16px;
|
||||
--lh-tight: 1.2;
|
||||
--lh-ui: 1.4;
|
||||
--lh-prose: 1.5;
|
||||
|
||||
/* ─── Spacing scale ─────────────────────────────────────────────────
|
||||
* 4-px base. Pick one of these for every gap / padding decision. */
|
||||
--space-1: 3px;
|
||||
--space-2: 5px;
|
||||
--space-3: 7px;
|
||||
--space-4: 10px;
|
||||
--space-5: 14px;
|
||||
--space-6: 20px;
|
||||
|
||||
/* ─── Radius ─────────────────────────────────────────────────────── */
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
|
||||
--bg: #f5efe2; /* parchment */
|
||||
--surface: #faf5e9;
|
||||
--surface-2: #ede4cf;
|
||||
|
||||
Reference in New Issue
Block a user