diff --git a/app/(app)/workspaces/[id]/learning-map/page.tsx b/app/(app)/workspaces/[id]/learning-map/page.tsx index fbe3fb9..a3547dd 100644 --- a/app/(app)/workspaces/[id]/learning-map/page.tsx +++ b/app/(app)/workspaces/[id]/learning-map/page.tsx @@ -1,6 +1,7 @@ import { getLearningMapByWorkspaceId } from "@/lib/services/learning-map.service"; import { getWorkspaceById } from "@/lib/services/workspace.service"; import { getConceptLinksByWorkspaceId } from "@/lib/services/concept-link.service"; +import { getQuestionsByWorkspaceId } from "@/lib/services/explain-back.service"; import { buildKnowledgeGraph, type KnowledgeGraphData } from "@/lib/services/knowledge-graph.service"; import { notFound } from "next/navigation"; import { ArrowLeft, CheckSquare, Square, Sparkles, FileText } from "lucide-react"; @@ -33,10 +34,11 @@ export default async function LearningMapPage({ const { id: workspaceId } = await params; const { q } = await searchParams; - const [wsResult, mapResult, clResult, indexed] = await Promise.all([ + const [wsResult, mapResult, clResult, qResult, indexed] = await Promise.all([ getWorkspaceById(workspaceId), getLearningMapByWorkspaceId(workspaceId), getConceptLinksByWorkspaceId(workspaceId), + getQuestionsByWorkspaceId(workspaceId), hasSearchIndex(workspaceId), ]); @@ -50,6 +52,19 @@ export default async function LearningMapPage({ const workspace = wsResult.data; const map = mapResult.ok ? mapResult.data : null; const conceptLinks = clResult.ok ? clResult.data : []; + const questions = qResult.ok ? qResult.data : []; + + // Per-module related counts (#27/#28) — group the flat question/concept lists + // by moduleKey so each module card shows what belongs to it. + const relatedByModule = new Map(); + const bump = (key: string | null, kind: "questions" | "concepts") => { + if (!key) return; + const cur = relatedByModule.get(key) ?? { questions: 0, concepts: 0 }; + cur[kind]++; + relatedByModule.set(key, cur); + }; + for (const q of questions) bump(q.moduleKey, "questions"); + for (const cl of conceptLinks) bump(cl.moduleKey, "concepts"); const completedCheckpoints = map?.checkpoints.filter((c) => c.completed).length ?? 0; const totalCheckpoints = map?.checkpoints.length ?? 0; @@ -261,6 +276,7 @@ export default async function LearningMapPage({ module={mod} mapId={map.id} workspaceId={workspaceId} + related={relatedByModule.get(mod.key)} /> ))} diff --git a/components/learning-map/ModuleCard.tsx b/components/learning-map/ModuleCard.tsx index 1fd9dd5..8819155 100644 --- a/components/learning-map/ModuleCard.tsx +++ b/components/learning-map/ModuleCard.tsx @@ -3,7 +3,8 @@ import { deleteModuleAction, } from "@/app/(app)/workspaces/[id]/learning-map/actions"; import type { LearningModule } from "@/lib/services/learning-map.service"; -import { Trash2 } from "lucide-react"; +import Link from "next/link"; +import { Trash2, HelpCircle, Lightbulb } from "lucide-react"; const DIFFICULTY_COLOR = { beginner: "text-emerald-400 bg-emerald-500/10", @@ -21,12 +22,15 @@ interface ModuleCardProps { module: LearningModule; mapId: string; workspaceId: string; + /** Cross-surface counts (#27/#28): questions + concepts tagged to this module. */ + related?: { questions: number; concepts: number }; } export default function ModuleCard({ module, mapId, workspaceId, + related, }: ModuleCardProps) { return (
@@ -91,6 +95,30 @@ export default function ModuleCard({
)} + {/* Related surfaces (#27/#28) — this module's questions + concepts */} + {related && (related.questions > 0 || related.concepts > 0) && ( +
+ {related.questions > 0 && ( + + + {related.questions} question{related.questions === 1 ? "" : "s"} + + )} + {related.concepts > 0 && ( + + + {related.concepts} concept{related.concepts === 1 ? "" : "s"} + + )} +
+ )} + {/* Confidence row */}

Understanding level

diff --git a/docs/COUNCIL_REVIEW_LOG.md b/docs/COUNCIL_REVIEW_LOG.md index 7d4a1c6..e3c84e6 100644 --- a/docs/COUNCIL_REVIEW_LOG.md +++ b/docs/COUNCIL_REVIEW_LOG.md @@ -569,5 +569,5 @@ Given 44 findings across two audits (many overlapping), what is the correct orde | REVIEW-008 | Whole-project review — polish sprint: GitHub-pull on refresh, blended progress formula (#26), conceptType form enum (#30); defer cohesion/search/graph | ✅ Implemented — 10/10 tests | Pre-1.0 | | REVIEW-009 | Landing strategy — preserve 15 commits (rebase-and-merge, no squash); skip paid AI smoke test; F2/F4/F5 cleanup as 3 free commits | ✅ Implemented — cleanup done | Pre-1.0 | | REVIEW-010 | CodeSensei program — visible-identity rename, 8-lens local senior-review engine (findings-only optional AI), Obsidian-grade graph | ✅ Implemented — 16/16 tests | v0.2 | -| REVIEW-011 | Road to deployed 1.0 — single-problem megasprints (MS1–MS7) + full deep rename to NirmiqCodeSensei; load-balancing recorded N/A | 🔄 In progress (MS1✅ MS2✅ MS3✅) | v0.2→1.0 | -| REVIEW-012 | MS3 cross-feature module links (#27/#28) — defer: no analyzer-produced module associations to populate an FK; revisit in MS4 as a soft `moduleId` column | ✅ Deferred (reasoned) | v0.2→1.0 | +| REVIEW-011 | Road to deployed 1.0 — single-problem megasprints (MS1–MS7) + full deep rename to NirmiqCodeSensei; load-balancing recorded N/A | 🔄 In progress (MS1✅ MS2✅ MS3✅ MS4✅) | v0.2→1.0 | +| REVIEW-012 | MS3 cross-feature module links (#27/#28) — deferred from MS3, then **built in MS4** as a soft `module_key` column with deterministic tagging on both AI + offline paths (no AI-schema dependency) | ✅ Implemented (MS4) | v0.2→1.0 | diff --git a/docs/MEGASPRINT_ROADMAP.md b/docs/MEGASPRINT_ROADMAP.md index 0a93657..45d04c7 100644 --- a/docs/MEGASPRINT_ROADMAP.md +++ b/docs/MEGASPRINT_ROADMAP.md @@ -19,7 +19,7 @@ free + BYOK path (`ANTHROPIC_API_KEY` optional; offline analyzer is the default) | **MS1** ✅ | Identity | Final distribution-ready name everywhere, incl. DB file / env / MCP internals, done once and safely | **DONE** — deep rename shipped; repo → `SheeshDarth/NirmiqCodeSensei`; DB boot-migration + `NCS_*` env fallback + `ncs_*` tools; gate green (16/16) | | **MS2** ✅ | Security | The app ingests users' private source code — it must be provably safe | **DONE** — symlink-confined walk, shell-free git, realpath+credential-dir blocks, prod CSP no `unsafe-eval`, `npm audit` critical-gate; self-scan security lens **A/100** | | **MS3** ✅ | Architecture & Data Integrity | Kill load-bearing hacks (description-as-path, no backup) | **DONE** — `sourcePath` column (migration 0008) retires the "Imported from:" hack; DB durability (`synchronous=NORMAL`, `busy_timeout`, boot integrity check, WAL checkpoint on exit) + downloadable backup; workspace error/loading boundaries; graph reconciliation already handled (`graphJson ?? buildKnowledgeGraph`). **#27/#28 module FKs deferred to MS4** (no analyzer-produced associations to populate them — REVIEW-012). Gate green (19/19) | -| **MS4** 🔄 | Algorithms & Analysis Depth | The analysis *is* the product — make it rigorous and calibrated | **In progress** — ✅ codeHealth scoring calibrated to code *density* not project size (self-scan F→**B(76)**, overall **A(95)**); size-relative `computeCodeHealthScore` + relativity test. ✅ incremental re-analysis — `computeSourceFingerprint` (path\|size\|mtime sha256) + `learning_maps.source_fingerprint` (0009); reanalyze short-circuits `{unchanged:true}` on an untouched tree. ✅ [benchmarks](BENCHMARKS.md) — 300-file full analysis <200ms, flat ~4ms lens pass, incremental skip ~8× cheaper; bounded by MAX_FILES/AST caps. Remaining: fold in #27/#28 module associations (REVIEW-012) | +| **MS4** ✅ | Algorithms & Analysis Depth | The analysis *is* the product — make it rigorous and calibrated | **DONE** — ✅ codeHealth calibrated to code *density* not project size (self-scan F→**B(76)**, overall **A(95)**); `computeCodeHealthScore` + relativity test. ✅ incremental re-analysis — `computeSourceFingerprint` (path\|size\|mtime sha256) + `learning_maps.source_fingerprint` (0009); reanalyze short-circuits `{unchanged:true}`. ✅ [benchmarks](BENCHMARKS.md) — 300-file analysis <200ms, flat ~4ms lens pass, incremental ~8× cheaper. ✅ #27/#28 module associations — soft `module_key` on questions/concepts (0010), deterministic tagging on both AI + offline paths, module cards link to their questions/concepts | | **MS5** | Quality & Reliability (QA) | 16 tests is a foundation, not production confidence | Critical path e2e-covered; CI green on Win/mac/Linux from a clean clone | | **MS6** | Framework & Performance | Production Next.js build quality | Standalone build runs; perf/a11y budgets met; no UI-blocking analysis | | **MS7** | Distribution & Release | Turn the repo into installable, versioned software — the actual "deploy" | `npx nirmiqcodesensei@latest` runs on a fresh machine; tagged v1.0.0 GitHub Release; CHANGELOG current; scaling-N/A ADR recorded | diff --git a/lib/db/migrations/0010_tiresome_goliath.sql b/lib/db/migrations/0010_tiresome_goliath.sql new file mode 100644 index 0000000..c986783 --- /dev/null +++ b/lib/db/migrations/0010_tiresome_goliath.sql @@ -0,0 +1,2 @@ +ALTER TABLE `concept_links` ADD `module_key` text;--> statement-breakpoint +ALTER TABLE `explain_back_questions` ADD `module_key` text; \ No newline at end of file diff --git a/lib/db/migrations/meta/0010_snapshot.json b/lib/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..30ee2e3 --- /dev/null +++ b/lib/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,815 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8965ee73-9624-4521-ae84-191e8d77ea72", + "prevId": "6cd52e48-fc14-48e6-8be7-c91c7d989cf1", + "tables": { + "concept_links": { + "name": "concept_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_feature": { + "name": "project_feature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "concept_name": { + "name": "concept_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "concept_type": { + "name": "concept_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "module_key": { + "name": "module_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "practice_task": { + "name": "practice_task", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file": { + "name": "source_file", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "code_snippet": { + "name": "code_snippet", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ast_confidence": { + "name": "ast_confidence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "concept_links_workspace_id_workspaces_id_fk": { + "name": "concept_links_workspace_id_workspaces_id_fk", + "tableFrom": "concept_links", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_logs": { + "name": "daily_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "built_today": { + "name": "built_today", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "understood_today": { + "name": "understood_today", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unclear_topics": { + "name": "unclear_topics", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bugs_faced": { + "name": "bugs_faced", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "daily_logs_workspace_date": { + "name": "daily_logs_workspace_date", + "columns": [ + "workspace_id", + "date" + ], + "isUnique": true + } + }, + "foreignKeys": { + "daily_logs_workspace_id_workspaces_id_fk": { + "name": "daily_logs_workspace_id_workspaces_id_fk", + "tableFrom": "daily_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "debug_logs": { + "name": "debug_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspected_cause": { + "name": "suspected_cause", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_cause": { + "name": "actual_cause", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fix_summary": { + "name": "fix_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lesson_learned": { + "name": "lesson_learned", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prevention_rule": { + "name": "prevention_rule", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "debug_logs_workspace_id_workspaces_id_fk": { + "name": "debug_logs_workspace_id_workspaces_id_fk", + "tableFrom": "debug_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "explain_back_questions": { + "name": "explain_back_questions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "learning_map_id": { + "name": "learning_map_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "module_key": { + "name": "module_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'beginner'" + }, + "expected_points_json": { + "name": "expected_points_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "user_answer": { + "name": "user_answer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "explain_back_questions_workspace_id_workspaces_id_fk": { + "name": "explain_back_questions_workspace_id_workspaces_id_fk", + "tableFrom": "explain_back_questions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "explain_back_questions_learning_map_id_learning_maps_id_fk": { + "name": "explain_back_questions_learning_map_id_learning_maps_id_fk", + "tableFrom": "explain_back_questions", + "tableTo": "learning_maps", + "columnsFrom": [ + "learning_map_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "learning_maps": { + "name": "learning_maps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "modules_json": { + "name": "modules_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "checkpoints_json": { + "name": "checkpoints_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "analysis_raw": { + "name": "analysis_raw", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "graph_json": { + "name": "graph_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "senior_review_json": { + "name": "senior_review_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_fingerprint": { + "name": "source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "learning_maps_workspace_id_workspaces_id_fk": { + "name": "learning_maps_workspace_id_workspaces_id_fk", + "tableFrom": "learning_maps", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_chunks": { + "name": "search_chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_type": { + "name": "chunk_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'file'" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layer": { + "name": "layer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_chunks_workspace_id_workspaces_id_fk": { + "name": "search_chunks_workspace_id_workspaces_id_fk", + "tableFrom": "search_chunks", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_logs": { + "name": "session_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_summary": { + "name": "action_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "risk_level": { + "name": "risk_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'safe'" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'hook'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_logs_workspace_id_workspaces_id_fk": { + "name": "session_logs_workspace_id_workspaces_id_fk", + "tableFrom": "session_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "progress_score": { + "name": "progress_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 77ddeab..0c5b492 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1783969111777, "tag": "0009_mushy_sebastian_shaw", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1784021598848, + "tag": "0010_tiresome_goliath", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 5aba5f9..da8a472 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -67,6 +67,10 @@ export const explainBackQuestions = sqliteTable("explain_back_questions", { learningMapId: text("learning_map_id").references(() => learningMaps.id, { onDelete: "set null", }), + // Soft link to the learning-map module this question belongs to (module key + // slug, e.g. "risk-map"). Groups the flat question list under modules so the + // surfaces are one system (#27/#28). Null for manually-added questions. + moduleKey: text("module_key"), question: text("question").notNull(), difficulty: text("difficulty", { enum: ["beginner", "intermediate", "advanced"], @@ -186,6 +190,9 @@ export const conceptLinks = sqliteTable("concept_links", { projectFeature: text("project_feature").notNull(), conceptName: text("concept_name").notNull(), conceptType: text("concept_type"), + // Soft link to the learning-map module this concept belongs to (#27/#28). + // Code-grounded DSA findings → "key-files"; conceptual links → "how-it-works". + moduleKey: text("module_key"), explanation: text("explanation"), practiceTask: text("practice_task"), // Code-grounded DSA: where the structure/algorithm was found diff --git a/lib/services/concept-link.service.ts b/lib/services/concept-link.service.ts index b063f6c..63a75db 100644 --- a/lib/services/concept-link.service.ts +++ b/lib/services/concept-link.service.ts @@ -27,6 +27,7 @@ export async function createConceptLink( conceptType: input.conceptType ?? null, explanation: input.explanation ?? null, practiceTask: input.practiceTask ?? null, + moduleKey: input.moduleKey ?? null, }) .returning(); return { ok: true, data: link }; @@ -51,6 +52,7 @@ export async function createConceptLinkWithSource( sourceFile?: string; codeSnippet?: string; astConfidence?: string; + moduleKey?: string; } ): Promise> { try { @@ -66,6 +68,7 @@ export async function createConceptLinkWithSource( sourceFile: input.sourceFile ?? null, codeSnippet: input.codeSnippet ?? null, astConfidence: input.astConfidence ?? null, + moduleKey: input.moduleKey ?? null, }) .returning(); return { ok: true, data: link }; diff --git a/lib/services/explain-back.service.ts b/lib/services/explain-back.service.ts index 9602f60..a596251 100644 --- a/lib/services/explain-back.service.ts +++ b/lib/services/explain-back.service.ts @@ -31,6 +31,7 @@ export async function createQuestion( .values({ workspaceId, learningMapId: input.learningMapId ?? null, + moduleKey: input.moduleKey ?? null, question: input.question, difficulty: input.difficulty, expectedPointsJson, diff --git a/lib/services/learning-map.service.ts b/lib/services/learning-map.service.ts index 48daf44..927d077 100644 --- a/lib/services/learning-map.service.ts +++ b/lib/services/learning-map.service.ts @@ -12,6 +12,8 @@ import type { export type LearningModule = { id: string; + /** Stable slug for cross-surface grouping (#27/#28), e.g. "risk-map". */ + key: string; title: string; summary: string; difficulty: "beginner" | "intermediate" | "advanced"; @@ -20,6 +22,24 @@ export type LearningModule = { confidence: "red" | "yellow" | "green" | null; }; +/** Stable module slugs — questions/concepts reference these to group by module. */ +export const MODULE_KEYS = { + techStack: "tech-stack", + howItWorks: "how-it-works", + keyFiles: "key-files", + riskMap: "risk-map", +} as const; + +/** Map a module title to its stable slug, so old maps (whose JSON predates the + * `key` field) still group correctly and new modules get a consistent key. */ +export function moduleKeyForTitle(title: string): string { + if (title.startsWith("Tech Stack")) return MODULE_KEYS.techStack; + if (title.startsWith("How It Works")) return MODULE_KEYS.howItWorks; + if (title.startsWith("Key Files")) return MODULE_KEYS.keyFiles; + if (title.startsWith("Risk Map")) return MODULE_KEYS.riskMap; + return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); +} + export type Checkpoint = { id: string; question: string; @@ -40,7 +60,9 @@ export type LearningMap = Omit< function parseModules(json: string): LearningModule[] { try { - return JSON.parse(json) as LearningModule[]; + const mods = JSON.parse(json) as LearningModule[]; + // Backfill `key` for maps whose JSON predates #27/#28 so grouping still works. + return mods.map((m) => ({ ...m, key: m.key ?? moduleKeyForTitle(m.title) })); } catch { return []; } @@ -98,6 +120,7 @@ export async function createLearningMapWithContent( seniorReviewJson?: string; sourceFingerprint?: string; modules: Array<{ + key?: string; title: string; summary: string; difficulty: "beginner" | "intermediate" | "advanced"; @@ -111,6 +134,7 @@ export async function createLearningMapWithContent( const modulesJson = JSON.stringify( content.modules.map((m) => ({ id: crypto.randomUUID(), + key: m.key ?? moduleKeyForTitle(m.title), title: m.title, summary: m.summary, difficulty: m.difficulty, @@ -203,6 +227,7 @@ export async function addModule( const modules = parseModules(raw.modulesJson); const newModule: LearningModule = { id: crypto.randomUUID(), + key: moduleKeyForTitle(input.title), title: input.title, summary: input.summary ?? "", difficulty: input.difficulty, diff --git a/lib/services/project-analyzer.service.ts b/lib/services/project-analyzer.service.ts index 44686ff..d10183f 100644 --- a/lib/services/project-analyzer.service.ts +++ b/lib/services/project-analyzer.service.ts @@ -29,6 +29,7 @@ import { import { createLearningMapWithContent, getLearningMapByWorkspaceId, + MODULE_KEYS, } from "@/lib/services/learning-map.service"; import { detectStack, generateLocalAnalysisText } from "@/lib/services/local-analyzer.service"; import { analyzeCode, computeSourceFingerprint } from "@/lib/services/code-analyzer.service"; @@ -244,6 +245,21 @@ export function cloneOrPullRepo(githubUrl: string, destPath: string): void { } } +// Classify an explain-back question into one of the four learning-map modules +// (#27/#28) so the flat question list groups under modules. Deterministic keyword +// heuristic — works on both the AI and the offline-heuristic paths. Defaults to +// "how-it-works" (the main conceptual bucket). +function inferQuestionModuleKey(text: string): string { + const t = text.toLowerCase(); + if (/\b(break|fail|crash|error|bug|risk|edge case|go wrong|vulnerab|secur|scale)\b/.test(t)) + return MODULE_KEYS.riskMap; + if (/\b(file|folder|directory|structure|entry point|where is|which file|organi[sz])\b/.test(t)) + return MODULE_KEYS.keyFiles; + if (/\b(stack|dependenc|library|framework|package|version|install|tooling|why .* chosen)\b/.test(t)) + return MODULE_KEYS.techStack; + return MODULE_KEYS.howItWorks; +} + // ── Analysis section parsing ─────────────────────────────────────────────────── function extractSection(text: string, header: string): string { @@ -683,6 +699,7 @@ async function persistAnalysis( const r = await createQuestion(workspaceId, { question: q.question, difficulty: q.difficulty, + moduleKey: inferQuestionModuleKey(q.question), }); if (r.ok) questionsCreated++; } @@ -692,6 +709,7 @@ async function persistAnalysis( conceptName: c.name, conceptType: c.type, explanation: c.explanation, + moduleKey: MODULE_KEYS.howItWorks, }); if (r.ok) conceptsCreated++; } @@ -709,6 +727,7 @@ async function persistAnalysis( const r = await createQuestion(workspaceId, { question: qMatch[3].trim(), difficulty, + moduleKey: inferQuestionModuleKey(qMatch[3].trim()), }); if (r.ok) questionsCreated++; } @@ -722,6 +741,7 @@ async function persistAnalysis( conceptName: cMatch[1].trim(), conceptType: cMatch[2].trim(), explanation: cMatch[3].trim(), + moduleKey: MODULE_KEYS.howItWorks, }); if (r.ok) conceptsCreated++; } @@ -748,6 +768,7 @@ async function persistAnalysis( sourceFile: `${f.file}:${f.line}`, codeSnippet: f.snippet, astConfidence: f.confidence, + moduleKey: MODULE_KEYS.keyFiles, }); if (r.ok) conceptsCreated++; } diff --git a/lib/validators/concept-link.schema.ts b/lib/validators/concept-link.schema.ts index 42a9b31..2ece6ab 100644 --- a/lib/validators/concept-link.schema.ts +++ b/lib/validators/concept-link.schema.ts @@ -42,6 +42,9 @@ export const createConceptLinkSchema = z.object({ conceptType: z.enum(CONCEPT_TYPES).optional(), explanation: z.string().max(2000).optional(), practiceTask: z.string().max(500).optional(), + // Set internally by the analyzer (never the manual form): the module slug this + // concept belongs to, for cross-surface grouping (#27/#28). + moduleKey: z.string().max(60).optional(), }); export type CreateConceptLinkInput = z.infer; diff --git a/lib/validators/explain-back.schema.ts b/lib/validators/explain-back.schema.ts index d890c03..c7fd7ff 100644 --- a/lib/validators/explain-back.schema.ts +++ b/lib/validators/explain-back.schema.ts @@ -13,6 +13,9 @@ export const createQuestionSchema = z.object({ .max(2000) .optional(), // free-text, stored as JSON string learningMapId: z.string().optional(), + // Set internally by the analyzer (never the manual form): the module slug this + // question belongs to, for cross-surface grouping (#27/#28). + moduleKey: z.string().max(60).optional(), }); export type CreateQuestionInput = z.infer; diff --git a/tests/import-pipeline.test.mts b/tests/import-pipeline.test.mts index 0bc3db0..599e5a3 100644 --- a/tests/import-pipeline.test.mts +++ b/tests/import-pipeline.test.mts @@ -330,6 +330,29 @@ test("analyzeProject: graph file nodes carry senior-review flags", async () => { assert.ok(flagged.flags?.security, "insecure.ts badged with a security flag"); }); +test("analyzeProject: tags questions + concepts with a module key (#27/#28)", async () => { + const validKeys = new Set(["tech-stack", "how-it-works", "key-files", "risk-map"]); + + const qs = await db.select().from(schema.explainBackQuestions) + .where(eq(schema.explainBackQuestions.workspaceId, workspaceId)); + assert.ok(qs.length > 0, "questions exist"); + assert.ok(qs.every((q) => q.moduleKey && validKeys.has(q.moduleKey)), + "every question is tagged with a valid module key"); + + const links = await db.select().from(schema.conceptLinks) + .where(eq(schema.conceptLinks.workspaceId, workspaceId)); + const grounded = links.filter((l) => l.sourceFile); + assert.ok(grounded.length > 0, "fixture yields code-grounded concepts"); + assert.ok(grounded.every((l) => l.moduleKey === "key-files"), + "code-grounded concepts group under key-files"); + + const [map] = await db.select().from(schema.learningMaps) + .where(eq(schema.learningMaps.workspaceId, workspaceId)); + const modules = JSON.parse(map.modulesJson) as Array<{ key?: string }>; + assert.ok(modules.length > 0 && modules.every((m) => m.key), + "every learning-map module carries a stable key"); +}); + test("graph-utils: neighborhood depths and search matching", async () => { const { buildAdjacency, neighborhood, matchNodes, endpointId } = await import( "@/components/learning-map/graph-utils"