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>
1164 lines
38 KiB
TypeScript
1164 lines
38 KiB
TypeScript
// 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<LoadResult> {
|
|
// 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<string, string>;
|
|
errors: Array<{ opIndex: number; code: string; message: string }>;
|
|
}
|
|
|
|
export async function applyOpsToProject(
|
|
projectId: string,
|
|
ops: ModelOp[],
|
|
expectedVersion: number | undefined,
|
|
reason?: string
|
|
): Promise<ApplyResult> {
|
|
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<void> {
|
|
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<ProjectSummary[]> {
|
|
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<string> {
|
|
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<StoredFinding[]> {
|
|
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<StoredFinding[]> {
|
|
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<StoredFinding[]> {
|
|
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<StoredFinding | null> {
|
|
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<StoredFinding[]> {
|
|
return prisma.$transaction(async tx => {
|
|
const existing = await tx.finding.findMany({ where: { projectId } });
|
|
const existingByKey = new Map<string, (typeof existing)[number]>();
|
|
for (const e of existing) {
|
|
const key = e.suggestionKey ?? findingKey(e);
|
|
existingByKey.set(key, e);
|
|
}
|
|
|
|
const incomingByKey = new Map<string, { f: DetectedFinding; key: string }>();
|
|
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<StoredFinding | null> {
|
|
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<void> {
|
|
// 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:<id>` 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<StoredDocument | null> {
|
|
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<StoredDocument> {
|
|
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<StoredTerm[]> {
|
|
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<MergeTaxonomyResult> {
|
|
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<string, DetectedTerm>();
|
|
for (const t of terms) {
|
|
const k = t.label.trim().toLowerCase();
|
|
if (k && !incomingByLabel.has(k)) incomingByLabel.set(k, t);
|
|
}
|
|
|
|
const addedIds = new Set<string>();
|
|
const deprecatedIds = new Set<string>();
|
|
|
|
// 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<StoredTerm | null> {
|
|
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<string>();
|
|
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<void> {
|
|
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<StoredTerm | null> {
|
|
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<void> {
|
|
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<StoredRequirement[]> {
|
|
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<StoredRequirement[]> {
|
|
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<string, DetectedRequirement>();
|
|
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<StoredRequirement | null> {
|
|
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<string> {
|
|
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<void> {
|
|
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<Record<string, StoredAnalysisRun>> {
|
|
const rows = await prisma.analysisRun.findMany({
|
|
where: { projectId },
|
|
orderBy: { startedAt: "desc" },
|
|
});
|
|
const latest: Record<string, StoredAnalysisRun> = {};
|
|
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;
|
|
}
|