diff --git a/apps/web/app/api/projects/[projectId]/findings/route.ts b/apps/web/app/api/projects/[projectId]/findings/route.ts new file mode 100644 index 0000000..57c641e --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/findings/route.ts @@ -0,0 +1,60 @@ +// GET /api/projects/[id]/findings — returns the project's open findings +// POST /api/projects/[id]/findings — runs detection, persists, returns the new set + +import { NextResponse } from "next/server"; +import { loadProject, listOpenFindings, replaceFindings } from "../../../../../lib/db/repo"; +import { detectFindings } from "../../../../../lib/llm/detect"; + +export async function GET(_req: Request, { params }: { params: Promise<{ projectId: string }> }) { + const { projectId } = await params; + const findings = await listOpenFindings(projectId); + return NextResponse.json({ + findings: findings.map(f => ({ + id: f.id, + kind: f.kind, + text: f.text, + linkedElementIds: f.linkedElementIds, + confidence: f.confidence, + severity: f.severity, + validationCode: f.validationCode, + modelVersion: f.modelVersion, + provider: f.provider, + llmModel: f.llmModel, + })), + }); +} + +export async function POST(_req: Request, { params }: { params: Promise<{ projectId: string }> }) { + const { projectId } = await params; + try { + const { model, version } = await loadProject(projectId); + const result = await detectFindings(model); + const stored = await replaceFindings(projectId, result.findings, version, result.provider, result.model); + + return NextResponse.json({ + findings: stored.map(f => ({ + id: f.id, + kind: f.kind, + text: f.text, + linkedElementIds: f.linkedElementIds, + confidence: f.confidence, + severity: f.severity, + validationCode: f.validationCode, + modelVersion: f.modelVersion, + provider: f.provider, + llmModel: f.llmModel, + })), + meta: { + provider: result.provider, + model: result.model, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + durationMs: result.durationMs, + strippedRefs: result.strippedRefs, + droppedFindings: result.droppedFindings, + }, + }); + } catch (err) { + return NextResponse.json({ error: (err as Error).message }, { status: 500 }); + } +} diff --git a/apps/web/components/editor/EditorShell.tsx b/apps/web/components/editor/EditorShell.tsx index cdc23df..b8afeec 100644 --- a/apps/web/components/editor/EditorShell.tsx +++ b/apps/web/components/editor/EditorShell.tsx @@ -11,6 +11,7 @@ import { LeftRail } from "./LeftRail"; import { CanvasHeader } from "./CanvasHeader"; import { StatusBar } from "./StatusBar"; import { IssuesPanel } from "./IssuesPanel"; +import { FindingsPanel } from "./FindingsPanel"; import { TextCanvas } from "../text-canvas/TextCanvas"; import { DiagramCanvas, type DiagramVariant } from "../diagram-canvas/DiagramCanvas"; import { SocratesDock, type Density, type SocratesPresence } from "../socrates/SocratesDock"; @@ -98,7 +99,7 @@ interface ShellBodyProps { function ShellBody({ data, projectId, density, markupStyle, diagramStyle, presence, breaks }: ShellBodyProps) { const [focusBlockId, setFocusBlockId] = useState(null); - const { model, issues, issuesByElement } = useModelStore(); + const { model, version, issues, issuesByElement } = useModelStore(); const stats = `SysML · ${model.blocks.length} blocks · ${model.associations.length} associations · ${model.constraints.length} constraints`; const subtitle = breaks.length > 0 ? `${stats} · breaks active: ${breaks.join(", ")}` : stats; @@ -161,6 +162,11 @@ function ShellBody({ data, projectId, density, markupStyle, diagramStyle, presen setFocusBlockId(id)} /> + setFocusBlockId(id)} + /> ); } diff --git a/apps/web/components/editor/FindingsPanel.tsx b/apps/web/components/editor/FindingsPanel.tsx new file mode 100644 index 0000000..e178dae --- /dev/null +++ b/apps/web/components/editor/FindingsPanel.tsx @@ -0,0 +1,187 @@ +// Findings panel — lists detected assumptions / risks / inconsistencies. +// Lives in the bottom-right corner above the IssuesPanel; collapsible; +// has a "detect" button that triggers a fresh background pass. +// +// Items are clickable: clicking focuses the first linked element across +// the rail + diagram (using the same setFocusBlockId path as the rail). + +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export type FindingKind = "assumption" | "risk" | "inconsistency"; + +export interface FindingDTO { + id: string; + kind: FindingKind; + text: string; + linkedElementIds: string[]; + confidence: number; + severity?: "low" | "medium" | "high" | null; + validationCode?: string | null; + modelVersion: number; + provider?: string | null; + llmModel?: string | null; +} + +interface FindingsPanelProps { + projectId: string; + /** Re-fetch whenever this changes. Pass the model version so we know when to refresh. */ + modelVersion: number; + onSelectAnchor?: (elementId: string) => void; +} + +const KIND_GLYPH: Record = { + assumption: "●", + risk: "▲", + inconsistency: "!", +}; + +const KIND_LABEL: Record = { + assumption: "asm", + risk: "risk", + inconsistency: "inc", +}; + +export function FindingsPanel({ projectId, modelVersion, onSelectAnchor }: FindingsPanelProps) { + const [findings, setFindings] = useState(null); + const [collapsed, setCollapsed] = useState(false); + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + const [meta, setMeta] = useState<{ provider?: string; model?: string; durationMs?: number; modelVersion?: number } | null>(null); + + // Initial load + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/findings`); + if (!res.ok) throw new Error(String(res.status)); + const data = (await res.json()) as { findings: FindingDTO[] }; + if (cancelled) return; + setFindings(data.findings); + if (data.findings.length > 0) { + const first = data.findings[0]!; + setMeta({ provider: first.provider ?? undefined, model: first.llmModel ?? undefined, modelVersion: first.modelVersion }); + } + } catch (err) { + if (!cancelled) setError((err as Error).message); + } + })(); + return () => { cancelled = true; }; + }, [projectId]); + + const detect = useCallback(async () => { + if (running) return; + setRunning(true); + setError(null); + try { + const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/findings`, { + method: "POST", + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `HTTP ${res.status}`); + } + const data = (await res.json()) as { + findings: FindingDTO[]; + meta: { provider: string; model: string; durationMs: number; strippedRefs: number; droppedFindings: number }; + }; + setFindings(data.findings); + setMeta({ + provider: data.meta.provider, + model: data.meta.model, + durationMs: data.meta.durationMs, + modelVersion, + }); + } catch (err) { + setError((err as Error).message); + } finally { + setRunning(false); + } + }, [projectId, modelVersion, running]); + + const counts = { + assumption: findings?.filter(f => f.kind === "assumption").length ?? 0, + risk: findings?.filter(f => f.kind === "risk").length ?? 0, + inconsistency: findings?.filter(f => f.kind === "inconsistency").length ?? 0, + }; + const total = counts.assumption + counts.risk + counts.inconsistency; + const stale = meta?.modelVersion !== undefined && meta.modelVersion !== modelVersion; + + return ( +
+ + + {!collapsed && ( + <> +
+ + {meta?.durationMs && ( + + {meta.provider} · {meta.model?.split("/").pop()} · {(meta.durationMs / 1000).toFixed(1)}s + + )} +
+ + {error &&
⚠ {error}
} + + {findings && findings.length > 0 && ( +
    + {(["inconsistency", "risk", "assumption"] as const).flatMap(kind => + findings.filter(f => f.kind === kind).map(f => ( +
  • { + if (f.linkedElementIds.length > 0 && onSelectAnchor) { + onSelectAnchor(f.linkedElementIds[0]!); + } + }} + style={{ cursor: f.linkedElementIds.length > 0 ? "pointer" : "default" }} + > + {KIND_GLYPH[f.kind]} + {KIND_LABEL[f.kind]} + {f.severity && {f.severity}} + {f.validationCode && {f.validationCode}} + {f.text} + {f.confidence.toFixed(2)} + {f.linkedElementIds.length > 0 && ( + + [{f.linkedElementIds.slice(0, 3).join(", ")}{f.linkedElementIds.length > 3 ? "…" : ""}] + + )} +
  • + )) + )} +
+ )} + + )} +
+ ); +} diff --git a/apps/web/lib/db/repo.ts b/apps/web/lib/db/repo.ts index 70c3b20..7ba7205 100644 --- a/apps/web/lib/db/repo.ts +++ b/apps/web/lib/db/repo.ts @@ -148,6 +148,114 @@ async function ensureSeeded(projectId: string): Promise<{ projectId: string }> { return { projectId }; } +// ─── 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; + status: string; + modelVersion: number; + provider: string | null; + llmModel: string | null; + createdAt: Date; +} + +export async function listOpenFindings(projectId: string): Promise { + const rows = await prisma.finding.findMany({ + where: { projectId, status: "open" }, + orderBy: [{ kind: "asc" }, { createdAt: "desc" }], + }); + return rows.map(rowToFinding); +} + +export async function replaceFindings( + projectId: string, + findings: DetectedFinding[], + modelVersion: number, + provider: string, + llmModel: string +): Promise { + return prisma.$transaction(async tx => { + // Wipe previous open findings — re-running detection supersedes them. + await tx.finding.deleteMany({ where: { projectId, status: "open" } }); + if (findings.length === 0) return []; + + const created = await Promise.all( + findings.map(f => + 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: "open", + modelVersion, + provider, + llmModel, + }, + }) + ) + ); + return created.map(rowToFinding); + }); +} + +export async function dismissFinding(findingId: string): Promise { + await prisma.finding.update({ + where: { id: findingId }, + data: { status: "dismissed" }, + }); +} + +interface FindingRow { + id: string; + kind: string; + text: string; + linkedElementIds: string; + confidence: number; + severity: string | null; + validationCode: string | null; + status: string; + 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 = []; + } + return { + id: row.id, + kind: row.kind, + text: row.text, + linkedElementIds: linked, + confidence: row.confidence, + severity: row.severity, + validationCode: row.validationCode, + status: row.status, + 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> { diff --git a/apps/web/lib/llm/detect.ts b/apps/web/lib/llm/detect.ts new file mode 100644 index 0000000..a8abe0f --- /dev/null +++ b/apps/web/lib/llm/detect.ts @@ -0,0 +1,301 @@ +// Background detection — runs three Phase-0-validated detection prompts +// against the current model and returns structured findings. +// +// Sequential not parallel: small local models share a KV-cache budget; three +// concurrent calls can OOM. Each call is independent so total wall time +// roughly equals 3× per-call time, but stability is much higher. +// +// Post-validation strips hallucinated element ids — the same defensive step +// that rescued ~5% of phase-0 detection runs. + +import "server-only"; +import { defaultGateway, chatJSON, type Message } from "./gateway"; +import { loadPrompt } from "./prompts"; +import type { SysMLModel } from "../sysml/model"; + +// ─── Output types ──────────────────────────────────────────────────────── + +export type FindingKind = "assumption" | "risk" | "inconsistency"; + +export interface Finding { + kind: FindingKind; + text: string; + linkedElementIds: string[]; + confidence: number; + severity?: "low" | "medium" | "high"; + validationCode?: string; +} + +export interface DetectResult { + findings: Finding[]; + inputTokens: number; + outputTokens: number; + durationMs: number; + strippedRefs: number; + droppedFindings: number; + provider: string; + model: string; +} + +// ─── Per-pass JSON schemas (oneOf-by-shape; small models cope) ────────── + +const baseProps = { + text: { type: "string", minLength: 1 }, + linkedElementIds: { type: "array", items: { type: "string" } }, + confidence: { type: "number", minimum: 0, maximum: 1 }, +}; + +const assumptionsJsonSchema = { + type: "object", + additionalProperties: false, + required: ["findings"], + properties: { + findings: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["text", "confidence"], + properties: baseProps, + }, + }, + }, +} as const; + +const risksJsonSchema = { + type: "object", + additionalProperties: false, + required: ["findings"], + properties: { + findings: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["text", "confidence", "severity"], + properties: { + ...baseProps, + severity: { type: "string", enum: ["low", "medium", "high"] }, + }, + }, + }, + }, +} as const; + +const inconsistenciesJsonSchema = { + type: "object", + additionalProperties: false, + required: ["findings"], + properties: { + findings: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["text", "confidence"], + properties: { + ...baseProps, + validationCode: { type: "string" }, + }, + }, + }, + }, +} as const; + +interface RawFinding { + text?: string; + linkedElementIds?: string[]; + confidence?: number; + severity?: "low" | "medium" | "high"; + validationCode?: string; +} + +// ─── Public API ────────────────────────────────────────────────────────── + +export async function detectFindings(model: SysMLModel): Promise { + const gateway = defaultGateway(); + const start = Date.now(); + let totalIn = 0; + let totalOut = 0; + + const userPayload = buildUserPayload(model); + + const assumptions = await runPass(gateway, "detect-assumptions", userPayload, assumptionsJsonSchema); + totalIn += assumptions.inputTokens; + totalOut += assumptions.outputTokens; + + const risks = await runPass(gateway, "detect-risks", userPayload, risksJsonSchema); + totalIn += risks.inputTokens; + totalOut += risks.outputTokens; + + const incons = await runPass(gateway, "detect-inconsistencies", userPayload, inconsistenciesJsonSchema); + totalIn += incons.inputTokens; + totalOut += incons.outputTokens; + + const raw: Finding[] = [ + ...assumptions.findings.map((f): Finding => ({ + kind: "assumption", + text: f.text ?? "", + linkedElementIds: f.linkedElementIds ?? [], + confidence: clamp01(f.confidence ?? 0.5), + })), + ...risks.findings.map((f): Finding => ({ + kind: "risk", + text: f.text ?? "", + linkedElementIds: f.linkedElementIds ?? [], + confidence: clamp01(f.confidence ?? 0.5), + severity: f.severity ?? "medium", + })), + ...incons.findings.map((f): Finding => ({ + kind: "inconsistency", + text: f.text ?? "", + linkedElementIds: f.linkedElementIds ?? [], + confidence: clamp01(f.confidence ?? 0.6), + validationCode: f.validationCode, + })), + ]; + + const validated = postValidate(raw, model); + + return { + findings: validated.findings, + inputTokens: totalIn, + outputTokens: totalOut, + durationMs: Date.now() - start, + strippedRefs: validated.strippedRefs, + droppedFindings: validated.droppedFindings, + provider: gateway.provider, + model: gateway.model, + }; +} + +// ─── Per-pass runner ───────────────────────────────────────────────────── + +interface PassResult { + findings: RawFinding[]; + inputTokens: number; + outputTokens: number; +} + +async function runPass( + gateway: ReturnType, + promptName: "detect-assumptions" | "detect-risks" | "detect-inconsistencies", + userPayload: string, + jsonSchema: object +): Promise { + const prompt = loadPrompt(`socrates/${promptName}.md`); + const messages: Message[] = [ + { role: "system", content: prompt }, + { role: "user", content: userPayload }, + ]; + + try { + const { value, result } = await chatJSON<{ findings?: RawFinding[] }>(gateway, messages, { + temperature: 0.2, + maxTokens: 768, + jsonSchema: { name: "findings", schema: jsonSchema as Record }, + jsonObjectMode: true, + maxRepairs: 2, + }); + return { + findings: Array.isArray(value?.findings) ? value.findings : [], + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + }; + } catch (err) { + // Fail-soft: a busted detection pass shouldn't take down the whole detect. + console.error(`[detect] ${promptName} pass failed:`, (err as Error).message); + return { findings: [], inputTokens: 0, outputTokens: 0 }; + } +} + +// ─── Trimmed payload (small models need tight context) ─────────────────── + +function buildUserPayload(model: SysMLModel): string { + const compact = { + blocks: model.blocks.map(b => ({ + id: b.id, + label: b.label, + kind: b.kind, + properties: b.properties.map(p => p.name), + })), + associations: model.associations.map(a => ({ + id: a.id, + from: a.fromBlockId, + to: a.toBlockId, + label: a.label, + kind: a.kind, + })), + constraints: model.constraints.map(c => ({ + id: c.id, + label: c.label, + appliesTo: c.appliesTo, + })), + requirements: model.requirements.map(r => ({ + id: r.id, + tag: r.tag, + text: r.text, + satisfiedBy: r.relations + .filter(rel => rel.kind === "satisfy") + .map(rel => (rel as { kind: "satisfy"; blockId: string }).blockId), + })), + }; + return `Model:\n\`\`\`json\n${JSON.stringify(compact, null, 2)}\n\`\`\`\n`; +} + +// ─── Post-validation: strip hallucinated element refs ──────────────────── + +interface ValidateOut { + findings: Finding[]; + strippedRefs: number; + droppedFindings: number; +} + +function postValidate(findings: Finding[], model: SysMLModel): ValidateOut { + const validIds = collectValidIds(model); + let stripped = 0; + let dropped = 0; + const out: Finding[] = []; + + for (const f of findings) { + if (!f.text || f.text.trim().length === 0) { + dropped++; + continue; + } + const goodRefs: string[] = []; + for (const ref of f.linkedElementIds) { + if (validIds.has(ref)) goodRefs.push(ref); + else stripped++; + } + // Drop only if it had refs and ALL of them were hallucinated. + if (f.linkedElementIds.length > 0 && goodRefs.length === 0) { + dropped++; + continue; + } + out.push({ ...f, linkedElementIds: goodRefs }); + } + return { findings: out, strippedRefs: stripped, droppedFindings: dropped }; +} + +function collectValidIds(model: SysMLModel): Set { + const ids = new Set(); + for (const b of model.blocks) { + ids.add(b.id); + for (const p of b.properties) { + ids.add(`${b.id}.${p.id}`); + ids.add(p.name); // tolerate bare property names — common LLM pattern + } + } + for (const a of model.associations) ids.add(a.id); + for (const c of model.constraints) ids.add(c.id); + for (const r of model.requirements) { + ids.add(r.id); + ids.add(r.tag); + } + return ids; +} + +function clamp01(n: number): number { + if (Number.isNaN(n)) return 0; + return Math.max(0, Math.min(1, n)); +} diff --git a/apps/web/lib/llm/prompts/socrates/detect-assumptions.md b/apps/web/lib/llm/prompts/socrates/detect-assumptions.md new file mode 100644 index 0000000..136eeef --- /dev/null +++ b/apps/web/lib/llm/prompts/socrates/detect-assumptions.md @@ -0,0 +1,50 @@ +# Detect implicit assumptions in a product seed and model + +You will receive a seed payload (JSON) and a generated model (JSON). Your job: surface the **implicit assumptions** the user is making — beliefs treated as true without explicit validation. + +## What is an assumption + +A measurable, falsifiable belief that underpins the idea but isn't stated as a requirement or constraint. Examples: + +- "Students will accept a tool that refuses to answer" — assumes adoption willingness +- "1.2s P50 latency is achievable on-prem with available models" — assumes technical feasibility +- "Faculty will not classify Socratic prompts as academic dishonesty" — assumes institutional acceptance + +## What is NOT an assumption + +- Stated requirements (REQ-NNN entries) — those are explicit goals +- Constraints — those are non-negotiables, not beliefs +- Definitions of terms +- Generic startup truisms ("users will want this") — too vague to be a useful assumption + +## Output + +Return a JSON object with a single field `findings` — an array of assumption candidates. Each candidate: + +- `text` — the assumption restated cleanly, in one sentence, in the user's register +- `linkedElementIds` — array of model element ids this assumption is about (block ids, requirement ids, or constraint ids — must match what's in the model) +- `confidence` — 0.0 to 1.0, how confident you are this is genuinely an unstated assumption + +## Rules + +- Return only candidates with `confidence ≥ 0.5` +- Cap at 8 findings +- Each assumption must name a SPECIFIC, falsifiable belief — not a generic concern +- Each must reference at least one real element id from the model +- If the seed is sparse and you cannot surface real assumptions, return fewer (or none) rather than fabricating + +## Schema + +```json +{ + "findings": [ + { + "text": "string", + "linkedElementIds": ["string"], + "confidence": 0.0 + } + ] +} +``` + +Return ONLY the JSON object. No prose, no code fences. diff --git a/apps/web/lib/llm/prompts/socrates/detect-inconsistencies.md b/apps/web/lib/llm/prompts/socrates/detect-inconsistencies.md new file mode 100644 index 0000000..48621fa --- /dev/null +++ b/apps/web/lib/llm/prompts/socrates/detect-inconsistencies.md @@ -0,0 +1,45 @@ +# Detect inconsistencies in a generated model + +You will receive a seed payload (JSON) and a generated model (JSON). Your job: find **inconsistencies** — internal contradictions or structural problems in the model. + +## Categories + +- **Internal contradictions** — two requirements that can't both hold simultaneously; a block whose properties contradict its kind; a constraint already violated by some property value +- **Reference issues** — an association whose endpoints don't make semantic sense (e.g., actor → constraint, or system → external actor with the wrong direction) +- **Over-broad claims** — a requirement that promises more than the system can deliver based on the blocks present +- **Missing satisfiers** — a requirement with no plausible block to satisfy it +- **Unused elements** — a block with no associations and no requirement satisfaction (may be dead) + +## Output + +Return a JSON object with a single field `findings`. Each candidate: + +- `text` — the inconsistency stated clearly in one sentence +- `linkedElementIds` — array of element ids involved +- `confidence` — 0.0 to 1.0 +- `validationCode` — optional. If the issue matches a structural rule, include the code: `M2` (cyclic composition), `T1` (untraced requirement), `T2` (unused element), `S1` (dangling association endpoint). Otherwise omit. + +## Rules + +- Return only candidates with `confidence ≥ 0.6` — for inconsistencies, false positives are worse than misses +- Cap at 6 findings +- An inconsistency must point to a SPECIFIC contradiction or structural defect, not a stylistic preference +- "This block has too many properties" is NOT an inconsistency +- "Requirement REQ-002 forbids what association A2 enables" IS an inconsistency + +## Schema + +```json +{ + "findings": [ + { + "text": "string", + "linkedElementIds": ["string"], + "confidence": 0.0, + "validationCode": "string (optional)" + } + ] +} +``` + +Return ONLY the JSON object. No prose, no code fences. diff --git a/apps/web/lib/llm/prompts/socrates/detect-risks.md b/apps/web/lib/llm/prompts/socrates/detect-risks.md new file mode 100644 index 0000000..92db66d --- /dev/null +++ b/apps/web/lib/llm/prompts/socrates/detect-risks.md @@ -0,0 +1,46 @@ +# Detect risks in a product seed and model + +You will receive a seed payload (JSON) and a generated model (JSON). Your job: surface **risks** — specific failure modes that could prevent the system from working as intended. + +## Risk categories + +- **Technical** — feasibility, performance, scaling +- **Market** — adoption, competitive dynamics, distribution +- **Execution** — team capability, timing, dependencies +- **Regulatory** — compliance, legal, privacy +- **External** — third-party reliance, geopolitical, supply + +## Output + +Return a JSON object with a single field `findings` — an array of risk candidates. Each candidate: + +- `text` — the risk restated as a specific failure mode in one sentence +- `linkedElementIds` — array of model element ids this risk implicates +- `severity` — `"low"`, `"medium"`, or `"high"` (impact-if-it-happens, NOT probability) +- `confidence` — 0.0 to 1.0, how confident you are this is a real risk worth tracking + +## Rules + +- Return only candidates with `confidence ≥ 0.5` +- Cap at 6 findings +- A risk must name a SPECIFIC failure mode tied to SPECIFIC element(s). "Won't work" is not a risk; "Latency target unachievable on consumer-grade hardware given 7B-param inference" is. +- Severity reflects what happens IF the risk materializes, not how likely it is. +- Each finding must reference at least one real element id from the model +- For vague seeds with weak models, return fewer findings rather than fabricated ones + +## Schema + +```json +{ + "findings": [ + { + "text": "string", + "linkedElementIds": ["string"], + "severity": "low" | "medium" | "high", + "confidence": 0.0 + } + ] +} +``` + +Return ONLY the JSON object. No prose, no code fences. diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 9812a56..1913805 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -26,6 +26,7 @@ model Project { snapshots ModelSnapshot[] changes ChangelogEntry[] threads SocratesThread[] + findings Finding[] } model ModelSnapshot { @@ -87,3 +88,55 @@ model SocratesMessage { @@index([threadId, createdAt]) } + +/// Background-detected finding (assumption / risk / inconsistency). +/// Findings are scoped to the model version that produced them; on every +/// detection run we delete the project's previous open findings and write +/// the new set so we don't accumulate stale ones across model edits. +model Finding { + id String @id @default(cuid()) + projectId String + /// "assumption" | "risk" | "inconsistency" + kind String + text String + /// JSON-encoded string[] of element ids this finding references. + linkedElementIds String + confidence Float + /// Risks only: "low" | "medium" | "high" + severity String? + /// Inconsistencies only: optional structural-rule code (S1, M2, T1, …) + validationCode String? + /// Lifecycle: "open" | "dismissed" | "resolved" + status String @default("open") + /// Model version this finding was detected against. + modelVersion Int + /// Provider + model that emitted the finding. + provider String? + llmModel String? + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + research ResearchFinding[] + + @@index([projectId, status]) + @@index([projectId, kind]) +} + +/// Web research results from validating a finding via Tavily / similar. +/// Deferred to a follow-up commit; schema is here so we don't have to +/// migrate again later. +model ResearchFinding { + id String @id @default(cuid()) + findingId String + query String + url String + title String? + snippet String? + /// "supports" | "contradicts" | "neutral" + stance String @default("neutral") + createdAt DateTime @default(now()) + + finding Finding @relation(fields: [findingId], references: [id], onDelete: Cascade) + + @@index([findingId]) +} diff --git a/apps/web/styles/base.css b/apps/web/styles/base.css index 0c0ae16..d650aed 100644 --- a/apps/web/styles/base.css +++ b/apps/web/styles/base.css @@ -1245,3 +1245,156 @@ button { font-family: inherit; } opacity: 0.4; cursor: not-allowed; } + +/* ─── Findings panel (M8) ─── */ +.findings-panel { + position: fixed; + /* Sit just above the IssuesPanel; both anchored bottom-right */ + bottom: 32px; + right: 412px; + width: 460px; + max-height: 50vh; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: 6px; + box-shadow: 0 8px 24px var(--shadow-strong); + display: flex; + flex-direction: column; + z-index: 50; + font-family: var(--font-body); + overflow: hidden; +} +.findings-panel-collapsed { max-height: none; } +.findings-panel-head { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + cursor: pointer; + font-family: inherit; + color: var(--fg); + width: 100%; + text-align: left; +} +.findings-panel-collapsed .findings-panel-head { border-bottom: none; } +.findings-panel-head:hover { background: var(--surface-2); } +.findings-panel-counts { display: flex; gap: 8px; } +.findings-count { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; +} +.findings-count-asm { color: var(--accent-strong); } +.findings-count-risk { color: var(--warn-strong); } +.findings-count-inc { color: var(--ok-strong); } +.findings-count-none { color: var(--muted); font-style: italic; } +.findings-panel-title { + font-family: var(--font-display); + font-weight: 500; + font-size: 13px; + flex: 1; +} +.findings-panel-stale { + margin-left: 6px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--warn-strong); +} +.findings-panel-caret { font-size: 9px; color: var(--muted); } + +.findings-panel-actions { + display: flex; align-items: center; gap: 10px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + background: var(--surface-2); +} +.findings-panel-btn { + padding: 3px 10px; + border-radius: 4px; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-on); + font-family: var(--font-mono); + font-size: 10.5px; + cursor: pointer; +} +.findings-panel-btn:hover:not(:disabled) { background: var(--accent-strong); border-color: var(--accent-strong); } +.findings-panel-btn:disabled { opacity: 0.45; cursor: wait; } +.findings-panel-meta { + font-family: var(--font-mono); + font-size: 10px; + color: var(--muted); +} +.findings-panel-error { + padding: 8px 12px; + background: var(--warn-soft); + color: var(--warn-strong); + font-family: var(--font-mono); + font-size: 11px; + border-bottom: 1px solid var(--border); +} + +.findings-panel-list { + list-style: none; + margin: 0; + padding: 4px; + overflow-y: auto; + flex: 1; +} +.findings-item { + display: grid; + grid-template-columns: 14px 28px auto auto 1fr auto auto; + gap: 6px; + align-items: baseline; + padding: 5px 8px; + border-radius: 4px; + font-size: 12px; + line-height: 1.4; +} +.findings-item:hover { background: var(--surface-2); } +.findings-item-glyph { + font-size: 11px; + text-align: center; +} +.findings-item-glyph-assumption { color: var(--accent-strong); } +.findings-item-glyph-risk { color: var(--warn-strong); } +.findings-item-glyph-inconsistency { color: var(--ok-strong); } +.findings-item-tag { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted-strong); + background: var(--surface-2); + padding: 1px 5px; + border-radius: 3px; +} +.findings-item-sev { + font-family: var(--font-mono); + font-size: 9.5px; + padding: 1px 5px; + border-radius: 3px; +} +.findings-item-sev-low { background: var(--surface-2); color: var(--muted-strong); } +.findings-item-sev-medium { background: var(--accent-soft); color: var(--accent-strong); } +.findings-item-sev-high { background: var(--warn-soft); color: var(--warn-strong); } +.findings-item-code { + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--muted-strong); + background: var(--surface-2); + padding: 1px 5px; + border-radius: 3px; +} +.findings-item-text { color: var(--prose); } +.findings-item-conf { + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--muted); +} +.findings-item-refs { + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--muted); +}