// Project + model persistence layer. // // Stores the full SysMLModel as a JSON snapshot per version, plus a changelog // of applied ops. Reads return the latest snapshot; writes apply ops // server-side and write a new snapshot + changelog row atomically. import { prisma } from "./client"; import { applyOps } from "../sync/applyOps"; import { fromFixture } from "../sysml/fromFixture"; import { aristotleFixture } from "../fixtures/aristotle"; import { fixtureToDoc } from "../../components/text-canvas/fixtureToDoc"; import type { SysMLModel } from "../sysml/model"; import type { ModelOp } from "../sync/ops"; export interface LoadResult { model: SysMLModel; version: number; } export class ProjectNotFoundError extends Error { readonly projectId: string; constructor(projectId: string) { super(`Project "${projectId}" not found`); this.projectId = projectId; this.name = "ProjectNotFoundError"; } } export async function loadProject(projectId: string): Promise { // Auto-seed Aristotle ONLY for the literal "aristotle" id (the fixture demo). // Any other unknown id is an honest 404 — we don't want to silently spawn // a fake project from the wrong fixture. if (projectId === "aristotle") { await ensureAristotleSeeded(); } const snapshot = await prisma.modelSnapshot.findFirst({ where: { projectId }, orderBy: { version: "desc" }, }); if (!snapshot) throw new ProjectNotFoundError(projectId); return { model: JSON.parse(snapshot.json) as SysMLModel, version: snapshot.version, }; } export interface ApplyResult { applied: boolean; model: SysMLModel; version: number; idMapping: Record; errors: Array<{ opIndex: number; code: string; message: string }>; } export async function applyOpsToProject( projectId: string, ops: ModelOp[], expectedVersion: number | undefined, reason?: string ): Promise { return prisma.$transaction(async tx => { const latest = await tx.modelSnapshot.findFirst({ where: { projectId }, orderBy: { version: "desc" }, }); if (!latest) throw new Error(`No snapshot for project ${projectId}`); if (expectedVersion !== undefined && expectedVersion !== latest.version) { // Optimistic-concurrency miss; caller must resync. return { applied: false, model: JSON.parse(latest.json) as SysMLModel, version: latest.version, idMapping: {}, errors: [{ opIndex: -1, code: "VERSION_MISMATCH", message: `Expected version ${expectedVersion}, server is ${latest.version}` }], }; } const currentModel = JSON.parse(latest.json) as SysMLModel; const result = applyOps(currentModel, ops); if (!result.applied) { return { applied: false, model: currentModel, version: latest.version, idMapping: {}, errors: result.errors, }; } const newVersion = latest.version + 1; await tx.modelSnapshot.create({ data: { projectId, version: newVersion, json: JSON.stringify(result.model), }, }); await tx.changelogEntry.create({ data: { projectId, version: newVersion, ops: JSON.stringify(ops), reason, }, }); return { applied: true, model: result.model, version: newVersion, idMapping: result.idMapping, errors: [], }; }); } /** Replace the project's SysML model wholesale. Used by the Analyze model * pass — the analyzer has already merged with the current model so we just * persist the result as a fresh snapshot. */ export async function replaceModel( projectId: string, model: SysMLModel, reason: string ): Promise<{ version: number }> { return prisma.$transaction(async tx => { const latest = await tx.modelSnapshot.findFirst({ where: { projectId }, orderBy: { version: "desc" }, }); const newVersion = (latest?.version ?? 0) + 1; await tx.modelSnapshot.create({ data: { projectId, version: newVersion, json: JSON.stringify(model) }, }); await tx.changelogEntry.create({ data: { projectId, version: newVersion, ops: JSON.stringify([]), reason }, }); return { version: newVersion }; }); } // ─── Seeding ───────────────────────────────────────────────────────────── /** Idempotent: ensure the literal "aristotle" demo project + initial snapshot * + opening thread exist. Used both by the editor route on first /editor/aristotle * load and by the seed page so users always have at least one project to look at. */ export async function ensureAristotleSeeded(): Promise { const projectId = "aristotle"; const existing = await prisma.project.findUnique({ where: { id: projectId } }); const snapCount = existing ? await prisma.modelSnapshot.count({ where: { projectId } }) : 0; const docExisting = existing ? await prisma.narrativeDocument.findUnique({ where: { projectId } }) : null; if (existing && snapCount > 0 && docExisting) return; const seedModel = fromFixture(aristotleFixture); await prisma.project.upsert({ where: { id: projectId }, update: {}, create: { id: projectId, name: aristotleFixture.project.name, scope: aristotleFixture.project.scope, tagline: aristotleFixture.project.tagline, }, }); if (snapCount === 0) { await prisma.modelSnapshot.create({ data: { projectId, version: 1, json: JSON.stringify(seedModel) }, }); } if (!docExisting) { // Persist the fixture narrative as the canonical document so Analyze has // something to chew on for the demo project. Users edit it in the editor // after that. const doc = fixtureToDoc(aristotleFixture); await prisma.narrativeDocument.create({ data: { projectId, version: 1, doc: JSON.stringify(doc) }, }); } } // ─── Project list + create-from-seed ──────────────────────────────────── export interface ProjectSummary { id: string; name: string; scope: string; tagline: string; updatedAt: Date; latestVersion: number; } export async function listProjects(): Promise { const rows = await prisma.project.findMany({ orderBy: { updatedAt: "desc" }, include: { snapshots: { orderBy: { version: "desc" }, take: 1, select: { version: true } } }, }); return rows.map(r => ({ id: r.id, name: r.name, scope: r.scope, tagline: r.tagline, updatedAt: r.updatedAt, latestVersion: r.snapshots[0]?.version ?? 0, })); } export interface CreateProjectInput { name: string; scope: string; tagline: string; model: SysMLModel; } export async function createProjectFromSeed(input: CreateProjectInput): Promise<{ projectId: string; version: number }> { const id = await uniqueProjectId(input.name); await prisma.$transaction(async tx => { await tx.project.create({ data: { id, name: input.name, scope: input.scope, tagline: input.tagline, }, }); await tx.modelSnapshot.create({ data: { projectId: id, version: 1, json: JSON.stringify(input.model), }, }); await tx.socratesThread.create({ data: { projectId: id, title: `Active thread · ${input.name}`, status: "open", }, }); }); return { projectId: id, version: 1 }; } async function uniqueProjectId(name: string): Promise { const base = name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 40) || "project"; let candidate = base; let n = 2; while (await prisma.project.findUnique({ where: { id: candidate } })) { candidate = `${base}-${n}`; n++; } return candidate; } // ─── Findings (M8) ─────────────────────────────────────────────────────── import type { Finding as DetectedFinding } from "../llm/detect"; export interface StoredFinding { id: string; kind: string; text: string; linkedElementIds: string[]; confidence: number; severity: string | null; validationCode: string | null; /// "suggested" | "accepted" | "deprecated" | "dismissed" | "resolved". /// Legacy value "open" is treated equivalently to "accepted". status: string; pinned: boolean; modelVersion: number; provider: string | null; llmModel: string | null; createdAt: Date; } /** Findings the user should currently see (suggested / accepted / deprecated). * Excludes dismissed and resolved. */ export async function listVisibleFindings(projectId: string): Promise { const rows = await prisma.finding.findMany({ where: { projectId, status: { in: ["suggested", "accepted", "deprecated", "open"] }, }, orderBy: [{ kind: "asc" }, { createdAt: "desc" }], }); return rows.map(rowToFinding); } /** @deprecated kept for compat; prefer listVisibleFindings. */ export async function listOpenFindings(projectId: string): Promise { return listVisibleFindings(projectId); } /** Findings the user has discarded (dismissed or resolved). Used by the * Discarded drawer in FindingsPane so the user can recover or audit them. */ export async function listDiscardedFindings(projectId: string): Promise { const rows = await prisma.finding.findMany({ where: { projectId, status: { in: ["dismissed", "resolved"] }, }, orderBy: [{ kind: "asc" }, { createdAt: "desc" }], }); return rows.map(rowToFinding); } /** Reactivate a finding that was previously dismissed or resolved. Sets * status back to "accepted" + clears any pin so the next merge can re-assess. */ export async function restoreFinding(findingId: string): Promise { const cur = await prisma.finding.findUnique({ where: { id: findingId } }); if (!cur) return null; const row = await prisma.finding.update({ where: { id: findingId }, data: { status: "accepted", pinned: false }, }); return rowToFinding(row); } /** * Merge analyzer-detected findings as a *review*. Identity is the * `suggestionKey` (kind + normalized-text for LLM findings, validationCode + * elementId for cross-validation findings). Pre-existing rows from before * merge-with-review use status="open"; we treat them as accepted. */ export async function mergeFindingsSuggestion( projectId: string, findings: DetectedFinding[], modelVersion: number, provider: string, llmModel: string ): Promise { return prisma.$transaction(async tx => { const existing = await tx.finding.findMany({ where: { projectId } }); const existingByKey = new Map(); for (const e of existing) { const key = e.suggestionKey ?? findingKey(e); existingByKey.set(key, e); } const incomingByKey = new Map(); for (const f of findings) { const key = findingKey(f); if (!incomingByKey.has(key)) incomingByKey.set(key, { f, key }); } // Phase 1 — incoming findings. for (const [key, { f }] of incomingByKey.entries()) { const prior = existingByKey.get(key); if (!prior) { await tx.finding.create({ data: { projectId, kind: f.kind, text: f.text, linkedElementIds: JSON.stringify(f.linkedElementIds), confidence: f.confidence, severity: f.severity ?? null, validationCode: f.validationCode ?? null, status: "suggested", pinned: false, suggestionKey: key, modelVersion, provider, llmModel, }, }); } else { // The user has decided about this one already (dismissed/resolved/ // accepted) — leave the decision intact, just refresh detector // metadata. If it had been deprecated, flip back to suggested. const nextStatus = prior.status === "deprecated" ? "suggested" : prior.status; await tx.finding.update({ where: { id: prior.id }, data: { text: f.text, linkedElementIds: JSON.stringify(f.linkedElementIds), confidence: f.confidence, severity: f.severity ?? prior.severity, validationCode: f.validationCode ?? prior.validationCode, status: nextStatus, modelVersion, provider, llmModel, suggestionKey: key, }, }); } } // Phase 2 — findings the detector didn't see this run. for (const e of existing) { const key = e.suggestionKey ?? findingKey(e); if (incomingByKey.has(key)) continue; // Never reviewed AND analyzer dropped it → quietly delete. if (e.status === "suggested") { await tx.finding.delete({ where: { id: e.id } }); continue; } // Accepted/open and unpinned → mark deprecated (still re-decidable). if ((e.status === "accepted" || e.status === "open") && !e.pinned) { await tx.finding.update({ where: { id: e.id }, data: { status: "deprecated" }, }); } // dismissed / resolved / pinned → leave alone. } return (await tx.finding.findMany({ where: { projectId, status: { in: ["suggested", "accepted", "deprecated", "open"] }, }, orderBy: [{ kind: "asc" }, { createdAt: "desc" }], })).map(rowToFinding); }); } export type FindingDecision = "keep" | "discard" | "resolve"; /** Apply a user decision to a finding. * * keep — confirms the finding as a real thing the user is tracking. * From "suggested" → "accepted" (visible). From "deprecated" → * "accepted" + pinned (so re-detection doesn't re-deprecate). * discard — "dismissed". Stops surfacing AND tells future merges to skip it. * resolve — "resolved". Like dismiss but signals "I fixed it." */ export async function setFindingDecision( findingId: string, decision: FindingDecision ): Promise { const cur = await prisma.finding.findUnique({ where: { id: findingId } }); if (!cur) return null; let nextStatus: string; let pinned = cur.pinned; if (decision === "keep") { nextStatus = "accepted"; if (cur.status === "deprecated") pinned = true; } else if (decision === "discard") { nextStatus = "dismissed"; } else { nextStatus = "resolved"; } const row = await prisma.finding.update({ where: { id: findingId }, data: { status: nextStatus, pinned }, }); return rowToFinding(row); } export async function dismissFinding(findingId: string): Promise { // Back-compat with the legacy "dismiss" route. await setFindingDecision(findingId, "discard"); } /** Stable identity for a finding suggestion. LLM findings: kind + text; * cross-validation findings: validationCode + first linkedElement (which * is the element id or `term:` sentinel). */ function findingKey(f: { kind: string; text: string; validationCode?: string | null; linkedElementIds: string | string[]; }): string { const codes = (f.validationCode ?? "").trim(); if (codes) { const ids = Array.isArray(f.linkedElementIds) ? f.linkedElementIds : safeStringArray(f.linkedElementIds); const first = ids[0] ?? ""; return `${codes}:${first}`; } return `${f.kind}:${(f.text ?? "").trim().toLowerCase().replace(/\s+/g, " ")}`; } interface FindingRow { id: string; kind: string; text: string; linkedElementIds: string; confidence: number; severity: string | null; validationCode: string | null; status: string; pinned: boolean; modelVersion: number; provider: string | null; llmModel: string | null; createdAt: Date; } function rowToFinding(row: FindingRow): StoredFinding { let linked: string[] = []; try { const parsed = JSON.parse(row.linkedElementIds); if (Array.isArray(parsed)) linked = parsed.filter((x): x is string => typeof x === "string"); } catch { linked = []; } // Legacy "open" rows map to "accepted" for the new lifecycle. const status = row.status === "open" ? "accepted" : row.status; return { id: row.id, kind: row.kind, text: row.text, linkedElementIds: linked, confidence: row.confidence, severity: row.severity, validationCode: row.validationCode, status, pinned: !!row.pinned, modelVersion: row.modelVersion, provider: row.provider, llmModel: row.llmModel, createdAt: row.createdAt, }; } // ─── Socrates threads ──────────────────────────────────────────────────── export async function getActiveThread(projectId: string): Promise<{ id: string; title: string | null; messages: Array<{ id: string; role: string; content: string; createdAt: Date }> } | null> { const thread = await prisma.socratesThread.findFirst({ where: { projectId, status: "open" }, orderBy: { updatedAt: "desc" }, include: { messages: { orderBy: { createdAt: "asc" } } }, }); if (!thread) return null; return { id: thread.id, title: thread.title, messages: thread.messages.map(m => ({ id: m.id, role: m.role, content: m.content, createdAt: m.createdAt, })), }; } // ─── Narrative document ────────────────────────────────────────────────── export interface StoredDocument { doc: unknown; version: number; updatedAt: Date; } export async function loadDocument(projectId: string): Promise { const row = await prisma.narrativeDocument.findUnique({ where: { projectId } }); if (!row) return null; let doc: unknown = null; try { doc = JSON.parse(row.doc); } catch { doc = null; } return { doc, version: row.version, updatedAt: row.updatedAt }; } export async function saveDocument(projectId: string, doc: unknown): Promise { const json = JSON.stringify(doc); const row = await prisma.narrativeDocument.upsert({ where: { projectId }, create: { projectId, doc: json, version: 1 }, update: { doc: json, version: { increment: 1 } }, }); return { doc, version: row.version, updatedAt: row.updatedAt }; } // ─── Taxonomy ──────────────────────────────────────────────────────────── export type TermStatus = "accepted" | "suggested" | "deprecated"; export interface StoredTerm { id: string; parentId: string | null; label: string; definition: string | null; synonyms: string[]; linkedBlockId: string | null; modelVersion: number; /// Review state (see prisma/schema.prisma comment). status: TermStatus; /// User explicitly preserved → don't re-deprecate on next merge. pinned: boolean; /// User authored/edited the definition → future Analyze runs skip it. definitionPinned: boolean; } export interface DetectedTerm { id?: string; // optional, allows analyzer to keep stable ids across runs parentLabel?: string | null; label: string; definition?: string | null; synonyms?: string[]; } export async function listTerms(projectId: string): Promise { const rows = await prisma.taxonomyTerm.findMany({ where: { projectId }, orderBy: [{ parentId: "asc" }, { label: "asc" }], }); return rows.map(rowToTerm); } export interface MergeTaxonomyResult { added: StoredTerm[]; deprecated: StoredTerm[]; kept: StoredTerm[]; all: StoredTerm[]; } /** * Merge analyzer output into the project's taxonomy as a *review* — never a * destructive replace. Existing user-accepted terms are preserved; terms the * analyzer didn't see are flagged "deprecated" (unless `pinned`); brand-new * terms are inserted as "suggested." The user can then keep or discard each * pending change via setTermDecision. * * Behaviour by case (matched on lowercased label): * - existing accepted, in incoming → label/synonyms/definition gently * updated (synonyms unioned; definition only filled when previously * empty), status stays "accepted". * - existing suggested, in incoming → updated and stays "suggested". * - existing deprecated, in incoming → flips back to "suggested" so the * user can re-decide (the analyzer changed its mind). * - existing accepted, NOT in incoming, NOT pinned → flipped to * "deprecated". * - existing accepted, NOT in incoming, pinned → left alone. * - existing suggested, NOT in incoming → deleted (user never reviewed and * analyzer no longer believes; quietly drop). * - new label not in existing → inserted as "suggested". */ export async function mergeTaxonomySuggestion( projectId: string, terms: DetectedTerm[], modelVersion: number ): Promise { return prisma.$transaction(async tx => { const existing = await tx.taxonomyTerm.findMany({ where: { projectId } }); const existingByLabel = new Map(existing.map(e => [e.label.toLowerCase(), e])); const incomingByLabel = new Map(); for (const t of terms) { const k = t.label.trim().toLowerCase(); if (k && !incomingByLabel.has(k)) incomingByLabel.set(k, t); } const addedIds = new Set(); const deprecatedIds = new Set(); // Phase 1 — handle each incoming term. for (const [k, t] of incomingByLabel.entries()) { const prior = existingByLabel.get(k); if (!prior) { // Truly new → suggested. const created = await tx.taxonomyTerm.create({ data: { projectId, parentId: null, // patched in phase 3 label: t.label, definition: t.definition ?? null, synonyms: JSON.stringify(t.synonyms ?? []), linkedBlockId: null, modelVersion, status: "suggested", pinned: false, }, }); addedIds.add(created.id); } else { // Existing — gentle update + status transitions. const mergedSynonyms = mergeSynonyms(safeStringArray(prior.synonyms), t.synonyms ?? []); // Definition pin: when the user has authored/edited the definition // by hand, the analyzer's emit is dropped on the floor. Otherwise // the gentle-update rule applies (existing wins; LLM only fills in // when prior was empty). const newDefinition = prior.definitionPinned ? prior.definition : prior.definition || t.definition || null; const nextStatus = prior.status === "deprecated" ? "suggested" : prior.status; await tx.taxonomyTerm.update({ where: { id: prior.id }, data: { // Don't change the canonical label spelling — that would alter // the user-facing identity. (Analyzer can rephrase tomorrow.) definition: newDefinition, synonyms: JSON.stringify(mergedSynonyms), modelVersion, status: nextStatus, }, }); if (nextStatus === "suggested" && prior.status !== "suggested") addedIds.add(prior.id); } } // Phase 2 — terms the analyzer didn't see this run. for (const e of existing) { const k = e.label.toLowerCase(); if (incomingByLabel.has(k)) continue; if (e.status === "suggested") { // Never reviewed and analyzer dropped it — quiet delete. await tx.taxonomyTerm.delete({ where: { id: e.id } }); continue; } if (e.status === "accepted" && !e.pinned) { await tx.taxonomyTerm.update({ where: { id: e.id }, data: { status: "deprecated" }, }); deprecatedIds.add(e.id); } // pinned/deprecated/accepted-pinned → leave as is. } // Phase 3 — re-resolve parent pointers from analyzer payload (label-based). const allRows = await tx.taxonomyTerm.findMany({ where: { projectId } }); const labelToId = new Map(allRows.map(r => [r.label.toLowerCase(), r.id])); for (const [k, t] of incomingByLabel.entries()) { const childId = labelToId.get(k); if (!childId) continue; const parentLabel = t.parentLabel?.toLowerCase(); const parentId = parentLabel ? labelToId.get(parentLabel) ?? null : null; // Only update when the parent actually changes — keeps the field stable // when the user has manually re-parented (future feature). const cur = allRows.find(r => r.id === childId); if (cur && cur.parentId !== parentId && childId !== parentId) { await tx.taxonomyTerm.update({ where: { id: childId }, data: { parentId } }); } } const finalRows = await tx.taxonomyTerm.findMany({ where: { projectId }, orderBy: [{ parentId: "asc" }, { label: "asc" }], }); const allTerms = finalRows.map(rowToTerm); return { added: allTerms.filter(t => addedIds.has(t.id)), deprecated: allTerms.filter(t => deprecatedIds.has(t.id)), kept: allTerms.filter(t => !addedIds.has(t.id) && !deprecatedIds.has(t.id)), all: allTerms, }; }); } export type TermDecision = "keep" | "discard"; /** * Apply a user decision to a pending (suggested or deprecated) term. * * - keep → status="accepted". For deprecated terms also sets pinned=true * so the next merge doesn't re-deprecate them. * - discard → row deleted. * * Returns the resulting StoredTerm, or null if the row was deleted. */ export async function setTermDecision( termId: string, decision: TermDecision ): Promise { if (decision === "discard") { await prisma.taxonomyTerm.delete({ where: { id: termId } }); return null; } const cur = await prisma.taxonomyTerm.findUnique({ where: { id: termId } }); if (!cur) return null; const pinned = cur.status === "deprecated" ? true : cur.pinned; const row = await prisma.taxonomyTerm.update({ where: { id: termId }, data: { status: "accepted", pinned }, }); return rowToTerm(row); } function mergeSynonyms(prev: string[], incoming: string[]): string[] { const seen = new Set(); const out: string[] = []; for (const s of [...prev, ...incoming]) { const k = s.trim().toLowerCase(); if (!k || seen.has(k)) continue; seen.add(k); out.push(s.trim()); } return out; } /** Patch glossary definitions onto existing terms (label-keyed). The * glossary analyze pass runs after taxonomy and just enriches it. Skips * terms whose definition the user has pinned (manually authored/edited) * AND skips terms that already have a definition — gentle update only. */ export async function applyGlossaryDefinitions( projectId: string, defs: Array<{ label: string; definition: string }> ): Promise { const map = new Map(defs.map(d => [d.label.toLowerCase(), d.definition])); const rows = await prisma.taxonomyTerm.findMany({ where: { projectId }, select: { id: true, label: true, definition: true, definitionPinned: true }, }); for (const r of rows) { if (r.definitionPinned) continue; if (r.definition && r.definition.length > 0) continue; const def = map.get(r.label.toLowerCase()); if (def) { await prisma.taxonomyTerm.update({ where: { id: r.id }, data: { definition: def } }); } } } /** * Set a term's definition by user action. Non-empty input is treated as a * manual edit and pins the definition (subsequent Analyze runs leave it * alone). An empty/whitespace input clears the definition AND the pin — * the "Reset to AI suggestion" path, after which the next Analyze fills * the definition from prose like a fresh term. */ export async function setTermDefinition( termId: string, definition: string | null ): Promise { const trimmed = (definition ?? "").trim(); if (trimmed.length === 0) { const row = await prisma.taxonomyTerm.update({ where: { id: termId }, data: { definition: null, definitionPinned: false }, }); return rowToTerm(row); } const row = await prisma.taxonomyTerm.update({ where: { id: termId }, data: { definition: trimmed, definitionPinned: true }, }); return rowToTerm(row); } export async function linkTermToBlock(termId: string, blockId: string | null): Promise { await prisma.taxonomyTerm.update({ where: { id: termId }, data: { linkedBlockId: blockId }, }); } interface TermRow { id: string; parentId: string | null; label: string; definition: string | null; synonyms: string; linkedBlockId: string | null; modelVersion: number; status: string; pinned: boolean; definitionPinned: boolean; } function rowToTerm(row: TermRow): StoredTerm { let synonyms: string[] = []; try { const parsed = JSON.parse(row.synonyms); if (Array.isArray(parsed)) synonyms = parsed.filter((s): s is string => typeof s === "string"); } catch { /* keep [] */ } const status: TermStatus = row.status === "suggested" || row.status === "deprecated" ? row.status : "accepted"; return { id: row.id, parentId: row.parentId, label: row.label, definition: row.definition, synonyms, linkedBlockId: row.linkedBlockId, modelVersion: row.modelVersion, status, pinned: !!row.pinned, definitionPinned: !!row.definitionPinned, }; } // ─── Requirements ──────────────────────────────────────────────────────── export type RequirementStatus = "accepted" | "suggested" | "deprecated"; export interface StoredRequirement { id: string; tag: string; text: string; tracedToIds: string[]; unsupported: boolean; modelVersion: number; /// Term this requirement is conceptually about (T2 integration). Null when /// the analyzer / promote-action didn't pick a term anchor. linkedTermId: string | null; status: RequirementStatus; pinned: boolean; } export interface DetectedRequirement { tag: string; text: string; tracedToIds?: string[]; unsupported?: boolean; linkedTermId?: string | null; } export async function listRequirements(projectId: string): Promise { const rows = await prisma.requirementEntry.findMany({ where: { projectId }, orderBy: { tag: "asc" }, }); return rows.map(rowToRequirement); } /** * Merge analyzer output into the project's requirements as a *review*. * Same shape as mergeTaxonomySuggestion: never destructive, surfaces new * suggestions and deprecated rows for the user to keep or discard. * * Identity match is by **normalized text** (lowercased, whitespace-collapsed). * Tags (REQ-NNN) aren't stable across runs because the analyzer renumbers, * so text-equality is the safer key. */ export async function mergeRequirementsSuggestion( projectId: string, reqs: DetectedRequirement[], modelVersion: number ): Promise { return prisma.$transaction(async tx => { const existing = await tx.requirementEntry.findMany({ where: { projectId } }); const existingByKey = new Map(existing.map(e => [normalizeText(e.text), e])); const incomingByKey = new Map(); for (const r of reqs) { const k = normalizeText(r.text); if (k && !incomingByKey.has(k)) incomingByKey.set(k, r); } // Phase 1 — handle each incoming requirement. for (const [k, r] of incomingByKey.entries()) { const prior = existingByKey.get(k); if (!prior) { await tx.requirementEntry.create({ data: { projectId, tag: r.tag, text: r.text, tracedToIds: JSON.stringify(r.tracedToIds ?? []), unsupported: r.unsupported ?? (r.tracedToIds ?? []).length === 0, modelVersion, linkedTermId: r.linkedTermId ?? null, status: "suggested", pinned: false, }, }); } else { const nextStatus = prior.status === "deprecated" ? "suggested" : prior.status; await tx.requirementEntry.update({ where: { id: prior.id }, data: { // Refresh the analyzer-derived fields; preserve the canonical text // (matched-on key) and user-set state. tag: r.tag || prior.tag, tracedToIds: JSON.stringify(r.tracedToIds ?? safeStringArray(prior.tracedToIds)), unsupported: r.unsupported ?? (r.tracedToIds ?? []).length === 0, modelVersion, linkedTermId: r.linkedTermId ?? prior.linkedTermId ?? null, status: nextStatus, }, }); } } // Phase 2 — requirements analyzer didn't see. for (const e of existing) { const k = normalizeText(e.text); if (incomingByKey.has(k)) continue; if (e.status === "suggested") { await tx.requirementEntry.delete({ where: { id: e.id } }); continue; } if ((e.status === "accepted" || e.status === "open") && !e.pinned) { await tx.requirementEntry.update({ where: { id: e.id }, data: { status: "deprecated" }, }); } } const finalRows = await tx.requirementEntry.findMany({ where: { projectId }, orderBy: { tag: "asc" }, }); return finalRows.map(rowToRequirement); }); } export type RequirementDecision = "keep" | "discard"; export async function setRequirementDecision( reqId: string, decision: RequirementDecision ): Promise { if (decision === "discard") { await prisma.requirementEntry.delete({ where: { id: reqId } }); return null; } const cur = await prisma.requirementEntry.findUnique({ where: { id: reqId } }); if (!cur) return null; const pinned = cur.status === "deprecated" ? true : cur.pinned; const row = await prisma.requirementEntry.update({ where: { id: reqId }, data: { status: "accepted", pinned }, }); return rowToRequirement(row); } interface RequirementRow { id: string; tag: string; text: string; tracedToIds: string; unsupported: boolean; modelVersion: number; linkedTermId: string | null; status: string; pinned: boolean; } function rowToRequirement(r: RequirementRow): StoredRequirement { const status: RequirementStatus = r.status === "suggested" || r.status === "deprecated" ? r.status : "accepted"; return { id: r.id, tag: r.tag, text: r.text, tracedToIds: safeStringArray(r.tracedToIds), unsupported: r.unsupported, modelVersion: r.modelVersion, linkedTermId: r.linkedTermId ?? null, status, pinned: !!r.pinned, }; } function normalizeText(s: string): string { return s.trim().toLowerCase().replace(/\s+/g, " "); } function safeStringArray(s: string): string[] { try { const parsed = JSON.parse(s); if (Array.isArray(parsed)) return parsed.filter((x): x is string => typeof x === "string"); } catch { /* fall through */ } return []; } // ─── Analysis runs ─────────────────────────────────────────────────────── export type AnalysisSection = | "concepts" | "model" | "requirements" | "assumptions" | "risks" | "inconsistencies" | "all"; /** Back-compat: callers (older clients, scripts) may still pass "taxonomy" or * "glossary". Both map to the unified "concepts" pass. */ export function normalizeAnalysisSection(s: string): AnalysisSection { if (s === "taxonomy" || s === "glossary") return "concepts"; if ( s === "concepts" || s === "model" || s === "requirements" || s === "assumptions" || s === "risks" || s === "inconsistencies" || s === "all" ) { return s; } throw new Error(`Unknown analysis section: ${s}`); } export interface StoredAnalysisRun { id: string; section: AnalysisSection; status: "running" | "succeeded" | "failed"; startedAt: Date; finishedAt: Date | null; modelVersion: number; inputTokens: number | null; outputTokens: number | null; errorMessage: string | null; } export async function startAnalysisRun( projectId: string, section: AnalysisSection, modelVersion: number ): Promise { const row = await prisma.analysisRun.create({ data: { projectId, section, status: "running", modelVersion }, }); return row.id; } export async function finishAnalysisRun( runId: string, patch: { status: "succeeded" | "failed"; inputTokens?: number; outputTokens?: number; errorMessage?: string } ): Promise { await prisma.analysisRun.update({ where: { id: runId }, data: { status: patch.status, finishedAt: new Date(), inputTokens: patch.inputTokens ?? null, outputTokens: patch.outputTokens ?? null, errorMessage: patch.errorMessage ?? null, }, }); } export async function listLatestAnalysisRuns(projectId: string): Promise> { const rows = await prisma.analysisRun.findMany({ where: { projectId }, orderBy: { startedAt: "desc" }, }); const latest: Record = {}; for (const r of rows) { if (latest[r.section]) continue; latest[r.section] = { id: r.id, section: r.section as AnalysisSection, status: r.status as StoredAnalysisRun["status"], startedAt: r.startedAt, finishedAt: r.finishedAt, modelVersion: r.modelVersion, inputTokens: r.inputTokens, outputTokens: r.outputTokens, errorMessage: r.errorMessage, }; } return latest; }