Files
Socrates/apps/web/lib/llm/analyze/runAll.ts
dtoro b55425cc68 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>
2026-05-01 00:12:06 +02:00

207 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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. X1X3 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];
}