diff --git a/.gitignore b/.gitignore index 1d14d35..c092d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ embabel-agent-api/src/main/resources/mcp/** # Ignore all personal profile files except the checked-in example scripts/user-config/application-*.yml !scripts/user-config/application-*.yml.example +scripts/user-config/ingestion-git-revisions.json # Temporary files *.tmp diff --git a/codegen-gradle/build.gradle.kts b/codegen-gradle/build.gradle.kts index 9d7d631..b3ba81e 100644 --- a/codegen-gradle/build.gradle.kts +++ b/codegen-gradle/build.gradle.kts @@ -19,7 +19,7 @@ plugins { group = "com.embabel.guide" version = "0.1.0-SNAPSHOT" -val drivineVersion = "0.0.28" +val drivineVersion = "0.0.45" repositories { mavenCentral() diff --git a/docs/spdd-branch-changes.md b/docs/spdd-branch-changes.md new file mode 100644 index 0000000..3c2ec49 --- /dev/null +++ b/docs/spdd-branch-changes.md @@ -0,0 +1,99 @@ +# Branch change summary for Guide developers + +Branch: `cursor/spike-spdd-dice-projection-17f4` (tracks upstream `main`). +Audience: developers who work on Guide and want to understand what this branch adds, +why, and what the blast radius is. + +**One paragraph:** the branch turns Guide into an optional *hybrid context backend* for +an SDLC workflow: alongside the existing RAG chunk store, an opt-in projection writes +**typed domain entities** (`__Entity__` nodes: `WorkId`, `Canvas`, `Area`, `Decision`, +`Pitfall`, `Pattern`) into the same Neo4j and exposes typed-edge retrieval over HTTP and +MCP. Everything is behind `guide.spdd-projection.enabled` (default **false**); with the +flag off, runtime behavior matches upstream except for the additions listed under +"Cross-cutting" below. + +## 1. New package `com.embabel.guide.spdd` (all opt-in) + +| Class | Role | +|-------|------| +| `SpddEntityDictionary` | `DataDictionary.fromClasses` over `NamedEntity` domain types in `spdd.domain` — schema + label validation | +| `SpddMarkdownProjectionService` | Parses structured markdown (`spdd/canvas/*.md`, `agent-context/memory/context-index.md`) and persists via `NamedEntityDataRepository.save` + `mergeRelationship` (merge-by-id, idempotent). Read side: `subgraphForWorkId`, `lessonsForArea`, `listByLabel` | +| `SpddProjectionController` | Operator HTTP under `/api/v1/data/spdd-projection` (`load`, `stats`, `work/{workId}`, `area?name=`) with explicit status mapping (400 validation / 404 not found / 409 disabled) | +| `SpddDomainTools` | `@LlmTool` methods exported to MCP as `spdd_*` via `McpToolExport.fromToolObject(ToolObject(...).withPrefix("spdd_"))`; failures return `{"error": …}` JSON | +| `SpddProjectionConfiguration` | Beans: `DrivineNamedEntityDataRepository` wired with the SPDD dictionary + the MCP export. `@ConditionalOnProperty` on the enable flag | + +Graph model: `WorkId —canvas→ Canvas`, `WorkId —area→ Area`, +`WorkId —decision/pitfall/pattern→ lesson`, `lesson —about→ Area`. The `about` edge is +what makes lessons retrievable **across** work items by code area. + +Hardening baked in: per-request `rootPath` overrides must resolve under +`default-root-path` or configured `allowed-roots` (the load endpoint is on the +permit-all list, so arbitrary filesystem roots are rejected); a malformed source file is +skipped and counted (`skippedFiles`) instead of failing the load; list reads accept only +schema labels and are capped (50 default / 200 max). + +Uses only public library APIs (`NamedEntityData`, `NamedEntityDataRepository`, +`RelationshipDirection`, `DataDictionary`). It does **not** touch the DICE proposition +extraction pipeline, and no consumer project names are hardcoded — the coupling is to +the SPDD directory conventions only. + +## 2. RAG ingest: git-incremental directories + +- `GitIncrementalDirectorySupport` + `GitIngestionRevisionStore` — when + `guide.git-ingestion.enabled=true`, directory ingest diffs against the last recorded + git revision and reprocesses only changed files. Subdirectory entries (e.g. + `repo/spdd/canvas`) are supported: Guide walks up to the `.git` root and scopes the + diff. +- `DataManager` — hooks the incremental path into `loadReferences()`. +- `RagContentMaintenanceService`, `RagMaintenanceController`, + `RagMaintenanceExceptionHandler` — operator endpoints for content-element purge + preview/purge and git revision reset. + +## 3. Cross-cutting changes (active regardless of the flag) + +- **`GuideProperties`** — new `spddProjection` block (`enabled`, `defaultRootPath`, + `allowedRoots`) and git-ingestion settings; `application.yml` documents both, defaults + off. +- **`SecurityConfig`** — permit-all additions: POST `…/spdd-projection/load`, + `…/git-ingestion/revision/reset`, `…/content-elements/purge{,-preview}`; GET + `…/spdd-projection/stats`, `…/work/*`, `…/area`. Same local-operator posture as the + existing `…/data/load-references`. +- **`PersonaSeedingService`** — startup resilience: fails fast with an actionable + message if the Drivine KSP query DSL is missing from the classpath, and persona + seeding failures no longer abort startup (RAG/MCP stay available). +- **Build (`pom.xml`)** — `embabel-agent-rag-neo-drivine` pinned to timestamp + `0.1.2-20260224.010659-19` (newer snapshots require agent 0.4.0; Guide is on + 0.3.5-SNAPSHOT); maven-enforcer rule fails the build early when the KSP-generated + DSL files are missing instead of dying at runtime. +- **Scripts** — `append-ingest.sh` gains env-driven Neo4j/profile handling; + `run-mcp-guide-against-hub.sh` added; `application-*-spdd-projection.yml.example` + profile example under `scripts/user-config/`. + +## 4. Tests + +- `SpddMarkdownProjectionServiceTest` (16) — projection, idempotent reload, lesson/about + edges, root allowlist enforcement, blank/unknown input validation, list caps. +- `SpddProjectionControllerTest` (8) — standalone MockMvc against the real service + + in-memory repository; verifies the 200/400/404 mapping. +- `SpddDomainToolsTest` (8) — MCP JSON contract, `{"error": …}` on bad input. +- `GitIncrementalDirectorySupportTest`, `RagMaintenanceControllerWebMvcTest`, + `DataManagerControllerWebMvcTest` — incremental ingest + maintenance endpoints. +- Fixture: `src/test/resources/spdd-fixture/` (minimal canvas + context index). + +## 5. How to review / try it + +1. `git diff upstream/main...HEAD` — ~30 files; the spdd package and rag ingest classes + are the substance. +2. Operator walkthrough: `docs/spdd-projection-ingest.md` (enable, persist, retrieve, + MCP tools). +3. Quick sanity: enable the flag against a project following the SPDD layout, then + `POST …/spdd-projection/load` and `GET …/work/{workId}`. + +## 6. Known limitations + +- Schema uses `DynamicType` + `SimpleNamedEntityData` (spike speed); a permanent home + would promote to typed Kotlin `NamedEntity` classes. +- Entity→chunk join (`findChunksForEntity`) exists at the store level but is not exposed + on the projection API yet. +- `Operation`, session, and domain-keyword entities are declared in the schema roadmap + but not projected yet. diff --git a/docs/spdd-projection-ingest.md b/docs/spdd-projection-ingest.md new file mode 100644 index 0000000..5f1f4bb --- /dev/null +++ b/docs/spdd-projection-ingest.md @@ -0,0 +1,112 @@ +# SPDD leg 3 entity projection (SPIKE-001) — DICE persist/retrieve contract + +Projects structured SPDD markdown into Neo4j `__Entity__` nodes via +`NamedEntityDataRepository`. **Coexists** with leg 2 RAG chunk ingest (`guide.directories`). + +Does **not** use the DICE proposition extraction pipeline (conversation → propositions). + +Markdown remains **source of truth**. Projection is the write path into domain memory; +domain-graph walk is the preferred read path for auditable context selection. + +## Enable + +```yaml +guide: + spdd-projection: + enabled: true + default-root-path: /home/ubuntu/github/jmjava/sdlc-spdd-orchestrator + # Optional. Roots a per-request rootPath override may resolve under, in addition + # to default-root-path. Anything else → HTTP 400 (load is on the permit-all list). + allowed-roots: [] +``` + +Or per-request `rootPath` on load (subject to the allowed-roots guard). + +Build note: Guide stays on Embabel agent **0.3.5-SNAPSHOT**. Pin +`embabel-agent-rag-neo-drivine` to a pre-`EmbeddingAware` timestamp +(`0.1.2-20260224.010659-19`); newer `0.1.2-SNAPSHOT` jars require agent 0.4.0. + +## Persist (write) + +```bash +# Project entities from orchestrator (or retrieval-fixture) root +curl -s -X POST http://localhost:21337/api/v1/data/spdd-projection/load \ + -H 'Content-Type: application/json' \ + -d '{"rootPath":"/home/ubuntu/github/jmjava/sdlc-spdd-orchestrator"}' | jq . +``` + +Idempotent: entity `id` values are stable; re-load merges via `save` / `mergeRelationship`. +Malformed source files are skipped and counted in the response's `skippedFiles`; canvases +are processed in sorted order. + +Sources: + +| Path under root | Entities | Relationships | +|-----------------|----------|---------------| +| `spdd/canvas/*.md` | WorkId, Canvas | WorkId —`canvas`→ Canvas | +| `agent-context/memory/context-index.md` | Area, Decision, Pitfall, Pattern | WorkId —`area`→ Area; WorkId —`decision`/`pitfall`/`pattern`→ lesson; lesson —`about`→ Area | + +The `about` edges make lessons queryable by code area **across Work IDs** (cross-run +lessons-learned lookup). + +## Retrieve (read) + +```bash +# Counts by label +curl -s http://localhost:21337/api/v1/data/spdd-projection/stats | jq . + +# WorkId subgraph (typed edges — not cosine) +curl -s http://localhost:21337/api/v1/data/spdd-projection/work/SPIKE-001-guide-rag-context-backend | jq . +``` + +| Endpoint | Role | +|----------|------| +| `GET /api/v1/data/spdd-projection/stats` | Label counts (`WorkId`, `Canvas`, `Area`, `Decision`, `Pitfall`, `Pattern`) | +| `GET /api/v1/data/spdd-projection/work/{workId}` | Domain subgraph via `findRelated` (canvas, area, decision, pitfall, pattern) | +| `GET /api/v1/data/spdd-projection/area?name={area}` | Cross-run lessons for a code area via incoming `about`/`area` edges | + +Status mapping: validation failures (bad root, blank workId/area, unknown label) → 400 +with `{"error": …}`; unknown workId/area → 404; feature disabled → 409. + +**MCP (leg 3):** when `guide.spdd-projection.enabled=true`, Guide SSE also exports: + +| Tool | Role | +|------|------| +| `spdd_workSubgraph` | Same as `GET …/work/{workId}` | +| `spdd_projectionStats` | Same as `GET …/stats` | +| `spdd_findByLabel` | List `__Entity__` nodes by label (schema labels only; capped at 200) | +| `spdd_areaLessons` | Same as `GET …/area?name=…` — prior lessons before touching an area | + +Tool failures return `{"error": …}` JSON instead of protocol errors. + +Implemented in `SpddDomainTools` (`@LlmTool`) + `McpToolExport` in `SpddProjectionConfiguration`. +Complements `docs_*` chunk tools. After adding tools, reload the Cursor `embabel-dev` MCP +server so the client refreshes its tool list. + +**Chunk join:** store-level `findChunksForEntity` can link entity → RAG chunks; not yet on +this controller. + +## Typical flow (both legs) + +1. **Leg 2** — `./scripts/append-ingest.sh` (menke-5 profile) → RAG chunks +2. **Leg 3** — `POST /api/v1/data/spdd-projection/load` → WorkId, Canvas, Area, … +3. **Verify** — stats + `GET …/work/{workId}` + +Re-run leg 3 after markdown index/canvas changes. Leg 2 git incremental handles chunk +updates separately. + +## Implementation package + +`com.embabel.guide.spdd`: + +- `domain/SpddDomain.kt` — first-class `NamedEntity` types (`WorkId`, `Canvas`, …) with `@Semantics` +- `SpddEntityDictionary` — `DataDictionary.fromClasses("sdlc-spdd", …)` (Embabel-standard; not `DynamicType`) +- `SpddMarkdownProjectionService` — parse + persist + `subgraphForWorkId` +- `SpddProjectionController` — operator HTTP +- `SpddDomainTools` — MCP `spdd_*` retrieve tools (`@LlmTool`) +- `SpddProjectionConfiguration` — `DrivineNamedEntityDataRepository` + MCP export beans + +## Branch + +Developed on `cursor/spike-spdd-dice-projection-17f4` (pair with orchestrator +`cursor/spike-guide-ingest-agent-context-17f4`). diff --git a/pom.xml b/pom.xml index 361dee2..d68fbf8 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,10 @@ ${embabel-agent.version} + com.embabel.agent embabel-agent-rag-graph @@ -233,6 +237,33 @@ + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + require-ksp-persona-dsl + process-sources + + enforce + + + + + + ${project.basedir}/codegen-gradle/build/generated/ksp/main/kotlin/com/embabel/guide/domain/PersonaViewQueryDsl.kt + ${project.basedir}/codegen-gradle/build/generated/ksp/main/kotlin/com/embabel/guide/domain/GuideUserQueryDsl.kt + + Drivine KSP DSL missing. From repo root: cd codegen-gradle && ./gradlew kspKotlin — then rebuild. Do not run concurrent ./mvnw spring-boot:run builds. + + + + + + + org.codehaus.mojo diff --git a/scripts/README.md b/scripts/README.md index 4fc233e..687d417 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,9 +4,12 @@ |---|---| | `fresh-ingest.sh` | Wipes Neo4j RAG data and re-ingests everything from scratch. Use for first-time setup or when you want a clean slate. | | `append-ingest.sh` | Re-ingests without clearing existing data. Use when you've added new URLs or directories. Comment out already-ingested items in your profile to avoid re-processing them. | +| `run-mcp-guide-against-hub.sh` | Run Guide on **`$GUIDE_PORT`** (default **1337**) for MCP at **`/sse`**, using the **same external Bolt defaults** as **`USE_EMBABEL_HUB_NEO4J=1`** in `append-ingest.sh` (self-contained script; no extra `lib/`). | | `shell.sh` | Runs the application in interactive shell mode. | -Both ingestion scripts start Neo4j in Docker, load your personal profile, and print an **INGESTION COMPLETE** banner when done. +Both ingestion scripts load your personal profile and run Guide with reload-on-startup. Watch application logs for ingestion progress. If **`ANTHROPIC_API_KEY`** is not set, `append-ingest.sh` exports a **`dummy-key`** placeholder so Spring starts (Anthropic autoconfigure requires the variable; ingestion embeddings use local ONNX). Put a real key in `.env` when you use Claude. + +By default they start **guide** Compose Neo4j (`embabel-neo4j`, Bolt `localhost:7687`). For **any other** Bolt endpoint (different host/port/password), set **`SKIP_COMPOSE_NEO4J=1`** and configure **`NEO4J_URI`**, **`NEO4J_PASSWORD`**, **`NEO4J_PORT`**, etc. The **`append-ingest.sh`** header documents a **`USE_EMBABEL_HUB_NEO4J=1`** shortcut for one common Docker layout; adjust env vars if yours differs. Do **not** point **`fresh-ingest.sh`** at a shared production-style graph (it deletes `ContentElement` nodes). ## Personal profiles @@ -39,6 +42,30 @@ guide: Then run `./scripts/append-ingest.sh`. The new content is added alongside existing data in Neo4j. +## Operator API (shared Neo4j) + +When Guide is running, these **`/api/v1/data`** endpoints are open for local/dev use (same security posture as **`load-references`** — do not expose Guide to untrusted networks without a proxy). + +| Method | Path | Purpose | +|--------|------|--------| +| `POST` | `/api/v1/data/content-elements/purge-preview` | JSON body: `{ "uriPrefix": "..." }` **or** `{ "directory": "~/path/to/repo" }` (not both). Returns match count + sample `uri`s. Prefix must be ≥ 8 characters. | +| `POST` | `/api/v1/data/content-elements/purge` | Same prefix fields plus `"confirm": true` — **deletes** matching `ContentElement` nodes (`uri STARTS WITH` resolved prefix). | +| `POST` | `/api/v1/data/git-ingestion/revision/reset` | JSON `{ "directory": "~/path" }` — removes that repo’s entry from the git-ingestion revision file (requires `guide.git-ingestion.enabled`). Next ingest does a full tree for that directory. | + +**Directory → `file:` URI:** `directory` is resolved like `guide.directories` (`~` expanded). Chunks use that `file:` prefix in Neo4j. + +**Examples:** + +```bash +curl -s -X POST http://localhost:1337/api/v1/data/content-elements/purge-preview \ + -H 'Content-Type: application/json' \ + -d '{"directory":"~/github/jmjava/dice"}' | jq . + +curl -s -X POST http://localhost:1337/api/v1/data/git-ingestion/revision/reset \ + -H 'Content-Type: application/json' \ + -d '{"directory":"~/github/jmjava/dice"}' | jq . +``` + ## Tips - **If ingestion seems stuck** on a URL: the thread is blocked on fetch -> parse -> embed. Try lowering `embedding-batch-size` to 20, or temporarily remove the slow URL. diff --git a/scripts/append-ingest.sh b/scripts/append-ingest.sh index ebac691..103c84b 100755 --- a/scripts/append-ingest.sh +++ b/scripts/append-ingest.sh @@ -1,22 +1,94 @@ #!/usr/bin/env bash # Re-ingest content WITHOUT clearing Neo4j first. # Existing RAG data is kept; new/updated content is added on top. -# IngestionRunner prints the summary when done. +# When guide.reload-content-on-startup is true, startup ingestion runs (IngestionRunner). # # Set GUIDE_PROFILE in .env to use your own profile (default: "user"). # e.g. GUIDE_PROFILE=menke → loads application-menke.yml +# +# Neo4j: two supported modes (pick one): +# +# 1) LOCAL (default) — starts guide docker compose Neo4j (embabel-neo4j), Bolt localhost:7687. +# No extra variables required. +# +# 2) EMBABEL HUB — Neo4j inside an embabel/hub (or compatible) container; Bolt on the host is often 27687. +# Easiest: USE_EMBABEL_HUB_NEO4J=1 +# That uses Hub Bolt/password defaults and ignores NEO4J_PASSWORD from .env (often brahmsian for +# local compose), which would cause Neo4j AuthenticationException against Hub. +# Override Hub password only if needed: NEO4J_PASSWORD_HUB=your-secret +# Drivine uses NEO4J_PORT for database.dataSources.neo (defaults to 7687); Hub preset sets it from Bolt port. +# Manual mode (no preset): set SKIP_COMPOSE_NEO4J=1 and NEO4J_URI / NEO4J_PASSWORD / … yourself; +# ensure NEO4J_PASSWORD matches the database you connect to (embabel123 for default Hub image). +# +# Use append mode only for hub Neo4j; do not use fresh-ingest.sh against hub (it wipes ContentElement). +# +# Optional: GUIDE_INGEST_LOG=/path/to/log.txt mirrors all script output to that file (still prints to +# the terminal). Helps when your IDE truncates long Maven / Spring Boot logs. +# +# LLM keys: embabel-agent-anthropic-autoconfigure fails startup if ANTHROPIC_API_KEY is unset, even when +# you only use OpenAI + local ONNX embeddings for ingestion. If unset, this script exports a placeholder +# (same pattern as .github/workflows/export-seed.yml). Override with ANTHROPIC_API_KEY_INGEST_PLACEHOLDER +# or set ANTHROPIC_API_KEY in .env. set -e +truthy() { + case "${1:-}" in 1|true|yes|on|TRUE|YES|ON) return 0 ;; esac + return 1 +} + +neo4j_ready() { + local user="${NEO4J_USERNAME:-neo4j}" + local pass="${NEO4J_PASSWORD:-brahmsian}" + if [ -n "${NEO4J_WAIT_CONTAINER:-}" ]; then + docker exec "$NEO4J_WAIT_CONTAINER" cypher-shell -u "$user" -p "$pass" "RETURN 1" >/dev/null 2>&1 + elif truthy "${SKIP_COMPOSE_NEO4J:-}"; then + (echo >/dev/tcp/127.0.0.1/${NEO4J_BOLT_PORT}) 2>/dev/null + else + docker exec embabel-neo4j cypher-shell -u "$user" -p "$pass" "RETURN 1" >/dev/null 2>&1 + fi +} + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" GUIDE_ROOT="$(dirname "$SCRIPT_DIR")" cd "$GUIDE_ROOT" +# Preserve an explicitly exported GUIDE_PROFILE across .env load +_GUIDE_PROFILE_PRESET="${GUIDE_PROFILE-}" if [ -f .env ]; then echo "Loading .env..." set -a source .env set +a fi +if [ -n "${_GUIDE_PROFILE_PRESET}" ]; then + export GUIDE_PROFILE="${_GUIDE_PROFILE_PRESET}" +fi +unset _GUIDE_PROFILE_PRESET + +if [ -z "${ANTHROPIC_API_KEY:-}" ]; then + export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY_INGEST_PLACEHOLDER:-dummy-key}" + echo "ANTHROPIC_API_KEY unset — using ingest placeholder so Spring can start (embedding uses ONNX; set a real key in .env if you need Anthropic)." +fi + +# Optional preset: Hub Neo4j (Bolt user/pass differ from guide compose — do not reuse .env NEO4J_PASSWORD) +if truthy "${USE_EMBABEL_HUB_NEO4J:-}"; then + export SKIP_COMPOSE_NEO4J=1 + export NEO4J_BOLT_PORT="${NEO4J_BOLT_PORT:-27687}" + export NEO4J_PORT="${NEO4J_PORT:-$NEO4J_BOLT_PORT}" + export NEO4J_URI="${NEO4J_URI:-bolt://localhost:${NEO4J_BOLT_PORT}}" + export NEO4J_WAIT_CONTAINER="${NEO4J_WAIT_CONTAINER:-embabel-hub}" + export NEO4J_USERNAME="${NEO4J_USERNAME_HUB:-neo4j}" + export NEO4J_PASSWORD="${NEO4J_PASSWORD_HUB:-embabel123}" +fi + +if [ -n "${GUIDE_INGEST_LOG:-}" ]; then + log_dir="$(dirname "$GUIDE_INGEST_LOG")" + if [ "$log_dir" != "." ]; then + mkdir -p "$log_dir" + fi + echo "Also logging to: $GUIDE_INGEST_LOG" + exec > >(tee -a "$GUIDE_INGEST_LOG") 2>&1 +fi GUIDE_PORT="${GUIDE_PORT:-1337}" EXISTING_PID=$(lsof -ti :"$GUIDE_PORT" 2>/dev/null | head -1) @@ -28,15 +100,24 @@ if [ -n "$EXISTING_PID" ]; then sleep 1 fi -echo "Ensuring Neo4j is up (Docker)..." -docker compose up neo4j -d +if truthy "${SKIP_COMPOSE_NEO4J:-}"; then + echo "Neo4j mode: external (not starting guide compose — Hub or custom Bolt)." +else + echo "Neo4j mode: local (guide docker compose → embabel-neo4j)." + echo "Ensuring Neo4j is up (Docker)..." + docker compose up neo4j -d +fi NEO4J_BOLT_PORT="${NEO4J_BOLT_PORT:-7687}" -echo "Waiting for Neo4j on port $NEO4J_BOLT_PORT..." +export NEO4J_PORT="${NEO4J_PORT:-$NEO4J_BOLT_PORT}" +echo "Waiting for Neo4j (Bolt port on host: $NEO4J_BOLT_PORT, NEO4J_PORT=$NEO4J_PORT for Drivine)..." +if truthy "${SKIP_COMPOSE_NEO4J:-}" && [ -z "${NEO4J_WAIT_CONTAINER:-}" ]; then + echo "(No NEO4J_WAIT_CONTAINER — using TCP check on Bolt port only.)" +fi max_wait=60 elapsed=0 while [ $elapsed -lt $max_wait ]; do - if docker exec embabel-neo4j cypher-shell -u "${NEO4J_USERNAME:-neo4j}" -p "${NEO4J_PASSWORD:-brahmsian}" "RETURN 1" >/dev/null 2>&1; then + if neo4j_ready; then echo "Neo4j is ready." break fi @@ -52,19 +133,36 @@ fi echo "Keeping existing RAG data (append mode)." GUIDE_PROFILE="${GUIDE_PROFILE:-user}" -export SPRING_PROFILES_ACTIVE="local,${GUIDE_PROFILE}" +# Embabel default graph dialect is the `neo4j` profile (see application.yml / +# README "Switching the Graph Database"). Keep it active alongside `local` and +# the personal GUIDE_PROFILE so Drivine/RAG never silently drop Neo4j. +# Callers may override the full list via SPRING_PROFILES_ACTIVE. +export SPRING_PROFILES_ACTIVE="${SPRING_PROFILES_ACTIVE:-neo4j,local,${GUIDE_PROFILE}}" export NEO4J_URI="${NEO4J_URI:-bolt://localhost:${NEO4J_BOLT_PORT}}" export NEO4J_HOST="${NEO4J_HOST:-localhost}" -# Force ingestion on startup (IngestionRunner prints the summary) -export GUIDE_RELOADCONTENTONSTARTUP=true +# Startup ingest: default on for this script (append pass). With guide.git-ingestion.enabled, +# DataManager only re-chunks files that changed since the last stored HEAD (subdir-aware). +# Skip startup ingest entirely: FORCE_STARTUP_INGEST=0 (or false/no/off). +# Force even if profile sets reload-content-on-startup: false: FORCE_STARTUP_INGEST=1 (default). +if [ -z "${FORCE_STARTUP_INGEST+x}" ]; then + FORCE_STARTUP_INGEST=1 +fi +if truthy "${FORCE_STARTUP_INGEST}"; then + export GUIDE_RELOADCONTENTONSTARTUP=true + echo "Startup ingest: enabled (git-incremental when guide.git-ingestion.enabled=true)." +else + unset GUIDE_RELOADCONTENTONSTARTUP || true + echo "Startup ingest: disabled (FORCE_STARTUP_INGEST=${FORCE_STARTUP_INGEST}); profile YAML reload-content-on-startup applies." + echo "Trigger later with: POST /api/v1/data/load-references" +fi echo "" echo "Starting Guide with profiles: $SPRING_PROFILES_ACTIVE" echo "Neo4j: $NEO4J_URI" echo "" echo "Ingestion will append to existing data." -echo "Watch for the INGESTION COMPLETE banner." +echo "Watch application logs for ingestion progress." echo "Press Ctrl+C to stop." echo "" diff --git a/scripts/fresh-ingest.sh b/scripts/fresh-ingest.sh index b236fcb..503184a 100755 --- a/scripts/fresh-ingest.sh +++ b/scripts/fresh-ingest.sh @@ -54,7 +54,8 @@ docker exec embabel-neo4j cypher-shell -u "${NEO4J_USERNAME:-neo4j}" -p "${NEO4J echo "RAG content cleared." GUIDE_PROFILE="${GUIDE_PROFILE:-user}" -export SPRING_PROFILES_ACTIVE="local,${GUIDE_PROFILE}" +# Keep Embabel Neo4j dialect profile active (see append-ingest.sh). +export SPRING_PROFILES_ACTIVE="${SPRING_PROFILES_ACTIVE:-neo4j,local,${GUIDE_PROFILE}}" export NEO4J_URI="${NEO4J_URI:-bolt://localhost:${NEO4J_BOLT_PORT}}" export NEO4J_HOST="${NEO4J_HOST:-localhost}" diff --git a/scripts/run-mcp-guide-against-hub.sh b/scripts/run-mcp-guide-against-hub.sh new file mode 100644 index 0000000..b46a5c1 --- /dev/null +++ b/scripts/run-mcp-guide-against-hub.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Run Guide on http://localhost:${GUIDE_PORT:-1337} with MCP at /sse against **external** Neo4j. +# Uses the **same** Bolt env defaults as `USE_EMBABEL_HUB_NEO4J=1` in `append-ingest.sh` (override +# NEO4J_BOLT_PORT, NEO4J_WAIT_CONTAINER, NEO4J_PASSWORD_HUB, etc. if your layout differs). +# +# Does not run startup ingestion unless your profile sets guide.reload-content-on-startup: true. +# Ingest first with: USE_EMBABEL_HUB_NEO4J=1 ./scripts/append-ingest.sh +# +# Set GUIDE_PROFILE in .env (e.g. menke → application-menke.yml under scripts/user-config/). +set -e + +truthy() { + case "${1:-}" in 1|true|yes|on|TRUE|YES|ON) return 0 ;; esac + return 1 +} + +neo4j_ready() { + local user="${NEO4J_USERNAME:-neo4j}" + local pass="${NEO4J_PASSWORD:-brahmsian}" + if [ -n "${NEO4J_WAIT_CONTAINER:-}" ]; then + docker exec "$NEO4J_WAIT_CONTAINER" cypher-shell -u "$user" -p "$pass" "RETURN 1" >/dev/null 2>&1 + elif truthy "${SKIP_COMPOSE_NEO4J:-}"; then + (echo >/dev/tcp/127.0.0.1/${NEO4J_BOLT_PORT}) 2>/dev/null + else + docker exec embabel-neo4j cypher-shell -u "$user" -p "$pass" "RETURN 1" >/dev/null 2>&1 + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GUIDE_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$GUIDE_ROOT" + +if [ -f .env ]; then + echo "Loading .env..." + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +if [ -z "${ANTHROPIC_API_KEY:-}" ]; then + export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY_INGEST_PLACEHOLDER:-dummy-key}" + echo "ANTHROPIC_API_KEY unset — using placeholder so Spring can start (set a real key in .env if you use Anthropic)." +fi + +# Match append-ingest.sh preset when USE_EMBABEL_HUB_NEO4J=1 +export SKIP_COMPOSE_NEO4J=1 +export NEO4J_BOLT_PORT="${NEO4J_BOLT_PORT:-27687}" +export NEO4J_PORT="${NEO4J_PORT:-$NEO4J_BOLT_PORT}" +export NEO4J_URI="${NEO4J_URI:-bolt://localhost:${NEO4J_BOLT_PORT}}" +export NEO4J_WAIT_CONTAINER="${NEO4J_WAIT_CONTAINER:-embabel-hub}" +export NEO4J_USERNAME="${NEO4J_USERNAME_HUB:-neo4j}" +export NEO4J_PASSWORD="${NEO4J_PASSWORD_HUB:-embabel123}" + +GUIDE_PORT="${GUIDE_PORT:-1337}" +EXISTING_PID=$(lsof -ti :"$GUIDE_PORT" 2>/dev/null | head -1) +if [ -n "$EXISTING_PID" ]; then + echo "Killing existing process on port $GUIDE_PORT (PID $EXISTING_PID)..." + kill "$EXISTING_PID" 2>/dev/null || true + sleep 1 + kill -9 "$EXISTING_PID" 2>/dev/null || true + sleep 1 +fi + +echo "Waiting for Neo4j (Bolt port on host: $NEO4J_BOLT_PORT, NEO4J_PORT=$NEO4J_PORT)..." +if truthy "${SKIP_COMPOSE_NEO4J:-}" && [ -z "${NEO4J_WAIT_CONTAINER:-}" ]; then + echo "(No NEO4J_WAIT_CONTAINER — using TCP check on Bolt port only.)" +fi +max_wait=120 +elapsed=0 +while [ "$elapsed" -lt "$max_wait" ]; do + if neo4j_ready; then + echo "Neo4j is ready." + break + fi + sleep 3 + elapsed=$((elapsed + 3)) + echo " ... ${elapsed}s" +done +if [ "$elapsed" -ge "$max_wait" ]; then + echo "Neo4j did not become ready in time." + exit 1 +fi + +GUIDE_PROFILE="${GUIDE_PROFILE:-user}" +# Keep Embabel Neo4j dialect profile active (see append-ingest.sh). +export SPRING_PROFILES_ACTIVE="${SPRING_PROFILES_ACTIVE:-neo4j,local,${GUIDE_PROFILE}}" +export NEO4J_URI="${NEO4J_URI:-bolt://localhost:${NEO4J_BOLT_PORT}}" +export NEO4J_HOST="${NEO4J_HOST:-localhost}" + +echo "" +echo "Starting Guide with profiles: $SPRING_PROFILES_ACTIVE" +echo "Neo4j: $NEO4J_URI" +echo "MCP SSE: http://localhost:${GUIDE_PORT}/sse" +echo "Press Ctrl+C to stop." +echo "" + +export SERVER_PORT="${GUIDE_PORT}" +./mvnw -DskipTests spring-boot:run -Dspring-boot.run.arguments="--spring.config.additional-location=file:./scripts/user-config/" diff --git a/scripts/user-config/README.md b/scripts/user-config/README.md index a63d403..37b0981 100644 --- a/scripts/user-config/README.md +++ b/scripts/user-config/README.md @@ -14,7 +14,7 @@ echo 'GUIDE_PROFILE=myname' >> .env ## How it works - The scripts (`fresh-ingest.sh`, `append-ingest.sh`) read `GUIDE_PROFILE` from `.env` (default: `user`) -- Spring profiles become `local,` → loads `application-.yml` +- Spring profiles become `neo4j,local,` (Embabel Neo4j dialect kept active) → loads `application-.yml` - The scripts pass `--spring.config.additional-location=file:./scripts/user-config/` so Spring picks up profiles from this directory - Personal profiles in `scripts/user-config/` are gitignored (only the `.example` is checked in) @@ -34,6 +34,29 @@ All failures are collected with their source and reason into the `IngestionResul - Printed in the **INGESTION COMPLETE** banner (so you can see what failed and why at a glance) - Returned as JSON from `POST /api/v1/data/load-references` for programmatic inspection +## Git incremental ingestion (optional) + +You can ingest **only files that changed** in a git work tree since the last run, instead of scanning the whole directory every time. + +- In your profile YAML, set **`guide.git-ingestion.enabled: true`** and **`guide.git-ingestion.state-file`** to a JSON path (often under **`scripts/user-config/`** so it stays local and gitignored if you prefer). +- The runner records the **git HEAD** per configured **`guide.directories`** entry. If HEAD is unchanged, that directory is skipped on the next startup ingest. +- Directory entries may be **subfolders** of a repo (e.g. `…/spdd/canvas`). Guide walks up to the `.git` root, then only re-ingests files under that subfolder that changed since the last stored HEAD. +- To **force a full re-ingest** of one directory (for example after a bad partial run), remove that directory’s entry from the state file, or call **`POST /api/v1/data/git-ingestion/revision/reset`** with a JSON body **`{ "directory": "~/path/to/repo" }`** while Guide is running (same path style as in YAML). See **`scripts/README.md`** for curl examples and security notes. + +## Shared Neo4j (Embabel Hub or custom Bolt) + +Ingestion scripts can target **compose Neo4j** (default) or **another Bolt endpoint** (Hub, remote, etc.): + +- **`append-ingest.sh`** is for **adding** content; use **`USE_EMBABEL_HUB_NEO4J=1`** for the common Hub layout (Bolt on **27687**, credentials preset in the script header). Override **`NEO4J_BOLT_PORT`**, **`NEO4J_PASSWORD_HUB`**, etc. if your setup differs. +- **`fresh-ingest.sh`** **wipes** `ContentElement` data in the connected database. Do **not** point it at a **shared Hub** or production graph. +- For MCP against the **same** Bolt DB as ingest, you can use **`scripts/run-mcp-guide-against-hub.sh`** (see **`scripts/README.md`**). + +Embabel Hub and the default Guide stack both use **384-dimensional** local ONNX embeddings (`all-MiniLM-L6-v2`) for vector indexes; mixing embedding models against one graph requires a deliberate re-index / re-ingest strategy (see operator docs in **`scripts/README.md`**). + +## Operator API (purge and resync) + +With Guide running, you can **preview or delete** ingested chunks by **URI prefix** or by **directory** (resolved to a `file:` prefix), and reset **git** revision state for one directory. Endpoints are **`POST /api/v1/data/content-elements/purge-preview`**, **`.../purge`**, and **`.../git-ingestion/revision/reset`**. Full table, examples, and safety notes (**prefix length**, **`confirm: true`**) are in **`scripts/README.md`** under **Operator API (shared Neo4j)**. + ## MCP tools All ingested content -- both URLs and local directories -- is immediately available through the MCP tools (`docs_vectorSearch`, `docs_textSearch`, etc.). The MCP tools and the ingestion pipeline share the same Neo4j store, so there is no separate sync step. Once ingestion completes, MCP clients (Cursor, Claude Desktop, etc.) can search the content right away. diff --git a/scripts/user-config/application-menke-5-spdd-projection.yml.example b/scripts/user-config/application-menke-5-spdd-projection.yml.example new file mode 100644 index 0000000..9637b16 --- /dev/null +++ b/scripts/user-config/application-menke-5-spdd-projection.yml.example @@ -0,0 +1,15 @@ +# Example: enable SPDD leg 3 projection alongside menke-5 RAG ingest. +# Pair with orchestrator spike branch + POST /api/v1/data/spdd-projection/load + +guide: + reload-content-on-startup: false + spdd-projection: + enabled: true + default-root-path: ~/github/jmjava/sdlc-spdd-orchestrator + directories: + - ~/github/jmjava/sdlc-spdd-orchestrator/agent-context/memory + - ~/github/jmjava/sdlc-spdd-orchestrator/spdd/canvas + - ~/github/jmjava/sdlc-spdd-orchestrator/spdd/analysis + git-ingestion: + enabled: true + state-file: scripts/user-config/ingestion-git-revisions-menke-5.json diff --git a/scripts/user-config/application-user.yml.example b/scripts/user-config/application-user.yml.example index e970cf3..d893373 100644 --- a/scripts/user-config/application-user.yml.example +++ b/scripts/user-config/application-user.yml.example @@ -20,9 +20,13 @@ guide: - https://github.com/embabel/embabel-agent - https://github.com/embabel/embabel-agent-examples - https://github.com/embabel/dice - # Ingest full local repos (paths relative to working dir or absolute) + # Ingest local repos (paths relative to working dir or absolute) directories: - ./embabel-projects/dice - ./embabel-projects/embabel-agent - ./embabel-projects/embabel-agent-examples + # Optional: only ingest files that changed in git since last run (stores HEAD per directory in state-file) + git-ingestion: + enabled: true + state-file: scripts/user-config/ingestion-git-revisions.json tool-groups: diff --git a/src/main/java/com/embabel/guide/rag/DataManager.java b/src/main/java/com/embabel/guide/rag/DataManager.java index 909d66e..baac1ec 100644 --- a/src/main/java/com/embabel/guide/rag/DataManager.java +++ b/src/main/java/com/embabel/guide/rag/DataManager.java @@ -17,11 +17,15 @@ import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; /** * Exposes references and RAG configuration. @@ -165,19 +169,37 @@ public IngestionResult loadReferences() { loadedUrls.size(), guideProperties.getUrls().size(), failedUrls.size()); List dirs = guideProperties.getDirectories(); + GitIngestionRevisionStore gitRevisionStore = null; + var gitIngestion = guideProperties.getGitIngestion(); + if (gitIngestion != null && Boolean.TRUE.equals(gitIngestion.getEnabled())) { + Path stateFile = Path.of(guideProperties.resolvePath(gitIngestion.getStateFile())); + gitRevisionStore = new GitIngestionRevisionStore(stateFile); + gitRevisionStore.load(); + } if (dirs != null && !dirs.isEmpty()) { for (String dir : dirs) { try { String absolutePath = guideProperties.resolvePath(dir); - logger.info("⏳ Ingesting directory: {}...", absolutePath); - ingestDirectory(absolutePath, failedDocuments); - logger.info("✅ Ingested directory: {}", absolutePath); + boolean handledByGit = gitRevisionStore != null + && ingestDirectoryWithGitRevisionTracking(absolutePath, failedDocuments, gitRevisionStore); + if (!handledByGit) { + logger.info("⏳ Ingesting directory: {}...", absolutePath); + ingestDirectory(absolutePath, failedDocuments); + } + logger.info("✅ Processed directory: {}", absolutePath); ingestedDirs.add(absolutePath); } catch (Throwable t) { logger.error("❌ Failure ingesting directory {}: {}", dir, t.getMessage(), t); failedDirs.add(IngestionFailure.fromException(dir, t)); } } + if (gitRevisionStore != null && gitRevisionStore.isDirty()) { + try { + gitRevisionStore.save(); + } catch (IOException e) { + logger.error("Failed to save git ingestion revision file: {}", e.getMessage(), e); + } + } logger.info("Ingested {}/{} directories ({} dir failures, {} document failures)", ingestedDirs.size(), dirs.size(), failedDirs.size(), failedDocuments.size()); } else { @@ -188,4 +210,88 @@ public IngestionResult loadReferences() { failedDocuments, Duration.between(start, Instant.now())); } + /** + * When {@code guide.git-ingestion.enabled} is true and the path is inside a git work tree: skip if HEAD matches + * the last stored commit; otherwise ingest only files changed since that commit under this directory + * (or full tree of the configured directory if none stored). + * Configured paths may be repo subdirs (e.g. {@code spdd/canvas}); the git root is discovered by walking parents. + * Revisions are written to {@code guide.git-ingestion.state-file} when ingestion succeeds without new failures. + * + * @return true if git logic applied (including skip); false if caller should run a full directory ingest + */ + private boolean ingestDirectoryWithGitRevisionTracking( + String absolutePath, + List failedDocuments, + GitIngestionRevisionStore revStore + ) { + Path configured = Path.of(absolutePath).toAbsolutePath().normalize(); + Optional gitRootOpt = GitIncrementalDirectorySupport.findGitWorkTreeRoot(configured); + if (gitRootOpt.isEmpty()) { + return false; + } + Path gitRoot = gitRootOpt.get(); + Optional headOpt = GitIncrementalDirectorySupport.headCommit(gitRoot); + if (headOpt.isEmpty()) { + logger.warn("Could not resolve git HEAD for {} (root {}); performing full directory ingest", + absolutePath, gitRoot); + return false; + } + String head = headOpt.get(); + String previous = revStore.getRevision(absolutePath).orElse(""); + if (head.equals(previous)) { + logger.info("Skipping directory {} (unchanged git HEAD {})", absolutePath, head); + return true; + } + int failuresBefore = failedDocuments.size(); + if (previous.isEmpty()) { + logger.info("Git-tracked first ingest for {} at {} (repo {})", absolutePath, head, gitRoot); + ingestDirectory(absolutePath, failedDocuments); + } else { + List changedInRepo = GitIncrementalDirectorySupport.changedPathsBetween(gitRoot, previous, head); + List changed = GitIncrementalDirectorySupport.filterPathsUnderDirectory( + gitRoot, configured, changedInRepo); + if (changed.isEmpty()) { + logger.info("No added/modified files under {} between {}..{} (repo had {} change(s)); updating stored revision only", + absolutePath, previous, head, changedInRepo.size()); + } else { + logger.info("Incremental ingest: {} file(s) under {} changed {}..{}", + changed.size(), absolutePath, previous, head); + ingestChangedGitFiles(gitRoot.toString(), changed, failedDocuments); + } + } + if (failedDocuments.size() == failuresBefore) { + revStore.putRevision(absolutePath, head); + } else { + logger.warn("Not updating stored git revision for {} until ingest succeeds ({} new failure(s))", + absolutePath, failedDocuments.size() - failuresBefore); + } + return true; + } + + private void ingestChangedGitFiles(String repoAbsolutePath, List relativePaths, + List failedDocuments) { + Path root = Path.of(repoAbsolutePath).toAbsolutePath().normalize(); + for (String rel : relativePaths) { + if (rel == null || rel.isBlank()) { + continue; + } + String normalizedRel = rel.replace('\\', '/'); + Path file = root.resolve(normalizedRel).normalize(); + if (!file.startsWith(root)) { + logger.warn("Skipping path outside repo: {}", rel); + continue; + } + if (!Files.isRegularFile(file)) { + continue; + } + try { + NavigableDocument doc = hierarchicalContentReader.parseFile(file.toFile(), normalizedRel); + store.writeAndChunkDocument(doc); + } catch (Throwable t) { + logger.error("Failed incremental ingest {}: {}", rel, t.getMessage(), t); + failedDocuments.add(IngestionFailure.fromException(repoAbsolutePath + " -> " + rel, t)); + } + } + } + } diff --git a/src/main/java/com/embabel/guide/rag/GitIncrementalDirectorySupport.java b/src/main/java/com/embabel/guide/rag/GitIncrementalDirectorySupport.java new file mode 100644 index 0000000..441ca6a --- /dev/null +++ b/src/main/java/com/embabel/guide/rag/GitIncrementalDirectorySupport.java @@ -0,0 +1,148 @@ +package com.embabel.guide.rag; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Runs local {@code git} to detect HEAD and changed paths between two commits for incremental RAG ingestion. + */ +public final class GitIncrementalDirectorySupport { + + private static final Logger logger = LoggerFactory.getLogger(GitIncrementalDirectorySupport.class); + + private GitIncrementalDirectorySupport() { + } + + public static boolean isGitWorkTree(Path dir) { + if (!Files.isDirectory(dir)) { + return false; + } + Path dotGit = dir.resolve(".git"); + return Files.isDirectory(dotGit) || Files.isRegularFile(dotGit); + } + + /** + * Walks parents from {@code start} until a directory containing {@code .git} is found. + * Needed because guide profiles often list subdirs (e.g. {@code spdd/canvas}) rather than the repo root. + */ + public static Optional findGitWorkTreeRoot(Path start) { + if (start == null) { + return Optional.empty(); + } + Path cur = start.toAbsolutePath().normalize(); + if (!Files.isDirectory(cur)) { + cur = cur.getParent(); + } + while (cur != null) { + if (isGitWorkTree(cur)) { + return Optional.of(cur); + } + cur = cur.getParent(); + } + return Optional.empty(); + } + + /** + * Keeps repo-relative paths that lie under {@code configuredDir} (itself under {@code gitRoot}). + * When {@code configuredDir} is the repo root, all paths are returned. + */ + public static List filterPathsUnderDirectory( + Path gitRoot, + Path configuredDir, + List repoRelativePaths + ) { + if (repoRelativePaths == null || repoRelativePaths.isEmpty()) { + return List.of(); + } + Path root = gitRoot.toAbsolutePath().normalize(); + Path dir = configuredDir.toAbsolutePath().normalize(); + if (!dir.startsWith(root)) { + return List.of(); + } + String prefix = root.relativize(dir).toString().replace('\\', '/'); + if (prefix.isEmpty() || ".".equals(prefix)) { + return new ArrayList<>(repoRelativePaths); + } + String prefixSlash = prefix.endsWith("/") ? prefix : prefix + "/"; + List out = new ArrayList<>(); + for (String p : repoRelativePaths) { + if (p == null || p.isBlank()) { + continue; + } + String n = p.replace('\\', '/'); + if (n.equals(prefix) || n.startsWith(prefixSlash)) { + out.add(n); + } + } + return out; + } + + public static Optional headCommit(Path repoDir) { + return runGit(repoDir, List.of("rev-parse", "HEAD"), Duration.ofSeconds(30)) + .filter(s -> !s.isBlank()) + .map(String::trim); + } + + /** + * Paths relative to repo root that were added, copied, modified, renamed, or type-changed between the two refs. + * Excludes pure deletes (RAG chunks for removed files are left until a full re-ingest). + */ + public static List changedPathsBetween(Path repoDir, String fromRef, String toRef) { + if (fromRef == null || fromRef.isBlank() || toRef == null || toRef.isBlank()) { + return List.of(); + } + Optional out = runGit(repoDir, + List.of("diff", "--name-only", "--diff-filter=ACMRTUXB", fromRef, toRef), + Duration.ofMinutes(2)); + if (out.isEmpty()) { + return List.of(); + } + List paths = new ArrayList<>(); + for (String line : out.get().split("\n")) { + String t = line.trim(); + if (!t.isEmpty()) { + paths.add(t.replace('\\', '/')); + } + } + return paths; + } + + private static Optional runGit(Path repoDir, List gitArgs, Duration timeout) { + List cmd = new ArrayList<>(); + cmd.add("git"); + cmd.add("-C"); + cmd.add(repoDir.toAbsolutePath().toString()); + cmd.addAll(gitArgs); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + try { + Process p = pb.start(); + boolean finished = p.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS); + if (!finished) { + p.destroyForcibly(); + logger.warn("git timed out: {}", String.join(" ", cmd)); + return Optional.empty(); + } + String output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + if (p.exitValue() != 0) { + logger.warn("git exited {} for {}: {}", p.exitValue(), String.join(" ", cmd), output); + return Optional.empty(); + } + return Optional.of(output); + } catch (IOException | InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("git failed for {}: {}", String.join(" ", cmd), e.getMessage()); + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/embabel/guide/rag/GitIngestionRevisionStore.java b/src/main/java/com/embabel/guide/rag/GitIngestionRevisionStore.java new file mode 100644 index 0000000..c2f26e8 --- /dev/null +++ b/src/main/java/com/embabel/guide/rag/GitIngestionRevisionStore.java @@ -0,0 +1,78 @@ +package com.embabel.guide.rag; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Persists last successfully ingested git commit per absolute repository path (JSON map). + */ +public final class GitIngestionRevisionStore { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final Path file; + private Map revisions = new LinkedHashMap<>(); + private boolean dirty; + + public GitIngestionRevisionStore(Path file) { + this.file = file; + } + + public void load() { + revisions = new LinkedHashMap<>(); + dirty = false; + if (!Files.isRegularFile(file)) { + return; + } + try { + revisions = new LinkedHashMap<>( + MAPPER.readValue(file.toFile(), new TypeReference>() { + })); + } catch (IOException e) { + revisions = new LinkedHashMap<>(); + } + } + + public Optional getRevision(String absoluteRepoPath) { + return Optional.ofNullable(revisions.get(absoluteRepoPath)); + } + + public void putRevision(String absoluteRepoPath, String commit) { + revisions.put(absoluteRepoPath, commit); + dirty = true; + } + + /** + * Remove stored HEAD for a repo path (e.g. before a deliberate full re-ingest). + * + * @return true if an entry existed and was removed + */ + public boolean removeRevision(String absoluteRepoPath) { + String removed = revisions.remove(absoluteRepoPath); + if (removed != null) { + dirty = true; + return true; + } + return false; + } + + public boolean isDirty() { + return dirty; + } + + public void save() throws IOException { + Path parent = file.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + MAPPER.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), revisions); + dirty = false; + } +} diff --git a/src/main/java/com/embabel/guide/rag/IngestionRunner.java b/src/main/java/com/embabel/guide/rag/IngestionRunner.java index c1ff20c..78cee13 100644 --- a/src/main/java/com/embabel/guide/rag/IngestionRunner.java +++ b/src/main/java/com/embabel/guide/rag/IngestionRunner.java @@ -3,6 +3,8 @@ import com.embabel.agent.rag.store.ContentElementRepositoryInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; @@ -21,12 +23,14 @@ public class IngestionRunner implements ApplicationRunner { private static final Logger logger = LoggerFactory.getLogger(IngestionRunner.class); private final DataManager dataManager; + private final ObjectProvider embeddingModel; @Value("${server.port:8080}") private int serverPort; - public IngestionRunner(DataManager dataManager) { + public IngestionRunner(DataManager dataManager, ObjectProvider embeddingModel) { this.dataManager = dataManager; + this.embeddingModel = embeddingModel; } @Override @@ -92,6 +96,8 @@ private void printSummary(IngestionResult result, ContentElementRepositoryInfo s sb.append(" Documents: ").append(stats.getDocumentCount()).append("\n"); sb.append(" Chunks: ").append(stats.getChunkCount()).append("\n"); sb.append(" Elements: ").append(stats.getContentElementCount()).append("\n"); + embeddingModel.ifAvailable(model -> + sb.append(" Embedding dimensions (runtime model): ").append(model.dimensions()).append("\n")); sb.append("\n"); sb.append(" Guide is running on port ").append(serverPort).append("\n"); diff --git a/src/main/java/com/embabel/guide/rag/RagMaintenanceController.java b/src/main/java/com/embabel/guide/rag/RagMaintenanceController.java new file mode 100644 index 0000000..c8014e7 --- /dev/null +++ b/src/main/java/com/embabel/guide/rag/RagMaintenanceController.java @@ -0,0 +1,71 @@ +package com.embabel.guide.rag; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Operator endpoints for shared Neo4j: scoped ContentElement purge and git revision reset. + *

+ * These are {@code permitAll} in {@link com.embabel.guide.chat.security.SecurityConfig} like + * {@code /api/v1/data/load-references}; do not expose Guide to untrusted networks without a reverse proxy. + */ +@RestController +@RequestMapping("/api/v1/data") +public class RagMaintenanceController { + + private final RagContentMaintenanceService maintenanceService; + + public RagMaintenanceController(RagContentMaintenanceService maintenanceService) { + this.maintenanceService = maintenanceService; + } + + @PostMapping("/content-elements/purge-preview") + public ResponseEntity purgePreview(@RequestBody PurgePreviewRequest body) { + int limit = body.sampleLimit() != null ? body.sampleLimit() : 10; + RagContentMaintenanceService.PurgePreviewResult r = maintenanceService.previewPurge( + body.uriPrefix(), body.directory(), limit); + return ResponseEntity.ok(new PurgePreviewResponse( + r.getAppliedUriPrefix(), r.getMatchCount(), r.getSampleUris())); + } + + @PostMapping("/content-elements/purge") + public ResponseEntity purge(@RequestBody PurgeExecuteRequest body) { + RagContentMaintenanceService.PurgeExecuteResult r = maintenanceService.executePurge( + body.uriPrefix(), body.directory(), body.confirm()); + return ResponseEntity.ok(new PurgeExecuteResponse(r.getAppliedUriPrefix(), r.getDeletedCount())); + } + + @PostMapping("/git-ingestion/revision/reset") + public ResponseEntity resetGitRevision(@RequestBody GitRevisionResetRequest body) { + if (body.directory() == null || body.directory().isBlank()) { + return ResponseEntity.badRequest().body(new GitRevisionResetResponse( + null, false, "directory is required")); + } + try { + RagContentMaintenanceService.GitRevisionResetResult r = + maintenanceService.resetGitIngestionRevision(body.directory()); + return ResponseEntity.ok(new GitRevisionResetResponse( + r.getAbsolutePath(), r.getRemoved(), r.getMessage())); + } catch (java.io.IOException e) { + return ResponseEntity.internalServerError().body(new GitRevisionResetResponse( + null, false, "Failed to save revision file: " + e.getMessage())); + } + } + + public record PurgePreviewRequest(String uriPrefix, String directory, Integer sampleLimit) {} + + public record PurgePreviewResponse(String appliedUriPrefix, long matchCount, List sampleUris) {} + + public record PurgeExecuteRequest(String uriPrefix, String directory, boolean confirm) {} + + public record PurgeExecuteResponse(String appliedUriPrefix, long deletedCount) {} + + public record GitRevisionResetRequest(String directory) {} + + public record GitRevisionResetResponse(String absolutePath, boolean removed, String message) {} +} diff --git a/src/main/kotlin/com/embabel/guide/GuideProperties.kt b/src/main/kotlin/com/embabel/guide/GuideProperties.kt index 77fcab7..83959cc 100644 --- a/src/main/kotlin/com/embabel/guide/GuideProperties.kt +++ b/src/main/kotlin/com/embabel/guide/GuideProperties.kt @@ -46,6 +46,8 @@ data class ContentConfig( * @param content content source configuration (versioned docs + supplementary) * @param directories optional list of local directory paths to ingest (full tree); resolved like projectsPath * @param toolGroups toolGroups, such as "web", that are allowed + * @param fetchRoutes optional content-fetcher route patterns + * @param gitIngestion optional git-based incremental ingestion for configured directories */ @Validated @ConfigurationProperties(prefix = "guide") @@ -66,11 +68,37 @@ data class GuideProperties( val directories: List?, val toolGroups: Set, val fetchRoutes: List = emptyList(), + @NestedConfigurationProperty val gitIngestion: GitIngestion? = null, + @NestedConfigurationProperty val spddProjection: SpddProjection = SpddProjection(), ) { /** All URLs to ingest (versioned + supplementary). */ val urls: List get() = content.allUrls() + /** + * Leg 3 SPDD entity projection (SPIKE-001). Independent of leg 2 RAG directory ingest. + * + * @param enabled activates the projection beans, HTTP operator API, and MCP tools + * @param defaultRootPath project root scanned when the load request carries no rootPath + * @param allowedRoots additional roots a load request may override to; the resolved + * override must live under one of these (or under [defaultRootPath]). + * Guards the permit-all operator endpoint against arbitrary path scans. + */ + data class SpddProjection( + val enabled: Boolean = false, + val defaultRootPath: String = ".", + val allowedRoots: List = emptyList(), + ) + + /** + * When [enabled], each configured directory that is a git work tree ingests only files changed since the + * last stored commit (see [stateFile]). Non-git directories are always fully ingested. + */ + data class GitIngestion( + val enabled: Boolean = false, + val stateFile: String = "scripts/user-config/ingestion-git-revisions.json", + ) + fun toolNamingStrategy(): StringTransformer = StringTransformer { name -> toolPrefix + name } /** diff --git a/src/main/kotlin/com/embabel/guide/chat/security/SecurityConfig.kt b/src/main/kotlin/com/embabel/guide/chat/security/SecurityConfig.kt index f51b3a6..a4c5ced 100644 --- a/src/main/kotlin/com/embabel/guide/chat/security/SecurityConfig.kt +++ b/src/main/kotlin/com/embabel/guide/chat/security/SecurityConfig.kt @@ -95,6 +95,10 @@ class SecurityConfig( "/api/hub/refresh", "/api/hub/feedback", "/api/v1/data/load-references", + "/api/v1/data/content-elements/purge-preview", + "/api/v1/data/content-elements/purge", + "/api/v1/data/git-ingestion/revision/reset", + "/api/v1/data/spdd-projection/load", "/api/hub/integrations/keys/validate", "/api/hub/oauth/*/callback", ).permitAll() @@ -104,6 +108,9 @@ class SecurityConfig( "/api/hub/personas", "/api/hub/sessions", "/api/v1/data/stats", + "/api/v1/data/spdd-projection/stats", + "/api/v1/data/spdd-projection/work/*", + "/api/v1/data/spdd-projection/area", "/api/v1/deepgram/models", "/api/hub/oauth/*/authorize", "/api/hub/email/verify", diff --git a/src/main/kotlin/com/embabel/guide/rag/RagContentMaintenanceService.kt b/src/main/kotlin/com/embabel/guide/rag/RagContentMaintenanceService.kt new file mode 100644 index 0000000..72edaba --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/rag/RagContentMaintenanceService.kt @@ -0,0 +1,158 @@ +package com.embabel.guide.rag + +import com.embabel.agent.rag.graph.DrivineCypherSearch +import com.embabel.guide.GuideProperties +import org.drivine.query.QuerySpecification +import org.drivine.manager.PersistenceManager +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.io.File +import java.io.IOException +import java.nio.file.Path + +/** + * Operator helpers for shared Neo4j: purge ContentElement nodes by URI prefix and reset git-ingestion state. + */ +@Service +class RagContentMaintenanceService( + private val guideProperties: GuideProperties, + @Qualifier("neo") private val persistenceManager: PersistenceManager, +) { + + private val log = LoggerFactory.getLogger(javaClass) + + /** Not a Spring bean: [DrivineCypherSearch] is final and cannot be proxied. */ + private val cypherSearch = DrivineCypherSearch(persistenceManager) + + /** + * Resolves [directory] (YAML-style path) to a [file:] URI prefix, or trims [uriPrefix]. + * Leading/trailing whitespace is trimmed. At least one of the two must be non-blank. + */ + fun resolveAppliedPrefix(uriPrefix: String?, directory: String?): String { + val trimmedPrefix = uriPrefix?.trim().orEmpty() + val trimmedDir = directory?.trim().orEmpty() + if (trimmedPrefix.isNotEmpty() && trimmedDir.isNotEmpty()) { + throw IllegalArgumentException("Provide only one of uriPrefix or directory, not both") + } + if (trimmedPrefix.isEmpty() && trimmedDir.isEmpty()) { + throw IllegalArgumentException("Provide uriPrefix or directory") + } + if (trimmedDir.isNotEmpty()) { + val abs = guideProperties.resolvePath(trimmedDir) + return File(abs).toURI().toString() + } + return trimmedPrefix + } + + fun previewPurge(uriPrefix: String?, directory: String?, sampleLimit: Int): PurgePreviewResult { + val prefix = resolveAppliedPrefix(uriPrefix, directory) + validatePrefixSafety(prefix) + val count = countByUriPrefix(prefix) + val samples = sampleUris(prefix, sampleLimit.coerceIn(1, 50)) + return PurgePreviewResult(prefix, count, samples) + } + + @Transactional(transactionManager = "drivineTransactionManager") + fun executePurge(uriPrefix: String?, directory: String?, confirm: Boolean): PurgeExecuteResult { + if (!confirm) { + throw IllegalArgumentException("confirm must be true to delete content") + } + val prefix = resolveAppliedPrefix(uriPrefix, directory) + validatePrefixSafety(prefix) + val before = countByUriPrefix(prefix) + deleteByUriPrefix(prefix) + log.warn("Purged {} ContentElement node(s) with uri STARTS WITH '{}'", before, prefix) + return PurgeExecuteResult(prefix, before) + } + + /** + * Clears stored git HEAD for [directory] so the next incremental ingest does a full tree + * (or first-time behavior). Requires [GuideProperties.gitIngestion] enabled. + */ + @Throws(IOException::class) + fun resetGitIngestionRevision(directory: String): GitRevisionResetResult { + val git = guideProperties.gitIngestion + ?: return GitRevisionResetResult(null, false, "guide.git-ingestion is not configured") + if (!git.enabled) { + return GitRevisionResetResult(null, false, "guide.git-ingestion.enabled is false") + } + val abs = guideProperties.resolvePath(directory.trim()) + val store = GitIngestionRevisionStore(Path.of(guideProperties.resolvePath(git.stateFile))) + store.load() + val removed = store.removeRevision(abs) + if (store.isDirty) { + store.save() + } + val msg = if (removed) { + "Removed revision entry for $abs" + } else { + "No revision entry existed for $abs" + } + log.info("Git ingestion revision reset: {}", msg) + return GitRevisionResetResult(abs, removed, msg) + } + + private fun validatePrefixSafety(prefix: String) { + require(prefix.length >= MIN_PREFIX_LENGTH) { + "uriPrefix too short (min $MIN_PREFIX_LENGTH chars) — refusing to avoid accidental mass delete" + } + } + + private fun countByUriPrefix(prefix: String): Long { + val cypher = """ + MATCH (n:ContentElement) + WHERE n.uri STARTS WITH ${'$'}prefix + RETURN count(n) AS cnt + """.trimIndent().let { "\n$it" } + return cypherSearch.queryForInt(cypher, mapOf("prefix" to prefix)).toLong() + } + + private fun sampleUris(prefix: String, limit: Int): List { + val cypher = """ + MATCH (n:ContentElement) + WHERE n.uri STARTS WITH ${'$'}prefix + RETURN n.uri AS uri + LIMIT ${'$'}lim + """.trimIndent().let { "\n$it" } + val qr = cypherSearch.query( + "purge preview sample uris", + cypher, + mapOf("prefix" to prefix, "lim" to limit), + log, + ) + return qr.items().mapNotNull { row -> row["uri"]?.toString() }.distinct() + } + + private fun deleteByUriPrefix(prefix: String) { + val cypher = """ + MATCH (n:ContentElement) + WHERE n.uri STARTS WITH ${'$'}prefix + DETACH DELETE n + """.trimIndent().let { "\n$it" } + val spec = QuerySpecification.withStatement(cypher).bind(mapOf("prefix" to prefix)) + persistenceManager.execute(spec) + } + + data class PurgePreviewResult( + val appliedUriPrefix: String, + val matchCount: Long, + val sampleUris: List, + ) + + data class PurgeExecuteResult( + val appliedUriPrefix: String, + val deletedCount: Long, + ) + + data class GitRevisionResetResult( + val absolutePath: String?, + val removed: Boolean, + val message: String, + ) + + companion object { + private const val MIN_PREFIX_LENGTH = 8 + } +} diff --git a/src/main/kotlin/com/embabel/guide/rag/RagMaintenanceExceptionHandler.kt b/src/main/kotlin/com/embabel/guide/rag/RagMaintenanceExceptionHandler.kt new file mode 100644 index 0000000..bcf3df2 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/rag/RagMaintenanceExceptionHandler.kt @@ -0,0 +1,14 @@ +package com.embabel.guide.rag + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice(assignableTypes = [RagMaintenanceController::class]) +class RagMaintenanceExceptionHandler { + + @ExceptionHandler(IllegalArgumentException::class) + fun badRequest(ex: IllegalArgumentException): ResponseEntity> = + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to ex.message)) +} diff --git a/src/main/kotlin/com/embabel/guide/spdd/SpddDomainTools.kt b/src/main/kotlin/com/embabel/guide/spdd/SpddDomainTools.kt new file mode 100644 index 0000000..9c81198 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/spdd/SpddDomainTools.kt @@ -0,0 +1,74 @@ +package com.embabel.guide.spdd + +import com.embabel.agent.api.annotation.LlmTool +import com.fasterxml.jackson.databind.ObjectMapper + +/** + * MCP / LLM tools for SPIKE-001 leg 3 (DICE domain graph). + * + * Complements `docs_*` chunk tools: these return typed `__Entity__` neighbors via + * relationships (`canvas`, `area`), not embedding similarity. + * + * Exported via [com.embabel.agent.mcpserver.McpToolExport.fromToolObject], which discovers + * Embabel [@LlmTool] methods (not Spring AI `@Tool`). + */ +class SpddDomainTools( + private val projectionService: SpddMarkdownProjectionService, + private val objectMapper: ObjectMapper, +) { + + @LlmTool( + description = "DICE domain retrieve: WorkId subgraph via typed edges (canvas, area). " + + "Use for auditable SPDD context by Work ID, not chunk similarity.", + ) + fun workSubgraph( + @LlmTool.Param(description = "Work ID, e.g. SPIKE-001-guide-rag-context-backend") workId: String, + ): String = safeJson { + projectionService.subgraphForWorkId(workId.trim()) + } + + @LlmTool( + description = "DICE domain stats: counts of projected WorkId, Canvas, Area, Decision, Pitfall, " + + "and Pattern entities in Neo4j.", + ) + fun projectionStats(): String = safeJson { + mapOf( + "workIdCount" to projectionService.entityCountByLabel("WorkId"), + "canvasCount" to projectionService.entityCountByLabel("Canvas"), + "areaCount" to projectionService.entityCountByLabel("Area"), + "decisionCount" to projectionService.entityCountByLabel("Decision"), + "pitfallCount" to projectionService.entityCountByLabel("Pitfall"), + "patternCount" to projectionService.entityCountByLabel("Pattern"), + "entityLabel" to com.embabel.agent.rag.model.NamedEntityData.ENTITY_LABEL, + ) + } + + @LlmTool( + description = "DICE domain list: NamedEntity nodes by label (WorkId, Canvas, Area, Decision, Pitfall). " + + "Results are capped; use workSubgraph for targeted retrieval.", + ) + fun findByLabel( + @LlmTool.Param(description = "Entity label, e.g. WorkId or Area") label: String, + ): String = safeJson { + projectionService.listByLabel(label.trim()) + } + + @LlmTool( + description = "DICE cross-run lessons by code area: decisions, pitfalls, and patterns recorded by ANY " + + "previous Work ID against this area, plus the Work IDs that touched it. " + + "Use before modifying code in an area to inherit prior lessons.", + ) + fun areaLessons( + @LlmTool.Param(description = "Code area name as recorded in the context index, e.g. 'scripts/'") area: String, + ): String = safeJson { + projectionService.lessonsForArea(area) + } + + /** + * MCP tool results are consumed by an LLM: surface validation problems as a structured + * `{"error": …}` payload instead of letting exceptions escape as opaque protocol errors. + */ + private fun safeJson(block: () -> Any): String = + runCatching { objectMapper.writeValueAsString(block()) } + .getOrElse { objectMapper.writeValueAsString(mapOf("error" to (it.message ?: it.javaClass.simpleName))) } +} diff --git a/src/main/kotlin/com/embabel/guide/spdd/SpddEntityDictionary.kt b/src/main/kotlin/com/embabel/guide/spdd/SpddEntityDictionary.kt new file mode 100644 index 0000000..6813970 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/spdd/SpddEntityDictionary.kt @@ -0,0 +1,34 @@ +package com.embabel.guide.spdd + +import com.embabel.agent.core.DataDictionary +import com.embabel.guide.spdd.domain.Area +import com.embabel.guide.spdd.domain.Canvas +import com.embabel.guide.spdd.domain.Decision +import com.embabel.guide.spdd.domain.Operation +import com.embabel.guide.spdd.domain.Pattern +import com.embabel.guide.spdd.domain.Pitfall +import com.embabel.guide.spdd.domain.WorkId + +/** + * SDLC-SPDD domain schema for leg 3 entity projection (SPIKE-001). + * + * Registered via Embabel's first-class path: [DataDictionary.fromClasses] over + * [com.embabel.agent.rag.model.NamedEntity] domain types (not DynamicType). + */ +object SpddEntityDictionary { + + private val domainClasses = arrayOf( + WorkId::class.java, + Canvas::class.java, + Operation::class.java, + Area::class.java, + Decision::class.java, + Pitfall::class.java, + Pattern::class.java, + ) + + /** Labels of the SPDD domain schema; used to validate retrieve-side label parameters. */ + val knownLabels: Set = domainClasses.mapNotNull { it.simpleName }.toSet() + + fun create(): DataDictionary = DataDictionary.fromClasses("sdlc-spdd", *domainClasses) +} diff --git a/src/main/kotlin/com/embabel/guide/spdd/SpddMarkdownProjectionService.kt b/src/main/kotlin/com/embabel/guide/spdd/SpddMarkdownProjectionService.kt new file mode 100644 index 0000000..8d39bf7 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/spdd/SpddMarkdownProjectionService.kt @@ -0,0 +1,379 @@ +package com.embabel.guide.spdd + +import com.embabel.agent.rag.model.NamedEntityData +import com.embabel.agent.rag.model.RelationshipDirection +import com.embabel.agent.rag.model.SimpleNamedEntityData +import com.embabel.agent.rag.service.NamedEntityDataRepository +import com.embabel.agent.rag.service.RelationshipData +import com.embabel.agent.rag.service.RetrievableIdentifier +import com.embabel.guide.GuideProperties +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Service +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.isRegularFile + +/** + * Leg 3 ingest: project structured SPDD markdown into Neo4j [NamedEntityData.ENTITY_LABEL] nodes. + * + * Coexists with leg 2 RAG chunk ingest ([com.embabel.guide.rag.DataManager]) — same Neo4j store, + * different node layer. Does **not** use the DICE proposition extraction pipeline. + * + * Persist contract: markdown is source of truth; [load] is idempotent merge-by-id via + * [NamedEntityDataRepository.save] + [NamedEntityDataRepository.mergeRelationship]. + * Retrieve contract: [subgraphForWorkId] walks typed edges from the WorkId join key. + */ +@Service +@ConditionalOnProperty(prefix = "guide.spdd-projection", name = ["enabled"], havingValue = "true") +class SpddMarkdownProjectionService( + private val guideProperties: GuideProperties, + private val entityRepository: NamedEntityDataRepository, +) { + + private val log = LoggerFactory.getLogger(javaClass) + + private val entityDictionary = SpddEntityDictionary.create() + + fun load(rootPath: String? = null): SpddProjectionResult { + val projection = guideProperties.spddProjection + if (!projection.enabled) { + throw IllegalStateException("guide.spdd-projection.enabled is false") + } + val root = resolveRoot(rootPath) + require(Files.isDirectory(root)) { "SPDD projection root not found: $root" } + + var workIds = 0 + var canvases = 0 + var areas = 0 + var operations = 0 + var decisions = 0 + var pitfalls = 0 + var patterns = 0 + var relationships = 0 + var skippedFiles = 0 + + val canvasDir = root.resolve("spdd/canvas") + if (Files.isDirectory(canvasDir)) { + Files.list(canvasDir).use { stream -> + stream.filter { it.isRegularFile() && it.fileName.toString().endsWith(".md") } + .sorted() + .forEach { path -> + // A single malformed canvas must not fail the whole load. + val r = runCatching { projectCanvas(root, path) } + .onFailure { log.warn("SPDD projection: skipping canvas {}: {}", path, it.message) } + .getOrNull() + if (r == null) { + skippedFiles++ + } else { + workIds += r.workIds + canvases += r.canvases + operations += r.operations + relationships += r.relationships + } + } + } + } + + val contextIndex = root.resolve("agent-context/memory/context-index.md") + if (Files.isRegularFile(contextIndex)) { + val r = runCatching { projectContextIndex(root, contextIndex) } + .onFailure { log.warn("SPDD projection: skipping context index {}: {}", contextIndex, it.message) } + .getOrNull() + if (r == null) { + skippedFiles++ + } else { + areas += r.areas + decisions += r.decisions + pitfalls += r.pitfalls + patterns += r.patterns + relationships += r.relationships + } + } + + log.info( + "SPDD projection complete root={} workIds={} canvases={} areas={} ops={} rels={} skipped={}", + root, workIds, canvases, areas, operations, relationships, skippedFiles, + ) + + return SpddProjectionResult( + rootPath = root.toString(), + workIds = workIds, + canvases = canvases, + areas = areas, + operations = operations, + decisions = decisions, + pitfalls = pitfalls, + patterns = patterns, + relationships = relationships, + skippedFiles = skippedFiles, + ) + } + + /** + * Resolve the projection root. Overrides are only honoured when the resolved path lives + * under the default root or one of `guide.spdd-projection.allowed-roots` — the load + * endpoint is reachable without auth, so arbitrary filesystem roots must be rejected. + */ + private fun resolveRoot(rootPath: String?): Path { + val projection = guideProperties.spddProjection + val defaultRoot = Path.of(guideProperties.resolvePath(projection.defaultRootPath)).normalize() + val requested = rootPath?.trim()?.takeIf { it.isNotEmpty() } ?: return defaultRoot + val override = Path.of(guideProperties.resolvePath(requested)).normalize() + // listOf() wrapper matters: Path is Iterable, so `list + path` would + // concatenate the path's COMPONENTS, not append the path itself. + val allowedRoots = projection.allowedRoots + .map { Path.of(guideProperties.resolvePath(it)).normalize() } + listOf(defaultRoot) + require(allowedRoots.any { override.startsWith(it) }) { + "rootPath override '$override' is not under an allowed root $allowedRoots; " + + "configure guide.spdd-projection.allowed-roots to permit it" + } + return override + } + + fun entityCountByLabel(label: String): Int = + entityRepository.findByLabel(label).size + + /** + * List projected entities by schema label. The label must be part of the + * [SpddEntityDictionary] schema and results are capped at [maxResults] + * (bounded by [MAX_LIST_RESULTS]) to keep MCP/HTTP payloads predictable. + */ + fun listByLabel(label: String, maxResults: Int = DEFAULT_LIST_RESULTS): List { + val normalized = requireKnownLabel(label) + val cap = maxResults.coerceIn(1, MAX_LIST_RESULTS) + return entityRepository.findByLabel(normalized).take(cap).map { toSummary(it) } + } + + private fun requireKnownLabel(label: String): String { + val normalized = label.trim() + require(normalized in SpddEntityDictionary.knownLabels) { + "Unknown entity label '$normalized'. Known labels: ${SpddEntityDictionary.knownLabels.sorted()}" + } + return normalized + } + + /** + * Domain retrieval by Work ID join key: WorkId → canvas / area / decision / pitfall neighbors. + * Auditability: each neighbor is included because of a typed relationship, not cosine. + */ + fun subgraphForWorkId(workId: String): SpddWorkIdSubgraph { + require(workId.isNotBlank()) { "workId must not be blank" } + val work = entityRepository.findById(workId) + ?: return SpddWorkIdSubgraph(workId = workId, found = false) + val workRef = RetrievableIdentifier(workId, "WorkId") + val canvases = entityRepository.findRelated(workRef, REL_CANVAS, RelationshipDirection.OUTGOING) + val areas = entityRepository.findRelated(workRef, REL_AREA, RelationshipDirection.OUTGOING) + val decisions = entityRepository.findRelated(workRef, REL_DECISION, RelationshipDirection.OUTGOING) + val pitfalls = entityRepository.findRelated(workRef, REL_PITFALL, RelationshipDirection.OUTGOING) + val patterns = entityRepository.findRelated(workRef, REL_PATTERN, RelationshipDirection.OUTGOING) + return SpddWorkIdSubgraph( + workId = workId, + found = true, + work = toSummary(work), + canvases = canvases.map { toSummary(it) }, + areas = areas.map { toSummary(it) }, + decisions = decisions.map { toSummary(it) }, + pitfalls = pitfalls.map { toSummary(it) }, + patterns = patterns.map { toSummary(it) }, + ) + } + + /** + * Cross-run lesson retrieval by code area: every Decision / Pitfall / Pattern recorded + * by *any* Work ID against this area (via incoming `about` edges), plus the Work IDs + * that touched it (via incoming `area` edges). This is the "I'm about to modify area X, + * what did previous runs learn?" query. + */ + fun lessonsForArea(area: String): SpddAreaLessons { + require(area.isNotBlank()) { "area must not be blank" } + val normalized = area.trim().removePrefix("area:") + val areaId = "area:$normalized" + val areaEntity = entityRepository.findById(areaId) + ?: return SpddAreaLessons(area = normalized, found = false) + val areaRef = RetrievableIdentifier(areaId, "Area") + val lessons = entityRepository.findRelated(areaRef, REL_ABOUT, RelationshipDirection.INCOMING) + val works = entityRepository.findRelated(areaRef, REL_AREA, RelationshipDirection.INCOMING) + return SpddAreaLessons( + area = normalized, + found = true, + areaEntity = toSummary(areaEntity), + workIds = works.map { toSummary(it) }, + decisions = lessons.filter { "Decision" in it.labels() }.map { toSummary(it) }, + pitfalls = lessons.filter { "Pitfall" in it.labels() }.map { toSummary(it) }, + patterns = lessons.filter { "Pattern" in it.labels() }.map { toSummary(it) }, + ) + } + + private fun toSummary(entity: NamedEntityData): SpddEntitySummary = + SpddEntitySummary( + id = entity.id, + name = entity.name, + description = entity.description, + labels = entity.labels().toList(), + uri = entity.uri, + ) + + private data class PartialResult( + val workIds: Int = 0, + val canvases: Int = 0, + val areas: Int = 0, + val operations: Int = 0, + val decisions: Int = 0, + val pitfalls: Int = 0, + val patterns: Int = 0, + val relationships: Int = 0, + ) + + private fun projectCanvas(root: Path, canvasPath: Path): PartialResult { + val text = Files.readString(canvasPath) + val workId = WORK_ID_PATTERN.find(text)?.groupValues?.get(1)?.trim() + ?: return PartialResult() + val title = CANVAS_TITLE_PATTERN.find(text)?.groupValues?.get(2)?.trim() ?: workId + val uri = canvasPath.toUri().toString() + + val workEntity = saveEntity( + id = workId, + uri = uri, + name = workId, + description = title, + label = "WorkId", + properties = mapOf("path" to canvasPath.toString()), + ) + val canvasEntity = saveEntity( + id = "$workId:canvas", + uri = uri, + name = title, + description = "REASONS canvas for $workId", + label = "Canvas", + properties = mapOf("path" to canvasPath.toString()), + ) + link(workEntity, canvasEntity, "canvas") + + return PartialResult(workIds = 1, canvases = 1, operations = 0, relationships = 1) + } + + private fun projectContextIndex(root: Path, indexPath: Path): PartialResult { + val lines = Files.readAllLines(indexPath) + var areas = 0 + var decisions = 0 + var pitfalls = 0 + var patterns = 0 + var rels = 0 + val seenAreas = mutableSetOf() + + for (line in lines) { + if (!line.startsWith("|") || line.contains("Area | Kind") || line.matches(Regex("^\\|[-| ]+\\|$"))) { + continue + } + val cols = line.split('|').map { it.trim() }.filter { it.isNotEmpty() } + if (cols.size < 7) continue + val area = cols[0] + val kind = cols[1].lowercase() + val workId = cols[2] + if (area.isBlank() || workId.isBlank()) continue + + if (seenAreas.add(area)) { + saveEntity( + id = "area:$area", + uri = indexPath.toUri().toString() + "#area-$area", + name = area, + description = "Code area $area", + label = "Area", + properties = mapOf("area" to area), + ) + areas++ + } + + val workRef = RetrievableIdentifier(workId, "WorkId") + val areaRef = RetrievableIdentifier("area:$area", "Area") + entityRepository.mergeRelationship(workRef, areaRef, RelationshipData(REL_AREA, emptyMap())) + rels++ + + val lessonLabel = LESSON_KIND_LABELS[kind] ?: continue + val relName = kind // decision / pitfall / pattern — matches REL_* constants + val lesson = saveEntity( + id = "$kind:$workId:$area:${cols[5]}", + uri = indexPath.toUri().toString() + "#$workId-$kind", + name = cols.getOrElse(6) { kind }, + description = cols.getOrElse(6) { kind }, + label = lessonLabel, + properties = mapOf("workId" to workId, "area" to area, "source" to cols[5]), + ) + val lessonRef = RetrievableIdentifier(lesson.id, lessonLabel) + // WorkId → lesson: "what did this work record?" + entityRepository.mergeRelationship(workRef, lessonRef, RelationshipData(relName, emptyMap())) + // Lesson → Area: "what has ANY work recorded against this area?" (cross-run lookup) + entityRepository.mergeRelationship(lessonRef, areaRef, RelationshipData(REL_ABOUT, emptyMap())) + rels += 2 + when (lessonLabel) { + "Decision" -> decisions++ + "Pitfall" -> pitfalls++ + "Pattern" -> patterns++ + } + } + + return PartialResult( + areas = areas, + decisions = decisions, + pitfalls = pitfalls, + patterns = patterns, + relationships = rels, + ) + } + + private fun saveEntity( + id: String, + uri: String, + name: String, + description: String, + label: String, + properties: Map = emptyMap(), + ): SimpleNamedEntityData { + val entity = SimpleNamedEntityData( + id = id, + uri = uri, + name = name, + description = description, + labels = setOf(label, NamedEntityData.ENTITY_LABEL), + properties = properties, + metadata = emptyMap(), + linkedDomainType = entityDictionary.domainTypeForLabels(setOf(label)), + ) + entityRepository.save(entity) + return entity + } + + private fun link(from: SimpleNamedEntityData, to: SimpleNamedEntityData, rel: String) { + entityRepository.mergeRelationship( + RetrievableIdentifier(from.id, from.labels.first { it != NamedEntityData.ENTITY_LABEL }), + RetrievableIdentifier(to.id, to.labels.first { it != NamedEntityData.ENTITY_LABEL }), + RelationshipData(rel, emptyMap()), + ) + } + + companion object { + const val DEFAULT_LIST_RESULTS = 50 + const val MAX_LIST_RESULTS = 200 + + /** Typed relationship names of the SPDD domain graph. */ + const val REL_CANVAS = "canvas" + const val REL_AREA = "area" + const val REL_DECISION = "decision" + const val REL_PITFALL = "pitfall" + const val REL_PATTERN = "pattern" + + /** Lesson → Area edge: enables cross-WorkId lookup by code area. */ + const val REL_ABOUT = "about" + + /** context-index `Kind` column values that become first-class lesson entities. */ + private val LESSON_KIND_LABELS = mapOf( + "decision" to "Decision", + "pitfall" to "Pitfall", + "pattern" to "Pattern", + ) + + private val WORK_ID_PATTERN = Regex("""- Work ID:\s*(\S+)""") + private val CANVAS_TITLE_PATTERN = Regex("""#\s*REASONS Canvas:\s*([^-]+)\s*-\s*(.+)""") + } +} diff --git a/src/main/kotlin/com/embabel/guide/spdd/SpddProjectionConfiguration.kt b/src/main/kotlin/com/embabel/guide/spdd/SpddProjectionConfiguration.kt new file mode 100644 index 0000000..12e7c7f --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/spdd/SpddProjectionConfiguration.kt @@ -0,0 +1,50 @@ +package com.embabel.guide.spdd + +import com.embabel.agent.api.tool.ToolObject +import com.embabel.agent.mcpserver.McpToolExport +import com.embabel.agent.rag.graph.DrivineNamedEntityDataRepository +import com.embabel.agent.rag.graph.GraphRagServiceProperties +import com.embabel.agent.rag.service.NamedEntityDataRepository +import com.embabel.common.ai.model.EmbeddingService +import com.fasterxml.jackson.databind.ObjectMapper +import org.drivine.manager.GraphObjectManager +import org.drivine.manager.PersistenceManager +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +@ConditionalOnProperty(prefix = "guide.spdd-projection", name = ["enabled"], havingValue = "true") +class SpddProjectionConfiguration { + + @Bean + fun spddNamedEntityDataRepository( + @Qualifier("neo") persistenceManager: PersistenceManager, + graphRagProperties: GraphRagServiceProperties, + embeddingService: EmbeddingService, + @Qualifier("neoGraphObjectManager") graphObjectManager: GraphObjectManager, + objectMapper: ObjectMapper, + ): NamedEntityDataRepository = + DrivineNamedEntityDataRepository( + persistenceManager, + graphRagProperties, + SpddEntityDictionary.create(), + embeddingService, + graphObjectManager, + objectMapper, + ) + + /** + * Expose leg-3 DICE retrieve as MCP tools (`spdd_workSubgraph`, `spdd_projectionStats`, `spdd_findByLabel`). + */ + @Bean + fun spddDomainMcpTools( + projectionService: SpddMarkdownProjectionService, + objectMapper: ObjectMapper, + ): McpToolExport { + val tools = SpddDomainTools(projectionService, objectMapper) + // Prefer withPrefix only — withNamingStrategy replaces (does not compose) the prefix strategy. + return McpToolExport.fromToolObject(ToolObject(tools).withPrefix("spdd_")) + } +} diff --git a/src/main/kotlin/com/embabel/guide/spdd/SpddProjectionController.kt b/src/main/kotlin/com/embabel/guide/spdd/SpddProjectionController.kt new file mode 100644 index 0000000..e954616 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/spdd/SpddProjectionController.kt @@ -0,0 +1,83 @@ +package com.embabel.guide.spdd + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +/** + * Operator API for the DICE persist/retrieve contract (SPIKE-001 leg 3). + * + * Write: [load] — structured markdown → `__Entity__` (merge-by-id). + * Read: [stats], [workSubgraph] — domain retrieval by Work ID join key. + * MCP: [SpddDomainTools] exported as `spdd_*` when projection is enabled. + */ +@RestController +@RequestMapping("/api/v1/data/spdd-projection") +@ConditionalOnProperty(prefix = "guide.spdd-projection", name = ["enabled"], havingValue = "true") +class SpddProjectionController( + private val projectionService: SpddMarkdownProjectionService, +) { + + @PostMapping("/load") + fun load(@RequestBody(required = false) body: LoadRequest?): ResponseEntity = + ResponseEntity.ok(projectionService.load(body?.rootPath)) + + @GetMapping("/stats") + fun stats(): ResponseEntity = + ResponseEntity.ok( + StatsResponse( + workIdCount = projectionService.entityCountByLabel("WorkId"), + canvasCount = projectionService.entityCountByLabel("Canvas"), + areaCount = projectionService.entityCountByLabel("Area"), + decisionCount = projectionService.entityCountByLabel("Decision"), + pitfallCount = projectionService.entityCountByLabel("Pitfall"), + patternCount = projectionService.entityCountByLabel("Pattern"), + entityLabel = com.embabel.agent.rag.model.NamedEntityData.ENTITY_LABEL, + ), + ) + + @GetMapping("/work/{workId}") + fun workSubgraph(@PathVariable workId: String): ResponseEntity { + val subgraph = projectionService.subgraphForWorkId(workId.trim()) + return if (subgraph.found) ResponseEntity.ok(subgraph) else ResponseEntity.notFound().build() + } + + /** Area names contain slashes/spaces, so the area arrives as a query parameter. */ + @GetMapping("/area") + fun areaLessons(@RequestParam name: String): ResponseEntity { + val lessons = projectionService.lessonsForArea(name) + return if (lessons.found) ResponseEntity.ok(lessons) else ResponseEntity.notFound().build() + } + + /** Validation failures (bad rootPath, blank workId, unknown label) → 400, not 500. */ + @ExceptionHandler(IllegalArgumentException::class) + fun badRequest(e: IllegalArgumentException): ResponseEntity = + ResponseEntity.badRequest().body(ErrorResponse(e.message ?: "Invalid request")) + + /** Feature disabled or otherwise misconfigured → 409. */ + @ExceptionHandler(IllegalStateException::class) + fun conflict(e: IllegalStateException): ResponseEntity = + ResponseEntity.status(HttpStatus.CONFLICT).body(ErrorResponse(e.message ?: "Invalid state")) + + data class LoadRequest(val rootPath: String? = null) + + data class StatsResponse( + val workIdCount: Int, + val canvasCount: Int, + val areaCount: Int, + val decisionCount: Int = 0, + val pitfallCount: Int = 0, + val patternCount: Int = 0, + val entityLabel: String, + ) + + data class ErrorResponse(val error: String) +} diff --git a/src/main/kotlin/com/embabel/guide/spdd/SpddProjectionResult.kt b/src/main/kotlin/com/embabel/guide/spdd/SpddProjectionResult.kt new file mode 100644 index 0000000..903adb7 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/spdd/SpddProjectionResult.kt @@ -0,0 +1,54 @@ +package com.embabel.guide.spdd + +data class SpddProjectionResult( + val rootPath: String, + val workIds: Int, + val canvases: Int, + val areas: Int, + val operations: Int, + val decisions: Int, + val pitfalls: Int, + val patterns: Int = 0, + val relationships: Int, + /** Source files that failed to parse/persist and were skipped (load continues past them). */ + val skippedFiles: Int = 0, +) + +/** Read-side summary for domain retrieval (leg 3). */ +data class SpddEntitySummary( + val id: String, + val name: String, + val description: String, + val labels: List, + val uri: String?, +) + +/** + * WorkId-centered subgraph returned by the projection read API. + * Neighbors are included via typed edges (`canvas`, `area`, `decision`, `pitfall`, `pattern`), + * not embedding similarity. + */ +data class SpddWorkIdSubgraph( + val workId: String, + val found: Boolean, + val work: SpddEntitySummary? = null, + val canvases: List = emptyList(), + val areas: List = emptyList(), + val decisions: List = emptyList(), + val pitfalls: List = emptyList(), + val patterns: List = emptyList(), +) + +/** + * Area-centered cross-run lessons: what any prior Work ID recorded against a code area. + * Lessons arrive via incoming `about` edges; Work IDs via incoming `area` edges. + */ +data class SpddAreaLessons( + val area: String, + val found: Boolean, + val areaEntity: SpddEntitySummary? = null, + val workIds: List = emptyList(), + val decisions: List = emptyList(), + val pitfalls: List = emptyList(), + val patterns: List = emptyList(), +) diff --git a/src/main/kotlin/com/embabel/guide/spdd/domain/SpddDomain.kt b/src/main/kotlin/com/embabel/guide/spdd/domain/SpddDomain.kt new file mode 100644 index 0000000..82d1bcd --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/spdd/domain/SpddDomain.kt @@ -0,0 +1,94 @@ +package com.embabel.guide.spdd.domain + +import com.embabel.agent.core.Semantics +import com.embabel.agent.core.With +import com.embabel.agent.rag.model.NamedEntity +import com.fasterxml.jackson.annotation.JsonClassDescription + +/** + * SDLC-SPDD domain types for Guide leg-3 projection. + * + * Embabel conventions: + * - Implement [NamedEntity] (`id` / `name` / `description`) + * - Neo4j label = class simple name (+ `__Entity__` from the repository layer) + * - Graph relationship names match property names (`canvas`, `area`, …) + * - [Semantics] documents natural-language predicates for the same edges + * + * Persist path uses [com.embabel.agent.rag.model.SimpleNamedEntityData] with + * `linkedDomainType` resolved from [com.embabel.guide.spdd.SpddEntityDictionary] + * so merge-by-id projection stays idempotent while the schema is first-class JVM types. + */ +@JsonClassDescription("A unit of SPDD work (FEAT-, SPIKE-, BUG-, REF-, CHORE-)") +data class WorkId( + override val id: String, + override val name: String, + override val description: String, + val workType: String = "", + val status: String = "", + @field:Semantics([With(key = "predicate", value = "has canvas")]) + val canvas: Canvas? = null, + @field:Semantics([With(key = "predicate", value = "in area")]) + val area: Area? = null, +) : NamedEntity + +@JsonClassDescription("REASONS canvas for a Work ID") +data class Canvas( + override val id: String, + override val name: String, + override val description: String, + val path: String = "", + val readiness: String = "", +) : NamedEntity + +@JsonClassDescription("Canvas operation (T01, T02, …)") +data class Operation( + override val id: String, + override val name: String, + override val description: String, + val status: String = "", + @field:Semantics([With(key = "predicate", value = "in canvas")]) + val canvas: Canvas? = null, +) : NamedEntity + +@JsonClassDescription("Code area bucket or package") +data class Area( + override val id: String, + override val name: String, + override val description: String, +) : NamedEntity + +@JsonClassDescription("Recorded architecture decision") +data class Decision( + override val id: String, + override val name: String, + override val description: String, + val sourcePath: String = "", + @field:Semantics([With(key = "predicate", value = "about")]) + val area: Area? = null, + @field:Semantics([With(key = "predicate", value = "recorded for")]) + val workId: WorkId? = null, +) : NamedEntity + +@JsonClassDescription("Known pitfall") +data class Pitfall( + override val id: String, + override val name: String, + override val description: String, + val sourcePath: String = "", + @field:Semantics([With(key = "predicate", value = "about")]) + val area: Area? = null, + @field:Semantics([With(key = "predicate", value = "recorded for")]) + val workId: WorkId? = null, +) : NamedEntity + +@JsonClassDescription("Reusable pattern") +data class Pattern( + override val id: String, + override val name: String, + override val description: String, + val sourcePath: String = "", + @field:Semantics([With(key = "predicate", value = "about")]) + val area: Area? = null, + @field:Semantics([With(key = "predicate", value = "recorded for")]) + val workId: WorkId? = null, +) : NamedEntity diff --git a/src/main/kotlin/com/embabel/hub/PersonaSeedingService.kt b/src/main/kotlin/com/embabel/hub/PersonaSeedingService.kt index 5df06db..2f020c7 100644 --- a/src/main/kotlin/com/embabel/hub/PersonaSeedingService.kt +++ b/src/main/kotlin/com/embabel/hub/PersonaSeedingService.kt @@ -44,6 +44,28 @@ class PersonaSeedingService( @EventListener(ApplicationReadyEvent::class) @Transactional fun seedSystemPersonas() { + try { + // Fail fast with an actionable message if KSP output wasn't compiled onto the classpath + // (common when concurrent spring-boot:run races wipe target/classes). + Class.forName("com.embabel.guide.domain.PersonaViewQueryDsl") + } catch (e: ClassNotFoundException) { + logger.error( + "Persona seeding skipped: PersonaViewQueryDsl missing from classpath. " + + "Run `cd codegen-gradle && ./gradlew kspKotlin` then `./mvnw -DskipTests compile` " + + "(avoid concurrent spring-boot:run builds). Guide will stay up for RAG/MCP.", + e, + ) + return + } + try { + doSeedSystemPersonas() + } catch (e: Exception) { + // Hub persona seed must not take down Guide — RAG/MCP and SPDD projection are independent. + logger.error("Persona seeding failed; continuing startup", e) + } + } + + private fun doSeedSystemPersonas() { val jesseUserData = getOrCreateJesseUser() val existing = personaRepository.findByOwner(SYSTEM_OWNER_ID) .map { it.persona.name } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bf5e52e..3aa7d69 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -82,10 +82,21 @@ guide: # archives to retrieve articles aged out of the main feed. fetch-routes: [] - # Local directories to ingest (full tree). Paths are absolute or relative to working directory. - # Example: clone repos then list them here for full-repo RAG. + # Local directories to ingest. Paths are absolute or relative to working directory. + # With git-ingestion.enabled, git work trees ingest only changed files since the last run (see state-file). + # Subdir entries (e.g. spdd/canvas) are supported: Guide walks up to the .git root and scopes the diff. directories: [] + git-ingestion: + enabled: false + state-file: scripts/user-config/ingestion-git-revisions.json + + # Leg 3: SPDD DICE-style entity projection (coexists with RAG chunk ingest above). + # POST /api/v1/data/spdd-projection/load when enabled. + spdd-projection: + enabled: false + default-root-path: . + tool-groups: email: @@ -113,6 +124,15 @@ spring: output: ansi: enabled: always + # Spring Boot Neo4j auto-config + Neo4jReactiveHealthIndicator use this driver. + # Without authentication they connect with AuthTokens.none() → scheme 'none' + # failures against embabel-neo4j (auth enabled). Drivine/RAG use database.dataSources + # / embabel.agent.rag.graph below; both must agree on credentials. + neo4j: + uri: ${NEO4J_URI:bolt://localhost:7687} + authentication: + username: ${NEO4J_USERNAME:neo4j} + password: ${NEO4J_PASSWORD:brahmsian} profiles: active: neo4j # Change to 'neo4j', 'falkordb', 'memgraph', or 'default' for TestContainers config: @@ -186,7 +206,8 @@ database: userName: ${NEO4J_USERNAME:neo4j} password: ${NEO4J_PASSWORD:brahmsian} host: ${NEO4J_HOST:localhost} - port: 7687 + # Must match Bolt port on the host when using external Neo4j (default 7687 for guide compose) + port: ${NEO4J_PORT:7687} protocol: ${NEO4J_PROTOCOL:bolt} databaseName: neo4j diff --git a/src/test/java/com/embabel/guide/rag/GitIncrementalDirectorySupportTest.java b/src/test/java/com/embabel/guide/rag/GitIncrementalDirectorySupportTest.java new file mode 100644 index 0000000..215cd45 --- /dev/null +++ b/src/test/java/com/embabel/guide/rag/GitIncrementalDirectorySupportTest.java @@ -0,0 +1,92 @@ +package com.embabel.guide.rag; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class GitIncrementalDirectorySupportTest { + + @TempDir + Path repo; + + @Test + void headAndChangedFilesBetweenCommits() throws Exception { + Assumptions.assumeTrue(gitAvailable(), "git must be on PATH"); + + run(repo, "git", "init"); + run(repo, "git", "config", "user.email", "test@test.local"); + run(repo, "git", "config", "user.name", "Test"); + Files.writeString(repo.resolve("a.txt"), "v1", StandardCharsets.UTF_8); + run(repo, "git", "add", "a.txt"); + run(repo, "git", "commit", "-m", "first"); + String first = GitIncrementalDirectorySupport.headCommit(repo).orElseThrow(); + + Files.writeString(repo.resolve("b.txt"), "new", StandardCharsets.UTF_8); + run(repo, "git", "add", "b.txt"); + run(repo, "git", "commit", "-m", "second"); + String second = GitIncrementalDirectorySupport.headCommit(repo).orElseThrow(); + + assertThat(first).isNotEqualTo(second); + assertThat(GitIncrementalDirectorySupport.isGitWorkTree(repo)).isTrue(); + + List changed = GitIncrementalDirectorySupport.changedPathsBetween(repo, first, second); + assertThat(changed).containsExactly("b.txt"); + } + + @Test + void findGitWorkTreeRootWalksUpFromSubdirectory() throws Exception { + Assumptions.assumeTrue(gitAvailable(), "git must be on PATH"); + + run(repo, "git", "init"); + Path nested = repo.resolve("spdd/canvas"); + Files.createDirectories(nested); + Files.writeString(nested.resolve("readme.md"), "x", StandardCharsets.UTF_8); + run(repo, "git", "config", "user.email", "test@test.local"); + run(repo, "git", "config", "user.name", "Test"); + run(repo, "git", "add", "."); + run(repo, "git", "commit", "-m", "init"); + + assertThat(GitIncrementalDirectorySupport.isGitWorkTree(nested)).isFalse(); + assertThat(GitIncrementalDirectorySupport.findGitWorkTreeRoot(nested)) + .contains(repo.toAbsolutePath().normalize()); + } + + @Test + void filterPathsUnderDirectoryScopesRepoDiffToConfiguredSubdir() { + Path root = Path.of("/repo").toAbsolutePath().normalize(); + Path canvas = root.resolve("spdd/canvas"); + List changed = List.of( + "spdd/canvas/FEAT-001.md", + "spdd/analysis/notes.md", + "README.md", + "spdd/canvas/extra/nested.md" + ); + assertThat(GitIncrementalDirectorySupport.filterPathsUnderDirectory(root, canvas, changed)) + .containsExactly("spdd/canvas/FEAT-001.md", "spdd/canvas/extra/nested.md"); + assertThat(GitIncrementalDirectorySupport.filterPathsUnderDirectory(root, root, changed)) + .containsExactlyElementsOf(changed); + } + + private static boolean gitAvailable() throws Exception { + Process p = new ProcessBuilder("git", "--version").start(); + return p.waitFor(5, TimeUnit.SECONDS) && p.exitValue() == 0; + } + + private static void run(Path cwd, String... cmd) throws Exception { + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(cwd.toFile()); + pb.redirectErrorStream(true); + Process p = pb.start(); + String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertThat(p.waitFor(30, TimeUnit.SECONDS)).as(out).isTrue(); + assertThat(p.exitValue()).as(out).isZero(); + } +} diff --git a/src/test/kotlin/com/embabel/guide/rag/DataManagerControllerWebMvcTest.kt b/src/test/kotlin/com/embabel/guide/rag/DataManagerControllerWebMvcTest.kt new file mode 100644 index 0000000..325b5fa --- /dev/null +++ b/src/test/kotlin/com/embabel/guide/rag/DataManagerControllerWebMvcTest.kt @@ -0,0 +1,44 @@ +package com.embabel.guide.rag + +import com.embabel.guide.stats.GuideStatsService +import com.embabel.hub.JwtTokenService +import org.junit.jupiter.api.Test +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.Duration + +@WebMvcTest(controllers = [DataManagerController::class]) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class DataManagerControllerWebMvcTest { + + @Autowired + lateinit var mockMvc: MockMvc + + @MockitoBean + lateinit var dataManager: DataManager + + @MockitoBean + lateinit var jwtTokenService: JwtTokenService + + @MockitoBean + lateinit var guideStatsService: GuideStatsService + + @Test + fun `load-references returns 200`() { + val result = IngestionResult( + emptyList(), emptyList(), emptyList(), emptyList(), emptyList(), Duration.ZERO, + ) + `when`(dataManager.loadReferences()).thenReturn(result) + + mockMvc.perform(post("/api/v1/data/load-references")) + .andExpect(status().isOk) + } +} diff --git a/src/test/kotlin/com/embabel/guide/rag/IngestionRunnerTest.kt b/src/test/kotlin/com/embabel/guide/rag/IngestionRunnerTest.kt index d5c25e5..07c0809 100644 --- a/src/test/kotlin/com/embabel/guide/rag/IngestionRunnerTest.kt +++ b/src/test/kotlin/com/embabel/guide/rag/IngestionRunnerTest.kt @@ -4,6 +4,8 @@ import com.embabel.agent.rag.graph.model.ContentElementRepositoryInfoImpl import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import org.mockito.Mockito.* +import org.springframework.ai.embedding.EmbeddingModel +import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.DefaultApplicationArguments import java.io.ByteArrayOutputStream import java.io.PrintStream @@ -13,11 +15,18 @@ class IngestionRunnerTest { private val dataManager = mock(DataManager::class.java) + @Suppress("UNCHECKED_CAST") + private val embeddingModelProvider = mock(ObjectProvider::class.java) as ObjectProvider + + init { + `when`(embeddingModelProvider.getIfAvailable()).thenReturn(null) + } + private fun failure(source: String, reason: String = "test error") = IngestionFailure(source, reason) private fun createRunner(port: Int = 1337): IngestionRunner { - val runner = IngestionRunner(dataManager) + val runner = IngestionRunner(dataManager, embeddingModelProvider) val field = IngestionRunner::class.java.getDeclaredField("serverPort") field.isAccessible = true field.setInt(runner, port) diff --git a/src/test/kotlin/com/embabel/guide/rag/RagMaintenanceControllerWebMvcTest.kt b/src/test/kotlin/com/embabel/guide/rag/RagMaintenanceControllerWebMvcTest.kt new file mode 100644 index 0000000..79c77ee --- /dev/null +++ b/src/test/kotlin/com/embabel/guide/rag/RagMaintenanceControllerWebMvcTest.kt @@ -0,0 +1,74 @@ +package com.embabel.guide.rag + +import com.embabel.hub.JwtTokenService +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Test +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(controllers = [RagMaintenanceController::class]) +@Import(RagMaintenanceExceptionHandler::class) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class RagMaintenanceControllerWebMvcTest { + + @Autowired + lateinit var mockMvc: MockMvc + + @Autowired + lateinit var objectMapper: ObjectMapper + + @MockitoBean + lateinit var maintenanceService: RagContentMaintenanceService + + @MockitoBean + lateinit var jwtTokenService: JwtTokenService + + @Test + fun `purge-preview returns ok body`() { + `when`( + maintenanceService.previewPurge(null, "~/repo", 10), + ).thenReturn( + RagContentMaintenanceService.PurgePreviewResult("file:/abs/repo/", 3L, listOf("file:/abs/repo/a.md")), + ) + + val body = objectMapper.writeValueAsString( + mapOf("directory" to "~/repo", "sampleLimit" to 10), + ) + + mockMvc.perform( + post("/api/v1/data/content-elements/purge-preview") + .contentType(MediaType.APPLICATION_JSON) + .content(body), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.appliedUriPrefix").value("file:/abs/repo/")) + .andExpect(jsonPath("$.matchCount").value(3)) + .andExpect(jsonPath("$.sampleUris[0]").value("file:/abs/repo/a.md")) + } + + @Test + fun `purge-preview bad request from maintenance service`() { + `when`(maintenanceService.previewPurge("", "", 10)) + .thenThrow(IllegalArgumentException("Provide uriPrefix or directory")) + + val body = objectMapper.writeValueAsString(mapOf("uriPrefix" to "", "directory" to "")) + + mockMvc.perform( + post("/api/v1/data/content-elements/purge-preview") + .contentType(MediaType.APPLICATION_JSON) + .content(body), + ) + .andExpect(status().isBadRequest) + } +} diff --git a/src/test/kotlin/com/embabel/guide/spdd/SpddDomainToolsTest.kt b/src/test/kotlin/com/embabel/guide/spdd/SpddDomainToolsTest.kt new file mode 100644 index 0000000..b0b462f --- /dev/null +++ b/src/test/kotlin/com/embabel/guide/spdd/SpddDomainToolsTest.kt @@ -0,0 +1,148 @@ +package com.embabel.guide.spdd + +import com.embabel.agent.rag.service.NamedEntityDataRepository +import com.embabel.agent.rag.service.support.InMemoryNamedEntityDataRepository +import com.embabel.common.ai.model.EmbeddingService +import com.embabel.guide.ContentConfig +import com.embabel.guide.GuideProperties +import com.embabel.guide.VersionedContentConfig +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.mockito.Mockito +import java.nio.file.Files +import java.nio.file.Path + +/** + * MCP tool contract: every tool returns JSON, and validation failures surface + * as `{"error": …}` payloads rather than exceptions escaping to the protocol layer. + */ +class SpddDomainToolsTest { + + @TempDir + lateinit var tempDir: Path + + private val objectMapper = ObjectMapper() + private lateinit var tools: SpddDomainTools + + @BeforeEach + fun setUp() { + val root = buildProject(tempDir.resolve("project")) + val service = SpddMarkdownProjectionService(guideProperties(root.toString()), inMemoryRepository()) + service.load() + tools = SpddDomainTools(service, objectMapper) + } + + @Test + fun `workSubgraph returns typed neighbors as json`() { + val json = objectMapper.readTree(tools.workSubgraph("SPIKE-FIX-001-retrieval-fixture")) + assertTrue(json["found"].asBoolean()) + assertEquals("SPIKE-FIX-001-retrieval-fixture:canvas", json["canvases"][0]["id"].asText()) + assertEquals("retry storms", json["pitfalls"][0]["name"].asText()) + } + + @Test + fun `workSubgraph reports not found without error`() { + val json = objectMapper.readTree(tools.workSubgraph("FEAT-999-unknown")) + assertFalse(json["found"].asBoolean()) + assertFalse(json.has("error")) + } + + @Test + fun `workSubgraph surfaces blank work id as error payload`() { + val json = objectMapper.readTree(tools.workSubgraph(" ")) + assertTrue(json.has("error")) + } + + @Test + fun `projectionStats counts all schema labels`() { + val json = objectMapper.readTree(tools.projectionStats()) + assertEquals(1, json["workIdCount"].asInt()) + assertEquals(1, json["pitfallCount"].asInt()) + assertEquals("__Entity__", json["entityLabel"].asText()) + } + + @Test + fun `findByLabel lists entities for a schema label`() { + val json = objectMapper.readTree(tools.findByLabel("WorkId")) + assertEquals(1, json.size()) + assertEquals("SPIKE-FIX-001-retrieval-fixture", json[0]["id"].asText()) + } + + @Test + fun `findByLabel surfaces unknown label as error payload`() { + val json = objectMapper.readTree(tools.findByLabel("DROP TABLE")) + assertTrue(json.has("error")) + assertTrue(json["error"].asText().contains("Known labels")) + } + + @Test + fun `areaLessons returns cross-run lessons as json`() { + val json = objectMapper.readTree(tools.areaLessons("src/billing")) + assertTrue(json["found"].asBoolean()) + assertEquals("retry storms", json["pitfalls"][0]["name"].asText()) + } + + @Test + fun `areaLessons surfaces blank area as error payload`() { + val json = objectMapper.readTree(tools.areaLessons("")) + assertTrue(json.has("error")) + } + + private fun buildProject(root: Path): Path { + Files.createDirectories(root.resolve("spdd/canvas")) + Files.createDirectories(root.resolve("agent-context/memory")) + Files.writeString( + root.resolve("spdd/canvas/SPIKE-FIX-001-retrieval-fixture.md"), + """ + # REASONS Canvas: SPIKE-FIX-001-retrieval-fixture - Retrieval experiment fixture + + ## Metadata + + - Work ID: SPIKE-FIX-001-retrieval-fixture + """.trimIndent(), + ) + Files.writeString( + root.resolve("agent-context/memory/context-index.md"), + """ + # Context Index + + | Area | Kind | Work ID | Phase | Timestamp | Source | Entry | + |------|------|---------|-------|-----------|--------|-------| + | src/billing | pitfall | SPIKE-FIX-001-retrieval-fixture | code | 2026-07-05T13:00:00Z | pitfalls.md | retry storms | + """.trimIndent(), + ) + return root + } + + private fun guideProperties(defaultRootPath: String) = + GuideProperties( + reloadContentOnStartup = false, + defaultPersona = "adaptive", + projectsPath = ".", + chunkerConfig = null, + referencesFile = "references.yml", + content = ContentConfig( + versioned = VersionedContentConfig(baseUrl = "https://example.invalid/", versions = emptyList()), + supplementary = emptyList(), + ), + toolPrefix = "", + directories = emptyList(), + toolGroups = emptySet(), + spddProjection = GuideProperties.SpddProjection(enabled = true, defaultRootPath = defaultRootPath), + ) + + private fun inMemoryRepository(): NamedEntityDataRepository { + val embeddingService = Mockito.mock(EmbeddingService::class.java) + Mockito.`when`(embeddingService.embed(Mockito.anyString())).thenReturn(floatArrayOf(0.1f, 0.2f, 0.3f)) + return InMemoryNamedEntityDataRepository( + SpddEntityDictionary.create(), + embeddingService, + ObjectMapper(), + ) + } +} diff --git a/src/test/kotlin/com/embabel/guide/spdd/SpddMarkdownProjectionServiceTest.kt b/src/test/kotlin/com/embabel/guide/spdd/SpddMarkdownProjectionServiceTest.kt new file mode 100644 index 0000000..4ca5086 --- /dev/null +++ b/src/test/kotlin/com/embabel/guide/spdd/SpddMarkdownProjectionServiceTest.kt @@ -0,0 +1,324 @@ +package com.embabel.guide.spdd + +import com.embabel.agent.rag.service.NamedEntityDataRepository +import com.embabel.agent.rag.service.support.InMemoryNamedEntityDataRepository +import com.embabel.common.ai.model.EmbeddingService +import com.embabel.guide.ContentConfig +import com.embabel.guide.GuideProperties +import com.embabel.guide.VersionedContentConfig +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.io.TempDir +import org.mockito.Mockito +import java.nio.file.Files +import java.nio.file.Path + +class SpddMarkdownProjectionServiceTest { + + @TempDir + lateinit var tempDir: Path + + // ---------------------------------------------------------------- persist + + @Test + fun `load projects work id canvas and area from markdown fixture`() { + val fixtureRoot = Path.of("src/test/resources/spdd-fixture").toAbsolutePath() + val repo = inMemoryRepository() + val service = service(repo, fixtureRoot.toString()) + + val result = service.load() + + assertEquals(fixtureRoot.normalize().toString(), result.rootPath) + assertTrue(result.workIds >= 1) + assertTrue(result.canvases >= 1) + assertTrue(result.areas >= 1) + assertEquals(0, result.skippedFiles) + assertTrue(service.entityCountByLabel("WorkId") >= 1) + assertTrue(repo.findByLabel("Area").any { it.name == "src/billing" }) + } + + @Test + fun `load is idempotent - reloading does not duplicate entities`() { + val root = copyFixtureTo(tempDir.resolve("project")) + val repo = inMemoryRepository() + val service = service(repo, root.toString()) + + val first = service.load() + val countsAfterFirst = repo.findByLabel("WorkId").size + repo.findByLabel("Canvas").size + + repo.findByLabel("Area").size + repo.findByLabel("Pitfall").size + val second = service.load() + val countsAfterSecond = repo.findByLabel("WorkId").size + repo.findByLabel("Canvas").size + + repo.findByLabel("Area").size + repo.findByLabel("Pitfall").size + + assertEquals(first.workIds, second.workIds) + assertEquals(countsAfterFirst, countsAfterSecond, "merge-by-id must not duplicate on reload") + } + + @Test + fun `load ignores canvas without work id and projects the rest`() { + val root = copyFixtureTo(tempDir.resolve("project")) + Files.writeString(root.resolve("spdd/canvas/no-work-id.md"), "# Not a canvas at all\n") + val service = service(inMemoryRepository(), root.toString()) + + val result = service.load() + + assertEquals(1, result.workIds, "valid canvas still projected") + assertEquals(1, result.canvases) + } + + @Test + fun `load projects decision pitfall and pattern lessons with about edges`() { + val root = buildProject( + tempDir.resolve("lessons"), + canvas = CANVAS, + contextIndex = """ + # Context Index + + | Area | Kind | Work ID | Phase | Timestamp | Source | Entry | + |------|------|---------|-------|-----------|--------|-------| + | src/billing | decision | SPIKE-FIX-001-retrieval-fixture | code | 2026-07-05T13:00:00Z | adr.md | use idempotency keys | + | src/billing | pitfall | SPIKE-FIX-001-retrieval-fixture | code | 2026-07-05T13:00:00Z | pitfalls.md | retry storms | + | src/billing | pattern | SPIKE-FIX-001-retrieval-fixture | code | 2026-07-05T13:00:00Z | patterns.md | outbox pattern | + """.trimIndent(), + ) + val repo = inMemoryRepository() + val service = service(repo, root.toString()) + + val result = service.load() + + assertEquals(1, result.decisions) + assertEquals(1, result.pitfalls) + assertEquals(1, result.patterns) + + val subgraph = service.subgraphForWorkId("SPIKE-FIX-001-retrieval-fixture") + assertTrue(subgraph.found) + assertEquals(listOf("use idempotency keys"), subgraph.decisions.map { it.name }) + assertEquals(listOf("retry storms"), subgraph.pitfalls.map { it.name }) + assertEquals(listOf("outbox pattern"), subgraph.patterns.map { it.name }) + } + + // ------------------------------------------------------- root path guard + + @Test + fun `load rejects root override outside allowed roots`() { + val root = copyFixtureTo(tempDir.resolve("project")) + val outside = Files.createDirectory(tempDir.resolve("outside")) + val service = service(inMemoryRepository(), root.toString()) + + val e = assertThrows { service.load(outside.toString()) } + assertTrue(e.message!!.contains("not under an allowed root")) + } + + @Test + fun `load accepts override under an explicitly allowed root`() { + val defaultRoot = copyFixtureTo(tempDir.resolve("default")) + val allowedParent = tempDir.resolve("allowed") + val otherProject = copyFixtureTo(allowedParent.resolve("other")) + val service = service( + inMemoryRepository(), + defaultRoot.toString(), + allowedRoots = listOf(allowedParent.toString()), + ) + + val result = service.load(otherProject.toString()) + + assertEquals(otherProject.normalize().toString(), result.rootPath) + } + + @Test + fun `load accepts override equal to the default root`() { + // Regression: Path is Iterable, so `allowedRoots + defaultRoot` once appended + // the default root's COMPONENTS, rejecting even an override identical to the default. + val root = copyFixtureTo(tempDir.resolve("project")) + val service = service(inMemoryRepository(), root.toString()) + + val result = service.load(root.toString()) + + assertEquals(root.normalize().toString(), result.rootPath) + } + + @Test + fun `load treats blank override as default root`() { + val root = copyFixtureTo(tempDir.resolve("project")) + val service = service(inMemoryRepository(), root.toString()) + + val result = service.load(" ") + + assertEquals(root.normalize().toString(), result.rootPath) + } + + @Test + fun `load rejects missing root directory`() { + val service = service(inMemoryRepository(), tempDir.resolve("does-not-exist").toString()) + assertThrows { service.load() } + } + + // --------------------------------------------------------------- retrieve + + @Test + fun `subgraph walk returns canvas and area neighbors`() { + val root = copyFixtureTo(tempDir.resolve("project")) + val service = service(inMemoryRepository(), root.toString()) + service.load() + + val subgraph = service.subgraphForWorkId("SPIKE-FIX-001-retrieval-fixture") + + assertTrue(subgraph.found) + assertTrue(subgraph.canvases.isNotEmpty()) + assertTrue(subgraph.areas.any { it.name == "src/billing" }) + assertTrue(subgraph.pitfalls.any { it.name == "idempotency key" }) + } + + @Test + fun `subgraph for unknown work id reports not found`() { + val service = service(inMemoryRepository(), copyFixtureTo(tempDir.resolve("p")).toString()) + service.load() + + val subgraph = service.subgraphForWorkId("FEAT-999-nope") + + assertFalse(subgraph.found) + } + + @Test + fun `subgraph rejects blank work id`() { + val service = service(inMemoryRepository(), copyFixtureTo(tempDir.resolve("p")).toString()) + assertThrows { service.subgraphForWorkId(" ") } + } + + @Test + fun `lessonsForArea returns cross-run lessons and touching work ids`() { + val root = buildProject( + tempDir.resolve("cross"), + canvas = CANVAS, + contextIndex = """ + # Context Index + + | Area | Kind | Work ID | Phase | Timestamp | Source | Entry | + |------|------|---------|-------|-----------|--------|-------| + | src/billing | pitfall | SPIKE-FIX-001-retrieval-fixture | code | 2026-07-05T13:00:00Z | pitfalls.md | retry storms | + | src/billing | decision | FEAT-002-other-work | code | 2026-07-06T13:00:00Z | adr.md | use idempotency keys | + """.trimIndent(), + ) + val service = service(inMemoryRepository(), root.toString()) + service.load() + + val lessons = service.lessonsForArea("src/billing") + + assertTrue(lessons.found) + // Lessons from BOTH work ids arrive via the area, which is the cross-run guarantee. + assertEquals(listOf("retry storms"), lessons.pitfalls.map { it.name }) + assertEquals(listOf("use idempotency keys"), lessons.decisions.map { it.name }) + assertTrue(lessons.workIds.any { it.id == "SPIKE-FIX-001-retrieval-fixture" }) + } + + @Test + fun `lessonsForArea for unknown area reports not found`() { + val service = service(inMemoryRepository(), copyFixtureTo(tempDir.resolve("p")).toString()) + service.load() + + assertFalse(service.lessonsForArea("does/not/exist").found) + } + + @Test + fun `lessonsForArea rejects blank area`() { + val service = service(inMemoryRepository(), copyFixtureTo(tempDir.resolve("p")).toString()) + assertThrows { service.lessonsForArea("") } + } + + @Test + fun `listByLabel rejects labels outside the schema`() { + val service = service(inMemoryRepository(), copyFixtureTo(tempDir.resolve("p")).toString()) + val e = assertThrows { service.listByLabel("ContentElement") } + assertTrue(e.message!!.contains("Known labels")) + } + + @Test + fun `listByLabel caps results`() { + val root = copyFixtureTo(tempDir.resolve("p")) + val service = service(inMemoryRepository(), root.toString()) + service.load() + + assertEquals(1, service.listByLabel("WorkId", maxResults = 1).size) + // Out-of-range requests are clamped, not rejected. + assertTrue(service.listByLabel("WorkId", maxResults = 0).isNotEmpty()) + assertTrue(service.listByLabel("WorkId", maxResults = 999999).size <= SpddMarkdownProjectionService.MAX_LIST_RESULTS) + } + + // ---------------------------------------------------------------- helpers + + private fun service( + repo: NamedEntityDataRepository, + defaultRootPath: String, + allowedRoots: List = emptyList(), + ): SpddMarkdownProjectionService = + SpddMarkdownProjectionService(guideProperties(defaultRootPath, allowedRoots), repo) + + private fun guideProperties(defaultRootPath: String, allowedRoots: List = emptyList()) = + GuideProperties( + reloadContentOnStartup = false, + defaultPersona = "adaptive", + projectsPath = ".", + chunkerConfig = null, + referencesFile = "references.yml", + content = ContentConfig( + versioned = VersionedContentConfig(baseUrl = "https://example.invalid/", versions = emptyList()), + supplementary = emptyList(), + ), + toolPrefix = "", + directories = emptyList(), + toolGroups = emptySet(), + spddProjection = GuideProperties.SpddProjection( + enabled = true, + defaultRootPath = defaultRootPath, + allowedRoots = allowedRoots, + ), + ) + + private fun copyFixtureTo(target: Path): Path { + val fixture = Path.of("src/test/resources/spdd-fixture") + Files.walk(fixture).forEach { source -> + val dest = target.resolve(fixture.relativize(source)) + if (Files.isDirectory(source)) { + Files.createDirectories(dest) + } else { + Files.createDirectories(dest.parent) + Files.copy(source, dest) + } + } + return target + } + + private fun buildProject(root: Path, canvas: String, contextIndex: String): Path { + Files.createDirectories(root.resolve("spdd/canvas")) + Files.createDirectories(root.resolve("agent-context/memory")) + Files.writeString(root.resolve("spdd/canvas/SPIKE-FIX-001-retrieval-fixture.md"), canvas) + Files.writeString(root.resolve("agent-context/memory/context-index.md"), contextIndex) + return root + } + + private fun inMemoryRepository(): NamedEntityDataRepository { + val embeddingService = Mockito.mock(EmbeddingService::class.java) + Mockito.`when`(embeddingService.embed(Mockito.anyString())).thenReturn(floatArrayOf(0.1f, 0.2f, 0.3f)) + return InMemoryNamedEntityDataRepository( + SpddEntityDictionary.create(), + embeddingService, + ObjectMapper(), + ) + } + + companion object { + private val CANVAS = """ + # REASONS Canvas: SPIKE-FIX-001-retrieval-fixture - Retrieval experiment fixture + + ## Metadata + + - Work ID: SPIKE-FIX-001-retrieval-fixture + - Work Type: Spike + - Status: Complete + """.trimIndent() + } +} diff --git a/src/test/kotlin/com/embabel/guide/spdd/SpddProjectionControllerTest.kt b/src/test/kotlin/com/embabel/guide/spdd/SpddProjectionControllerTest.kt new file mode 100644 index 0000000..e24f28c --- /dev/null +++ b/src/test/kotlin/com/embabel/guide/spdd/SpddProjectionControllerTest.kt @@ -0,0 +1,167 @@ +package com.embabel.guide.spdd + +import com.embabel.agent.rag.service.NamedEntityDataRepository +import com.embabel.agent.rag.service.support.InMemoryNamedEntityDataRepository +import com.embabel.common.ai.model.EmbeddingService +import com.embabel.guide.ContentConfig +import com.embabel.guide.GuideProperties +import com.embabel.guide.VersionedContentConfig +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.mockito.Mockito +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.nio.file.Files +import java.nio.file.Path + +/** + * Wiring test: real service + in-memory repository behind the controller, + * standalone MockMvc (no Spring context). Verifies status-code mapping added + * in the hardening pass (400 for validation, 404 for unknown ids). + */ +class SpddProjectionControllerTest { + + @TempDir + lateinit var tempDir: Path + + private lateinit var mockMvc: MockMvc + private lateinit var root: Path + + @BeforeEach + fun setUp() { + root = buildProject(tempDir.resolve("project")) + val service = SpddMarkdownProjectionService(guideProperties(root.toString()), inMemoryRepository()) + mockMvc = MockMvcBuilders.standaloneSetup(SpddProjectionController(service)).build() + } + + @Test + fun `load returns counts including lessons`() { + mockMvc.perform(post("/api/v1/data/spdd-projection/load").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.workIds").value(1)) + .andExpect(jsonPath("$.canvases").value(1)) + .andExpect(jsonPath("$.pitfalls").value(1)) + .andExpect(jsonPath("$.skippedFiles").value(0)) + } + + @Test + fun `load with disallowed root override returns 400 with error body`() { + val outside = Files.createDirectory(tempDir.resolve("outside")) + mockMvc.perform( + post("/api/v1/data/spdd-projection/load") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"rootPath": "$outside"}"""), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").exists()) + } + + @Test + fun `stats reports counts by label`() { + mockMvc.perform(post("/api/v1/data/spdd-projection/load").contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/api/v1/data/spdd-projection/stats")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.workIdCount").value(1)) + .andExpect(jsonPath("$.pitfallCount").value(1)) + .andExpect(jsonPath("$.entityLabel").value("__Entity__")) + } + + @Test + fun `work subgraph returns 200 with typed neighbors`() { + mockMvc.perform(post("/api/v1/data/spdd-projection/load").contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/api/v1/data/spdd-projection/work/SPIKE-FIX-001-retrieval-fixture")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.found").value(true)) + .andExpect(jsonPath("$.canvases[0].id").value("SPIKE-FIX-001-retrieval-fixture:canvas")) + .andExpect(jsonPath("$.pitfalls[0].name").value("retry storms")) + } + + @Test + fun `work subgraph returns 404 for unknown work id`() { + mockMvc.perform(get("/api/v1/data/spdd-projection/work/FEAT-999-unknown")) + .andExpect(status().isNotFound) + } + + @Test + fun `area lessons returns cross-run lessons`() { + mockMvc.perform(post("/api/v1/data/spdd-projection/load").contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/api/v1/data/spdd-projection/area").param("name", "src/billing")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.found").value(true)) + .andExpect(jsonPath("$.pitfalls[0].name").value("retry storms")) + .andExpect(jsonPath("$.workIds[0].id").value("SPIKE-FIX-001-retrieval-fixture")) + } + + @Test + fun `area lessons returns 404 for unknown area`() { + mockMvc.perform(get("/api/v1/data/spdd-projection/area").param("name", "no/such/area")) + .andExpect(status().isNotFound) + } + + @Test + fun `area lessons returns 400 for blank area`() { + mockMvc.perform(get("/api/v1/data/spdd-projection/area").param("name", " ")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").exists()) + } + + private fun buildProject(root: Path): Path { + Files.createDirectories(root.resolve("spdd/canvas")) + Files.createDirectories(root.resolve("agent-context/memory")) + Files.writeString( + root.resolve("spdd/canvas/SPIKE-FIX-001-retrieval-fixture.md"), + """ + # REASONS Canvas: SPIKE-FIX-001-retrieval-fixture - Retrieval experiment fixture + + ## Metadata + + - Work ID: SPIKE-FIX-001-retrieval-fixture + """.trimIndent(), + ) + Files.writeString( + root.resolve("agent-context/memory/context-index.md"), + """ + # Context Index + + | Area | Kind | Work ID | Phase | Timestamp | Source | Entry | + |------|------|---------|-------|-----------|--------|-------| + | src/billing | pitfall | SPIKE-FIX-001-retrieval-fixture | code | 2026-07-05T13:00:00Z | pitfalls.md | retry storms | + """.trimIndent(), + ) + return root + } + + private fun guideProperties(defaultRootPath: String) = + GuideProperties( + reloadContentOnStartup = false, + defaultPersona = "adaptive", + projectsPath = ".", + chunkerConfig = null, + referencesFile = "references.yml", + content = ContentConfig( + versioned = VersionedContentConfig(baseUrl = "https://example.invalid/", versions = emptyList()), + supplementary = emptyList(), + ), + toolPrefix = "", + directories = emptyList(), + toolGroups = emptySet(), + spddProjection = GuideProperties.SpddProjection(enabled = true, defaultRootPath = defaultRootPath), + ) + + private fun inMemoryRepository(): NamedEntityDataRepository { + val embeddingService = Mockito.mock(EmbeddingService::class.java) + Mockito.`when`(embeddingService.embed(Mockito.anyString())).thenReturn(floatArrayOf(0.1f, 0.2f, 0.3f)) + return InMemoryNamedEntityDataRepository( + SpddEntityDictionary.create(), + embeddingService, + ObjectMapper(), + ) + } +} diff --git a/src/test/resources/spdd-fixture/agent-context/memory/context-index.md b/src/test/resources/spdd-fixture/agent-context/memory/context-index.md new file mode 100644 index 0000000..85d7ce3 --- /dev/null +++ b/src/test/resources/spdd-fixture/agent-context/memory/context-index.md @@ -0,0 +1,6 @@ +# Context Index + +| Area | Kind | Work ID | Phase | Timestamp | Source | Entry | +|------|------|---------|-------|-----------|--------|-------| +| src/billing | session | SPIKE-FIX-001-retrieval-fixture | code | 2026-07-05T15:00:00Z | spdd/canvas/SPIKE-FIX-001-retrieval-fixture.md | agent-context/memory/sessions/x.md | +| src/billing | pitfall | SPIKE-FIX-001-retrieval-fixture | code | 2026-07-05T13:00:00Z | known-pitfalls.md | idempotency key | diff --git a/src/test/resources/spdd-fixture/spdd/canvas/SPIKE-FIX-001-retrieval-fixture.md b/src/test/resources/spdd-fixture/spdd/canvas/SPIKE-FIX-001-retrieval-fixture.md new file mode 100644 index 0000000..198eaf1 --- /dev/null +++ b/src/test/resources/spdd-fixture/spdd/canvas/SPIKE-FIX-001-retrieval-fixture.md @@ -0,0 +1,15 @@ +# REASONS Canvas: SPIKE-FIX-001-retrieval-fixture - Retrieval experiment fixture + +## Metadata + +- Work ID: SPIKE-FIX-001-retrieval-fixture +- Work Type: Spike +- Status: Complete + +## O - Operations + +### T01 - Seed fixture indexes + +- Status: Complete +- Description: Seed indexes for gold test +- Validation: resolver gold test passes