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>
207 lines
7.4 KiB
TypeScript
207 lines
7.4 KiB
TypeScript
// 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];
|
||
}
|