// 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 { 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 { 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 { 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 { 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 { 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 { 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]; }