Files
Socrates/apps/web/lib/db/repo.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

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;
}