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
|
// GET /api/projects/[id]/findings — returns the project's currently visible
|
||||||
// POST /api/projects/[id]/findings — runs detection, persists, returns the new set
|
// 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 { 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";
|
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 { projectId } = await params;
|
||||||
const findings = await listOpenFindings(projectId);
|
// ?include=all merges in dismissed + resolved so the FindingsPane Discarded
|
||||||
return NextResponse.json({
|
// drawer can render them. Default stays visible-only for back-compat.
|
||||||
findings: findings.map(f => ({
|
const url = new URL(req.url);
|
||||||
id: f.id,
|
const include = (url.searchParams.get("include") ?? "").toLowerCase();
|
||||||
kind: f.kind,
|
if (include === "all") {
|
||||||
text: f.text,
|
const [visible, discarded] = await Promise.all([
|
||||||
linkedElementIds: f.linkedElementIds,
|
listVisibleFindings(projectId),
|
||||||
confidence: f.confidence,
|
listDiscardedFindings(projectId),
|
||||||
severity: f.severity,
|
]);
|
||||||
validationCode: f.validationCode,
|
return NextResponse.json({ findings: [...visible, ...discarded].map(toApi) });
|
||||||
modelVersion: f.modelVersion,
|
}
|
||||||
provider: f.provider,
|
const findings = await listVisibleFindings(projectId);
|
||||||
llmModel: f.llmModel,
|
return NextResponse.json({ findings: findings.map(toApi) });
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(_req: Request, { params }: { params: Promise<{ projectId: string }> }) {
|
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 {
|
try {
|
||||||
const { model, version } = await loadProject(projectId);
|
const { model, version } = await loadProject(projectId);
|
||||||
const result = await detectFindings(model);
|
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({
|
return NextResponse.json({
|
||||||
findings: stored.map(f => ({
|
findings: stored.map(toApi),
|
||||||
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,
|
|
||||||
})),
|
|
||||||
meta: {
|
meta: {
|
||||||
provider: result.provider,
|
provider: result.provider,
|
||||||
model: result.model,
|
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 { EditorShell } from "../../../components/editor/EditorShell";
|
||||||
import { aristotleFixture } from "../../../lib/fixtures/aristotle";
|
import { aristotleFixture } from "../../../lib/fixtures/aristotle";
|
||||||
|
import { loadProject, ensureAristotleSeeded, ProjectNotFoundError } from "../../../lib/db/repo";
|
||||||
|
|
||||||
// M1: every projectId resolves to the Aristotle fixture.
|
// Server component: loads the latest model + project metadata. Auto-seeds the
|
||||||
// M4–M5 wire this to a real database lookup.
|
// Aristotle demo for the literal "aristotle" id; any other unknown id 404s.
|
||||||
// (EditorShell wraps itself in Suspense for useSearchParams; no boundary
|
export default async function EditorPage(props: { params: Promise<{ projectId: string }> }) {
|
||||||
// needed at this level.)
|
const { projectId } = await props.params;
|
||||||
export default async function EditorPage(_props: { params: Promise<{ projectId: string }> }) {
|
|
||||||
return <EditorShell data={aristotleFixture} />;
|
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 (
|
return (
|
||||||
<div style={{ padding: "60px 80px", maxWidth: 720, margin: "0 auto", lineHeight: 1.55 }}>
|
<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 }}>
|
<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 }}>
|
<p style={{ color: "var(--muted)", marginBottom: 32 }}>
|
||||||
Structured thinking and validation platform for product managers.
|
Structured thinking and validation platform for product managers.
|
||||||
</p>
|
</p>
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 12 }}>
|
|
||||||
<li>
|
<div style={{ marginBottom: 32 }}>
|
||||||
<Link
|
<Link
|
||||||
href="/editor/aristotle"
|
href="/seed"
|
||||||
style={{
|
style={{
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
padding: "12px 18px",
|
padding: "12px 18px",
|
||||||
border: "1px solid var(--border-strong)",
|
border: "1px solid var(--accent)",
|
||||||
borderRadius: 6,
|
background: "var(--accent)",
|
||||||
color: "var(--accent)",
|
color: "var(--accent-on)",
|
||||||
fontFamily: "var(--font-mono)",
|
borderRadius: 6,
|
||||||
fontSize: 13,
|
fontFamily: "var(--font-mono)",
|
||||||
textDecoration: "none",
|
fontSize: 13,
|
||||||
}}
|
textDecoration: "none",
|
||||||
>
|
}}
|
||||||
→ Open editor (Aristotle fixture)
|
>
|
||||||
</Link>
|
→ Start a new idea
|
||||||
</li>
|
</Link>
|
||||||
<li>
|
</div>
|
||||||
<Link
|
|
||||||
href="/seed"
|
<div>
|
||||||
style={{
|
<h2 style={{
|
||||||
display: "inline-block",
|
fontFamily: "var(--font-mono)",
|
||||||
padding: "12px 18px",
|
fontSize: 11,
|
||||||
border: "1px solid var(--border)",
|
textTransform: "uppercase",
|
||||||
borderRadius: 6,
|
letterSpacing: "0.10em",
|
||||||
color: "var(--muted)",
|
color: "var(--muted)",
|
||||||
fontFamily: "var(--font-mono)",
|
marginBottom: 12,
|
||||||
fontSize: 13,
|
}}>
|
||||||
textDecoration: "none",
|
{projects.length === 0 ? "no projects yet" : `${projects.length} project${projects.length === 1 ? "" : "s"}`}
|
||||||
}}
|
</h2>
|
||||||
>
|
|
||||||
→ Seed screen (coming next)
|
{projects.length === 0 ? (
|
||||||
</Link>
|
<p style={{ color: "var(--muted)", fontStyle: "italic" }}>
|
||||||
</li>
|
Click "Start a new idea" above to begin a Socratic interview that bootstraps a fresh project.
|
||||||
</ul>
|
</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>
|
</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,
|
type ModelOp,
|
||||||
} from "../../lib/sync/ops";
|
} from "../../lib/sync/ops";
|
||||||
import type { ValidationIssue } from "../../lib/sysml/validate";
|
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 { FixtureData, BlockKind } from "../../lib/fixtures/aristotle";
|
||||||
import type { Block, Property, PropertyType, SysMLModel } from "../../lib/sysml/model";
|
import type { Block, Property, PropertyType, SysMLModel } from "../../lib/sysml/model";
|
||||||
|
|
||||||
@@ -69,6 +69,8 @@ interface DiagramCanvasProps {
|
|||||||
focusBlockId: string | null;
|
focusBlockId: string | null;
|
||||||
onSelect?: (id: string | null) => void;
|
onSelect?: (id: string | null) => void;
|
||||||
issuesByElement?: Map<string, ValidationIssue[]>;
|
issuesByElement?: Map<string, ValidationIssue[]>;
|
||||||
|
/** When set, term-drop on the canvas links the chosen term server-side. */
|
||||||
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeTypes = { sysmlBlock: BlockNode };
|
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 model = useModel();
|
||||||
const apply = useApply();
|
const apply = useApply();
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -130,14 +132,29 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
|||||||
const dataChanged =
|
const dataChanged =
|
||||||
existing.data?.label !== b.label ||
|
existing.data?.label !== b.label ||
|
||||||
existing.data?.kind !== b.kind ||
|
existing.data?.kind !== b.kind ||
|
||||||
!sameStringArray(existing.data?.properties ?? [], propNames);
|
!sameStringArray(existing.data?.properties ?? [], propNames) ||
|
||||||
|
existing.data?.reviewStatus !== b.reviewStatus;
|
||||||
if (dataChanged) {
|
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 {
|
} else {
|
||||||
next.push(existing);
|
next.push(existing);
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
for (const c of model.constraints) {
|
||||||
@@ -146,14 +163,30 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
|||||||
const expr = c.expression || "{ }";
|
const expr = c.expression || "{ }";
|
||||||
const dataChanged =
|
const dataChanged =
|
||||||
existing.data?.label !== c.label ||
|
existing.data?.label !== c.label ||
|
||||||
existing.data?.expression !== expr;
|
existing.data?.expression !== expr ||
|
||||||
|
existing.data?.reviewStatus !== c.reviewStatus;
|
||||||
if (dataChanged) {
|
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 {
|
} else {
|
||||||
next.push(existing);
|
next.push(existing);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
next.push({ ...makeNodeForConstraint(c), position: placeNew() });
|
const created = makeNodeForConstraint(c);
|
||||||
|
next.push({
|
||||||
|
...created,
|
||||||
|
position: placeNew(),
|
||||||
|
data: { ...created.data, reviewStatus: c.reviewStatus },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -295,9 +328,47 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
|||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(event: React.DragEvent) => {
|
(event: React.DragEvent) => {
|
||||||
event.preventDefault();
|
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");
|
const kindRaw = event.dataTransfer.getData("application/sysml-kind");
|
||||||
if (!kindRaw) return;
|
if (!kindRaw) return;
|
||||||
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
||||||
|
|
||||||
if (kindRaw === "constraint") {
|
if (kindRaw === "constraint") {
|
||||||
const cid = tempId("c");
|
const cid = tempId("c");
|
||||||
@@ -332,7 +403,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
|
|||||||
onSelect?.(final);
|
onSelect?.(final);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[apply, onSelect, screenToFlowPosition, setNodes]
|
[apply, onSelect, screenToFlowPosition, setNodes, projectId]
|
||||||
);
|
);
|
||||||
|
|
||||||
function patchSelected(patch: Partial<BlockNodeData>) {
|
function patchSelected(patch: Partial<BlockNodeData>) {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export interface BlockNodeData extends Record<string, unknown> {
|
|||||||
expression?: string;
|
expression?: string;
|
||||||
/** Highest severity of any validation issue anchored to this block. */
|
/** Highest severity of any validation issue anchored to this block. */
|
||||||
issueSeverity?: "error" | "warning" | "soft";
|
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> = {
|
const STEREO: Record<string, string> = {
|
||||||
@@ -33,6 +36,7 @@ export function BlockNode({ data, selected }: NodeProps) {
|
|||||||
`sysml-node-${kind}`,
|
`sysml-node-${kind}`,
|
||||||
selected ? "sysml-node-selected" : "",
|
selected ? "sysml-node-selected" : "",
|
||||||
d.issueSeverity ? `sysml-node-issue-${d.issueSeverity}` : "",
|
d.issueSeverity ? `sysml-node-issue-${d.issueSeverity}` : "",
|
||||||
|
d.reviewStatus && d.reviewStatus !== "accepted" ? `sysml-node-review-${d.reviewStatus}` : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.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.
|
// The pivoted workspace shell.
|
||||||
// M5: state lives in ModelStoreProvider; both canvases consume the canonical
|
//
|
||||||
// SysMLModel and emit ModelOps back through useApply().
|
// 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";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, Suspense } from "react";
|
import { useMemo, Suspense } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { TopBar } from "./TopBar";
|
import { TopBar } from "./TopBar";
|
||||||
import { LeftRail } from "./LeftRail";
|
import { LeftSidebar } from "./LeftSidebar";
|
||||||
import { CanvasHeader } from "./CanvasHeader";
|
|
||||||
import { StatusBar } from "./StatusBar";
|
import { StatusBar } from "./StatusBar";
|
||||||
import { IssuesPanel } from "./IssuesPanel";
|
import { MainWorkspace } from "./MainWorkspace";
|
||||||
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 type { FixtureData } from "../../lib/fixtures/aristotle";
|
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||||
import { fromFixture } from "../../lib/sysml/fromFixture";
|
import { fromFixture } from "../../lib/sysml/fromFixture";
|
||||||
import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks";
|
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 {
|
interface EditorShellProps {
|
||||||
data: FixtureData;
|
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). */
|
* deriving from `data` (legacy fixture path; useful for tests). */
|
||||||
initialModel?: import("../../lib/sysml/model").SysMLModel;
|
initialModel?: import("../../lib/sysml/model").SysMLModel;
|
||||||
initialVersion?: number;
|
initialVersion?: number;
|
||||||
/** When set, apply() POSTs to /api/projects/[projectId]/apply. */
|
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
density?: Density;
|
density?: Density;
|
||||||
markupStyle?: MarkupStyle;
|
|
||||||
diagramStyle?: DiagramVariant;
|
|
||||||
presence?: SocratesPresence;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorShell(props: EditorShellProps) {
|
export function EditorShell(props: EditorShellProps) {
|
||||||
@@ -43,16 +41,7 @@ export function EditorShell(props: EditorShellProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditorShellInner({
|
function EditorShellInner({ data, initialModel, initialVersion, projectId, density = "comfortable" }: EditorShellProps) {
|
||||||
data,
|
|
||||||
initialModel,
|
|
||||||
initialVersion,
|
|
||||||
projectId,
|
|
||||||
density = "comfortable",
|
|
||||||
markupStyle = "color",
|
|
||||||
diagramStyle = "softened",
|
|
||||||
presence = "default",
|
|
||||||
}: EditorShellProps) {
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const breaks = useMemo<BreakName[]>(() => {
|
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);
|
return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
// Prefer server-loaded model; fall back to fixture derivation for legacy
|
|
||||||
// callers without DB persistence wiring.
|
|
||||||
const startingModel = useMemo(() => {
|
const startingModel = useMemo(() => {
|
||||||
const base = initialModel ?? fromFixture(data);
|
const base = initialModel ?? fromFixture(data);
|
||||||
return breaks.length > 0 ? applyBreaks(base, breaks) : base;
|
return breaks.length > 0 ? applyBreaks(base, breaks) : base;
|
||||||
}, [initialModel, data, breaks]);
|
}, [initialModel, data, breaks]);
|
||||||
|
|
||||||
|
const id = projectId ?? "aristotle";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModelStoreProvider
|
<ModelStoreProvider initialModel={startingModel} initialVersion={initialVersion ?? 1} projectId={id}>
|
||||||
initialModel={startingModel}
|
<OpenPanesProvider projectId={id}>
|
||||||
initialVersion={initialVersion ?? 1}
|
<AnalysisStoreProvider projectId={id}>
|
||||||
projectId={projectId}
|
<EditorPaneContextProvider projectId={id}>
|
||||||
>
|
<div className={`shell shell-density-${density}`}>
|
||||||
<ShellBody
|
<TopBar data={data} />
|
||||||
data={data}
|
<div className="shell-body shell-body-pivot">
|
||||||
projectId={projectId}
|
<LeftSidebar />
|
||||||
density={density}
|
<MainWorkspace data={data} projectId={id} />
|
||||||
markupStyle={markupStyle}
|
</div>
|
||||||
diagramStyle={diagramStyle}
|
<StatusBar data={data} />
|
||||||
presence={presence}
|
</div>
|
||||||
breaks={breaks}
|
</EditorPaneContextProvider>
|
||||||
/>
|
</AnalysisStoreProvider>
|
||||||
|
</OpenPanesProvider>
|
||||||
</ModelStoreProvider>
|
</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.
|
// Top-level brand row + Analyze All action (the pivot's primary global verb).
|
||||||
// Ported from docs/design-source/socrata/project/editor-shell.jsx (TopBar).
|
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||||
|
import { useAnalysis } from "../../lib/workspace/analysisStore";
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
data: FixtureData;
|
data: FixtureData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({ data }: TopBarProps) {
|
export function TopBar({ data }: TopBarProps) {
|
||||||
|
const { analyze, inFlight } = useAnalysis();
|
||||||
|
const running = inFlight.size > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-left">
|
<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>
|
<span className="brand-name">Socrata</span>
|
||||||
</div>
|
</Link>
|
||||||
<div className="breadcrumbs">
|
<div className="breadcrumbs">
|
||||||
<span className="bc-sep">/</span>
|
|
||||||
<span className="bc-item">{data.project.scope}</span>
|
|
||||||
<span className="bc-sep">/</span>
|
<span className="bc-sep">/</span>
|
||||||
<span className="bc-item bc-active">{data.project.name}</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>
|
</div>
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
<div className="sync-pill">
|
<button
|
||||||
<span className="sync-dot" /> model in sync · {data.project.lastSync}
|
type="button"
|
||||||
</div>
|
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="topbar-divider" />
|
||||||
<div className="avatar">MC</div>
|
<div className="avatar">MC</div>
|
||||||
</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).
|
// Live seed interview screen.
|
||||||
// Ported from docs/design-source/socrata/project/seed-screen.jsx.
|
//
|
||||||
|
// 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";
|
import { Sigil } from "../socrates/Sigil";
|
||||||
|
|
||||||
interface SeedField {
|
interface InterviewTurn {
|
||||||
key: string;
|
role: "socrates" | "user";
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
inferred?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThreadTurn {
|
|
||||||
who: "socrates" | "user";
|
|
||||||
text: string;
|
text: string;
|
||||||
|
pending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields: SeedField[] = [
|
interface SeedDraft {
|
||||||
{
|
title: string;
|
||||||
key: "problem",
|
problem: string;
|
||||||
label: "Problem",
|
targetUser: string;
|
||||||
value: "First-year STEM students disengage in the long tail between lectures and office hours; a self-efficacy gap forms quickly.",
|
desiredOutcome: string;
|
||||||
},
|
initialHypothesis?: string;
|
||||||
{
|
constraints?: string[];
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const thread: ThreadTurn[] = [
|
const EMPTY_DRAFT: SeedDraft = {
|
||||||
{
|
title: "",
|
||||||
who: "socrates",
|
problem: "",
|
||||||
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?",
|
targetUser: "",
|
||||||
},
|
desiredOutcome: "",
|
||||||
{
|
};
|
||||||
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?",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SeedScreen() {
|
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 (
|
return (
|
||||||
<div className="seed-screen">
|
<div className="seed-screen">
|
||||||
<header className="seed-top">
|
<header className="seed-top">
|
||||||
@@ -76,110 +168,147 @@ export function SeedScreen() {
|
|||||||
<Sigil size={28} />
|
<Sigil size={28} />
|
||||||
<span className="seed-brand">Socrata</span>
|
<span className="seed-brand">Socrata</span>
|
||||||
<span className="seed-pip">·</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>
|
||||||
<div className="seed-top-right">
|
<div className="seed-top-right">
|
||||||
<span className="seed-mode-pill seed-mode-active">Interview</span>
|
<a href="/" className="seed-mode-pill" style={{ textDecoration: "none" }}>← back</a>
|
||||||
<span className="seed-mode-pill">Form</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="seed-body">
|
<div className="seed-body">
|
||||||
{/* Left: emerging seed */}
|
|
||||||
<section className="seed-left">
|
<section className="seed-left">
|
||||||
<div className="seed-section-label">Emerging seed</div>
|
<div className="seed-section-label">Emerging seed</div>
|
||||||
<div className="seed-fields">
|
<div className="seed-fields">
|
||||||
{fields.map(f => (
|
<Field label="Title" value={draft.title} />
|
||||||
<div key={f.key} className={`seed-field ${f.inferred ? "seed-field-inferred" : ""}`}>
|
<Field label="Problem" value={draft.problem} />
|
||||||
<div className="seed-field-label">
|
<Field label="Target user" value={draft.targetUser} />
|
||||||
{f.label}
|
<Field label="Desired outcome" value={draft.desiredOutcome} />
|
||||||
{f.inferred && <span className="seed-conf">inferred · 0.74</span>}
|
{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>
|
||||||
<div className="seed-field-value">{f.value}</div>
|
|
||||||
</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>
|
||||||
|
|
||||||
<div className="seed-confidence">
|
<div className="seed-confidence">
|
||||||
<div className="seed-confidence-row">
|
<div className="seed-confidence-row">
|
||||||
<span>Model confidence</span>
|
<span>Draft confidence</span>
|
||||||
<span>0.62</span>
|
<span>{confidencePct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="seed-confidence-bar">
|
<div className="seed-confidence-bar">
|
||||||
<div className="seed-confidence-fill" style={{ width: "62%" }} />
|
<div className="seed-confidence-fill" style={{ width: `${confidencePct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="seed-confidence-hint">
|
<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>
|
</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>
|
</section>
|
||||||
|
|
||||||
{/* Right: Socrates conversation */}
|
|
||||||
<section className="seed-right">
|
<section className="seed-right">
|
||||||
<div className="seed-thread">
|
<div className="seed-thread">
|
||||||
{thread.map((m, i) => (
|
{history.map((m, i) => (
|
||||||
<div key={i} className={`seed-bubble seed-bubble-${m.who}`}>
|
<div key={i} className={`seed-bubble seed-bubble-${m.role}`}>
|
||||||
{m.who === "socrates" && (
|
{m.role === "socrates" && (
|
||||||
<div className="seed-bubble-avatar">
|
<div className="seed-bubble-avatar">
|
||||||
<Sigil size={32} />
|
<Sigil size={32} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="seed-bubble-body">
|
<div className="seed-bubble-body">
|
||||||
<div className="seed-bubble-who">{m.who === "socrates" ? "Socrates" : "You"}</div>
|
<div className="seed-bubble-who">{m.role === "socrates" ? "Socrates" : "You"}</div>
|
||||||
<div className="seed-bubble-text">{m.text}</div>
|
<div
|
||||||
|
className="seed-bubble-text"
|
||||||
|
style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}
|
||||||
|
>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div ref={threadEndRef} />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div className="seed-input-row">
|
<form
|
||||||
|
className="seed-input-row"
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
void onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="seed-input">
|
<div className="seed-input">
|
||||||
<span className="seed-input-prompt">›</span>
|
<span className="seed-input-prompt">›</span>
|
||||||
<span className="seed-input-text">
|
<input
|
||||||
Public-university undergrads, weeks 3–10. A partner that asks. The market is saturated with explainers.
|
className="seed-input-field"
|
||||||
</span>
|
value={input}
|
||||||
<span className="seed-input-caret" />
|
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>
|
||||||
<div className="seed-input-actions">
|
<div className="seed-input-actions">
|
||||||
<button className="seed-btn" type="button">Save draft</button>
|
<button
|
||||||
<button className="seed-btn seed-btn-primary" type="button">Send · ⌘↵</button>
|
type="submit"
|
||||||
|
className="seed-btn seed-btn-primary"
|
||||||
|
disabled={sending || generating || !input.trim()}
|
||||||
|
>
|
||||||
|
Send · ⌘↵
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</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 type { MarkupStyle } from "./Chip";
|
||||||
import { useChipFocus } from "./FocusContext";
|
import { useChipFocus } from "./FocusContext";
|
||||||
import { useModelStore } from "../../lib/sync/ModelStore";
|
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";
|
import { updateBlock, updateRequirement, updateAssociation } from "../../lib/sync/ops";
|
||||||
|
|
||||||
const KIND_LABEL: Record<ChipKind, string> = {
|
const KIND_LABEL: Record<ChipKind, string> = {
|
||||||
@@ -42,6 +44,8 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
|
|||||||
|
|
||||||
const { focusBlockId, setFocusBlockId } = useChipFocus();
|
const { focusBlockId, setFocusBlockId } = useChipFocus();
|
||||||
const { model, apply } = useModelStore();
|
const { model, apply } = useModelStore();
|
||||||
|
const { getTerm } = useAnalysis();
|
||||||
|
const { setStack } = useOpenPanes();
|
||||||
|
|
||||||
// Resolve the live label from the model, falling back to the node's stored
|
// 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).
|
// 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();
|
e.preventDefault();
|
||||||
if (refId) setIsEditing(true);
|
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.
|
// 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";
|
"use client";
|
||||||
|
|
||||||
import { useEditor, EditorContent } from "@tiptap/react";
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ChipNode } from "./ChipNode";
|
import { ChipNode } from "./ChipNode";
|
||||||
import { SlashExtension } from "./SlashExtension";
|
|
||||||
import { ChipFocusContext } from "./FocusContext";
|
import { ChipFocusContext } from "./FocusContext";
|
||||||
import { fixtureToDoc } from "./fixtureToDoc";
|
import { fixtureToDoc } from "./fixtureToDoc";
|
||||||
import type { Density } from "../socrates/SocratesDock";
|
import { ChipSuggestionExtension } from "./ChipSuggestionExtension";
|
||||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||||
import type { MarkupStyle } from "./Chip";
|
import type { MarkupStyle } from "./Chip";
|
||||||
|
import { useAnalysis } from "../../lib/workspace/analysisStore";
|
||||||
|
|
||||||
|
export type Density = "comfortable" | "compact";
|
||||||
|
|
||||||
interface TextCanvasProps {
|
interface TextCanvasProps {
|
||||||
data: FixtureData;
|
data: FixtureData;
|
||||||
density: Density;
|
projectId: string;
|
||||||
markupStyle: MarkupStyle;
|
density?: Density;
|
||||||
focusBlockId: string | null;
|
markupStyle?: MarkupStyle;
|
||||||
setFocusBlockId: (id: string | null) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 padY = density === "compact" ? 10 : 18;
|
||||||
const padX = density === "compact" ? 22 : 36;
|
const padX = density === "compact" ? 22 : 36;
|
||||||
const focusValue = useMemo(
|
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
||||||
() => ({ focusBlockId, setFocusBlockId }),
|
const [initialDoc, setInitialDoc] = useState<unknown | null>(null);
|
||||||
[focusBlockId, setFocusBlockId]
|
const [loaded, setLoaded] = useState(false);
|
||||||
);
|
const focusValue = useMemo(() => ({ focusBlockId, setFocusBlockId }), [focusBlockId, setFocusBlockId]);
|
||||||
|
|
||||||
const editor = useEditor({
|
const { terms } = useAnalysis();
|
||||||
immediatelyRender: false,
|
|
||||||
extensions: [
|
// Load the saved doc once on mount; fall back to the fixture if there is none.
|
||||||
StarterKit.configure({
|
useEffect(() => {
|
||||||
heading: { levels: [1, 2] },
|
let cancelled = false;
|
||||||
// Drop features we don't need yet
|
(async () => {
|
||||||
codeBlock: false,
|
try {
|
||||||
blockquote: false,
|
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/document`);
|
||||||
horizontalRule: false,
|
if (!res.ok) throw new Error(`load doc: ${res.status}`);
|
||||||
bulletList: false,
|
const body = (await res.json()) as { doc: unknown };
|
||||||
orderedList: false,
|
if (cancelled) return;
|
||||||
listItem: false,
|
setInitialDoc(body.doc ?? fixtureToDoc(data));
|
||||||
strike: false,
|
} catch {
|
||||||
code: false,
|
if (!cancelled) setInitialDoc(fixtureToDoc(data));
|
||||||
link: false,
|
} finally {
|
||||||
}),
|
if (!cancelled) setLoaded(true);
|
||||||
ChipNode,
|
}
|
||||||
SlashExtension,
|
})();
|
||||||
],
|
return () => {
|
||||||
content: fixtureToDoc(data),
|
cancelled = true;
|
||||||
editorProps: {
|
};
|
||||||
attributes: {
|
}, [projectId, data]);
|
||||||
class: "text-canvas tiptap",
|
|
||||||
style: `padding: ${padY}px ${padX}px;`,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
(editor.storage as unknown as Record<string, unknown>).markupStyle = markupStyle;
|
const storage = editor.storage as unknown as Record<string, unknown>;
|
||||||
// Force a re-render of all chip node views so they pick up the new style.
|
storage.markupStyle = markupStyle;
|
||||||
|
storage.terms = terms;
|
||||||
editor.view.dispatch(editor.state.tr.setMeta("force-update", true));
|
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 (
|
return (
|
||||||
<div className="text-canvas" style={{ padding: `${padY}px ${padX}px` }}>
|
<div className="text-canvas" style={{ padding: `${padY}px ${padX}px` }}>
|
||||||
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>loading editor…</div>
|
||||||
loading editor…
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,15 +131,6 @@ export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusB
|
|||||||
return (
|
return (
|
||||||
<ChipFocusContext.Provider value={focusValue}>
|
<ChipFocusContext.Provider value={focusValue}>
|
||||||
<EditorContent editor={editor} />
|
<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>
|
</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 ─────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function trimModel(model: SysMLModel): unknown {
|
function trimModel(model: SysMLModel): unknown {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ function anchorKey(anchor: ValidationIssue["anchor"]): string | null {
|
|||||||
case "constraint": return `constraint:${anchor.id}`;
|
case "constraint": return `constraint:${anchor.id}`;
|
||||||
case "requirement": return `req:${anchor.id}`;
|
case "requirement": return `req:${anchor.id}`;
|
||||||
case "property": return anchor.blockId;
|
case "property": return anchor.blockId;
|
||||||
|
case "term": return `term:${anchor.id}`;
|
||||||
case "model": return null;
|
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;
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Union ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ModelOp =
|
export type ModelOp =
|
||||||
@@ -148,7 +164,8 @@ export type ModelOp =
|
|||||||
| UpdateRequirementOp
|
| UpdateRequirementOp
|
||||||
| RemoveRequirementOp
|
| RemoveRequirementOp
|
||||||
| AddRelationOp
|
| AddRelationOp
|
||||||
| RemoveRelationOp;
|
| RemoveRelationOp
|
||||||
|
| DecideElementOp;
|
||||||
|
|
||||||
// ─── Op constructors (call sites stay readable) ─────────────────────────
|
// ─── Op constructors (call sites stay readable) ─────────────────────────
|
||||||
|
|
||||||
@@ -220,6 +237,12 @@ export function removeRelation(requirementId: string, relationIndex: number): Re
|
|||||||
return { kind: "remove-relation", requirementId, relationIndex };
|
return { kind: "remove-relation", requirementId, relationIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function decideElement(
|
||||||
|
element: { kind: ReviewableElementKind; id: string }
|
||||||
|
): DecideElementOp {
|
||||||
|
return { kind: "decide-element", element, decision: "keep" };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let counter = 0;
|
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";
|
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 =
|
export type PropertyType =
|
||||||
| { kind: "string" }
|
| { kind: "string" }
|
||||||
| { kind: "number" }
|
| { kind: "number" }
|
||||||
@@ -29,6 +41,14 @@ export interface Block {
|
|||||||
stereotypes: string[];
|
stereotypes: string[];
|
||||||
properties: Property[];
|
properties: Property[];
|
||||||
description?: string;
|
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 =
|
export type AssociationKind =
|
||||||
@@ -45,6 +65,12 @@ export interface Association {
|
|||||||
label: string;
|
label: string;
|
||||||
kind: AssociationKind;
|
kind: AssociationKind;
|
||||||
multiplicity?: { from: Multiplicity; to: Multiplicity };
|
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 {
|
export interface Constraint {
|
||||||
@@ -52,6 +78,10 @@ export interface Constraint {
|
|||||||
label: string;
|
label: string;
|
||||||
expression: string;
|
expression: string;
|
||||||
appliesTo: string[];
|
appliesTo: string[];
|
||||||
|
/// Term that conceptually names this rule (e.g. "Daily Cap", "Eligibility").
|
||||||
|
linkedTermId?: string;
|
||||||
|
reviewStatus?: ReviewStatus;
|
||||||
|
reviewPinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RequirementRelation =
|
export type RequirementRelation =
|
||||||
@@ -64,6 +94,12 @@ export interface Requirement {
|
|||||||
tag: string;
|
tag: string;
|
||||||
text: string;
|
text: string;
|
||||||
relations: RequirementRelation[];
|
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 {
|
export interface SysMLModel {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type IssueAnchor =
|
|||||||
| { kind: "constraint"; id: string }
|
| { kind: "constraint"; id: string }
|
||||||
| { kind: "requirement"; id: string }
|
| { kind: "requirement"; id: string }
|
||||||
| { kind: "property"; blockId: string; propertyId: string }
|
| { kind: "property"; blockId: string; propertyId: string }
|
||||||
|
| { kind: "term"; id: string }
|
||||||
| { kind: "model" };
|
| { kind: "model" };
|
||||||
|
|
||||||
export interface ValidationIssue {
|
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[]
|
changes ChangelogEntry[]
|
||||||
threads SocratesThread[]
|
threads SocratesThread[]
|
||||||
findings Finding[]
|
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 {
|
model ModelSnapshot {
|
||||||
@@ -106,8 +212,23 @@ model Finding {
|
|||||||
severity String?
|
severity String?
|
||||||
/// Inconsistencies only: optional structural-rule code (S1, M2, T1, …)
|
/// Inconsistencies only: optional structural-rule code (S1, M2, T1, …)
|
||||||
validationCode String?
|
validationCode String?
|
||||||
/// Lifecycle: "open" | "dismissed" | "resolved"
|
/// Lifecycle (review-aware):
|
||||||
status String @default("open")
|
/// "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.
|
/// Model version this finding was detected against.
|
||||||
modelVersion Int
|
modelVersion Int
|
||||||
/// Provider + model that emitted the finding.
|
/// Provider + model that emitted the finding.
|
||||||
@@ -120,6 +241,7 @@ model Finding {
|
|||||||
|
|
||||||
@@index([projectId, status])
|
@@index([projectId, status])
|
||||||
@@index([projectId, kind])
|
@@index([projectId, kind])
|
||||||
|
@@index([projectId, suggestionKey])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web research results from validating a finding via Tavily / similar.
|
/// 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;
|
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
|
||||||
--tracking: 0;
|
--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 */
|
--bg: #f5efe2; /* parchment */
|
||||||
--surface: #faf5e9;
|
--surface: #faf5e9;
|
||||||
--surface-2: #ede4cf;
|
--surface-2: #ede4cf;
|
||||||
|
|||||||
Reference in New Issue
Block a user