diff --git a/.env.example b/.env.example index 60667c9..160d3fd 100644 --- a/.env.example +++ b/.env.example @@ -20,9 +20,6 @@ GUIDE_PROFILE=user # NEO4J_PASSWORD=brahmsian # NEO4J_URI=bolt://localhost:7687 -# Discord bot token (optional — only needed for Discord integration) -# DISCORD_TOKEN=your-discord-token - # Encryption key for BYOK API keys cached client-side. # Generate with: openssl rand -base64 32 EMBABEL_KEY_SECRET= diff --git a/.github/workflows/export-seed.yml b/.github/workflows/export-seed.yml index 99d6054..1f87ef6 100644 --- a/.github/workflows/export-seed.yml +++ b/.github/workflows/export-seed.yml @@ -57,7 +57,7 @@ jobs: echo "model.onnx: $(du -h "$MODEL_DIR/model.onnx" | cut -f1)" echo "tokenizer.json: $(du -h "$MODEL_DIR/tokenizer.json" | cut -f1)" - - name: Start guide with auto-seed + - name: Start guide env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: dummy-key @@ -66,30 +66,51 @@ jobs: -DNEO4J_URI=bolt://localhost:7687 \ -DNEO4J_USERNAME=neo4j \ -DNEO4J_PASSWORD=testpassword \ - -Dguide.reload-content-on-startup=true \ -jar target/*.jar > /tmp/guide.log 2>&1 & echo "GUIDE_PID=$!" >> $GITHUB_ENV - - name: Wait for guide to be ready (ingestion completes in constructor) + - name: Wait for guide to be ready run: | - echo "Waiting for guide to start and finish ingestion..." - for i in $(seq 1 120); do + echo "Waiting for guide to start..." + for i in $(seq 1 60); do HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:1337/api/v1/data/stats 2>/dev/null || echo "000") if [ "$HTTP_CODE" = "200" ]; then - echo "Guide is ready after ~${i}0 seconds (HTTP $HTTP_CODE)" + echo "Guide is ready after ~${i}0 seconds" exit 0 fi - # Show recent log output for progress echo "--- Attempt $i (${i}0s elapsed) HTTP=$HTTP_CODE ---" tail -5 /tmp/guide.log 2>/dev/null || true sleep 10 done - echo "ERROR: Guide did not become ready within 20 minutes" - echo "=== Full guide log ===" + echo "ERROR: Guide did not start within 10 minutes" cat /tmp/guide.log exit 1 + timeout-minutes: 12 + + - name: Trigger ingestion and wait for completion + run: | + echo "Triggering ingestion via load-references endpoint..." + HTTP_CODE=$(curl -s -o /tmp/ingestion-response.txt -w "%{http_code}" \ + -X POST http://localhost:1337/api/v1/data/load-references) + echo "Ingestion completed with HTTP $HTTP_CODE" + cat /tmp/ingestion-response.txt + if [ "$HTTP_CODE" != "200" ]; then + echo "ERROR: Ingestion failed" + tail -100 /tmp/guide.log + exit 1 + fi timeout-minutes: 25 + - name: Remove Persona nodes before export + run: | + echo "Removing Persona nodes from export (managed by the app, not the seed)..." + RESULT=$(curl -sf \ + -u "neo4j:testpassword" \ + -H "Content-Type: application/json" \ + "http://localhost:7474/db/neo4j/tx/commit" \ + -d '{"statements": [{"statement": "MATCH (p:Persona) DETACH DELETE p RETURN count(p) AS removed"}]}') + echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Removed {d[\"results\"][0][\"data\"][0][\"row\"][0]} Persona nodes')" + - name: Tag all nodes with managedBy run: | echo "Tagging all nodes with managedBy=embabel..." 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/Dockerfile b/Dockerfile index 433a838..ce00565 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,4 +19,9 @@ COPY guide-app.jar app.jar EXPOSE 1337 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:+UseG1GC", \ + "-XX:+ParallelRefProcEnabled", \ + "-jar", "app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index 1335aba..b02fcfe 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ -![Build](https://github.com/embabel/embabel-agent/actions/workflows/maven.yml/badge.svg) + -![Java](https://img.shields.io/badge/java-%23ED8B00.svg?style=for-the-badge&logo=openjdk&logoColor=white) -![Spring](https://img.shields.io/badge/spring-%236DB33F.svg?style=for-the-badge&logo=spring&logoColor=white) -![Apache Tomcat](https://img.shields.io/badge/apache%20tomcat-%23F8DC75.svg?style=for-the-badge&logo=apache-tomcat&logoColor=black) -![Apache Maven](https://img.shields.io/badge/Apache%20Maven-C71A36?style=for-the-badge&logo=Apache%20Maven&logoColor=white) -![ChatGPT](https://img.shields.io/badge/chatGPT-74aa9c?style=for-the-badge&logo=openai&logoColor=white) -![JSON](https://img.shields.io/badge/JSON-000?logo=json&logoColor=fff) -![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=white) -![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) -![IntelliJ IDEA](https://img.shields.io/badge/IntelliJIDEA-000000.svg?style=for-the-badge&logo=intellij-idea&logoColor=white) +### **Embabel Guide : Chat and MCP Server** -# Embabel Guide : Chat and MCP Server +![Build](https://github.com/embabel/embabel-agent/actions/workflows/maven.yml/badge.svg) [![Embabel](https://img.shields.io/badge/Embabel-Agent_Framework-6C3483?style=for-the-badge)](https://github.com/embabel/embabel-agent) ![Java](https://img.shields.io/badge/java-%23ED8B00.svg?style=for-the-badge&logo=openjdk&logoColor=white) ![Spring](https://img.shields.io/badge/spring-%236DB33F.svg?style=for-the-badge&logo=spring&logoColor=white) ![Apache Maven](https://img.shields.io/badge/Apache%20Maven-C71A36?style=for-the-badge&logo=Apache%20Maven&logoColor=white) ![ChatGPT](https://img.shields.io/badge/chatGPT-74aa9c?style=for-the-badge&logo=openai&logoColor=white) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) - +
Guide exposes resources relating to the Embabel Agent Framework, such as documentation, relevant blogs and other content, and up-to-the-minute API information. +This repository is the backend for [Embabel Hub](https://hub.embabel.com) — the Guide powers the +natural-language "Talk to the Docs" experience there. + +## Links + +- [Embabel Hub](https://hub.embabel.com) — talk to the docs, powered by this Guide +- [Embabel Agent Framework](https://github.com/embabel/embabel-agent) +

The Voice, The Word, and The Wheel @@ -45,6 +45,45 @@ To see stats on data, make a GET request or browse to http://localhost:1337/api/ RAG content storage uses the `ChunkingContentElementRepository` interface from the `embabel-agent-rag-core` library. The default backend is Neo4j via `DrivineStore`. You can plug in other backends by providing a different `ChunkingContentElementRepository` bean. +## Switching the Graph Database + +`DrivineStore` (from `embabel-agent-rag-graph`) supports three Cypher-speaking backends: **Neo4j** (default), **FalkorDB**, and **Memgraph**. The active backend is selected by Spring profile in `src/main/resources/application.yml` — each profile sets `database.dataSources.neo.type`, and `RagConfiguration` picks the matching `RagDialect` at startup. + +| Backend | Profile | Default port | Compose profile | +|----------|------------|--------------|-----------------| +| Neo4j | `neo4j` | `7687` (bolt)| `neo4j` | +| FalkorDB | `falkordb` | `6379` | `falkordb` | +| Memgraph | `memgraph` | `7688` (bolt)| `memgraph` | + +### Switching at startup + +Change `spring.profiles.active` in `application.yml`, or override at launch: + +```bash +# Neo4j (default) +./mvnw spring-boot:run -Dspring-boot.run.profiles=neo4j + +# FalkorDB +./mvnw spring-boot:run -Dspring-boot.run.profiles=falkordb + +# Memgraph +./mvnw spring-boot:run -Dspring-boot.run.profiles=memgraph +``` + +### Starting the matching container + +```bash +docker compose --profile neo4j up -d # or falkordb / memgraph +``` + +Memgraph maps host port `7688` → container `7687` to avoid clashing with Neo4j, so you can run them side-by-side. FalkorDB uses Redis protocol on `6379`. + +### Browsing data + +- **Neo4j**: http://localhost:7474/browser/ (user `neo4j`, password `brahmsian`) +- **FalkorDB**: http://localhost:3001 (FalkorDB Browser, started with the `falkordb` compose profile) +- **Memgraph**: connect via [Memgraph Lab](https://memgraph.com/lab) to `bolt://localhost:7688` + ## Viewing and Deleting Data Go to the Neo Browser at http://localhost:7474/browser/ diff --git a/codegen-gradle/build.gradle.kts b/codegen-gradle/build.gradle.kts index a484700..d271186 100644 --- a/codegen-gradle/build.gradle.kts +++ b/codegen-gradle/build.gradle.kts @@ -18,7 +18,7 @@ plugins { group = "com.embabel.guide" version = "0.1.0-SNAPSHOT" -val drivineVersion = "0.0.28" +val drivineVersion = "0.0.45" repositories { mavenCentral() @@ -40,7 +40,6 @@ dependencies { // Dependencies needed for domain classes to compile implementation("com.embabel.agent:embabel-agent-api:0.3.2-SNAPSHOT") - implementation("com.embabel.agent:embabel-agent-discord:0.3.2-SNAPSHOT") implementation("com.fasterxml.jackson.core:jackson-annotations:2.18.2") } diff --git a/compose.yaml b/compose.yaml index 0b7f55b..b1140b7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,5 +1,7 @@ services: neo4j: + profiles: + - neo4j image: neo4j:${NEO4J_VERSION:-2025.10.1-community-bullseye} container_name: embabel-neo4j ports: @@ -55,7 +57,6 @@ services: - NEO4J_USERNAME=${NEO4J_USERNAME:-neo4j} - NEO4J_PASSWORD=${NEO4J_PASSWORD:-brahmsian} - OPENAI_API_KEY=${OPENAI_API_KEY} - - DISCORD_TOKEN=${DISCORD_TOKEN:-} volumes: - /var/run/docker.sock:/var/run/docker.sock depends_on: @@ -83,6 +84,46 @@ services: - embabel-network restart: unless-stopped + memgraph: + profiles: + - memgraph + image: memgraph/memgraph-mage:latest + container_name: embabel-memgraph + ports: + - "${MEMGRAPH_BOLT_PORT:-7688}:7687" + command: ["--also-log-to-stderr", "--log-level=WARNING"] + volumes: + - memgraph_data:/var/lib/memgraph + - memgraph_log:/var/log/memgraph + networks: + - embabel-network + + falkordb: + profiles: + - falkordb + image: falkordb/falkordb:latest + container_name: embabel-falkordb + ports: + - "${FALKORDB_PORT:-6379}:6379" + volumes: + - falkordb_data:/data + networks: + - embabel-network + + falkordb-browser: + profiles: + - falkordb + image: falkordb/falkordb-browser:latest + container_name: embabel-falkordb-browser + ports: + - "${FALKORDB_BROWSER_PORT:-3001}:3000" + environment: + - FALKORDB_URL=redis://falkordb:6379 + depends_on: + - falkordb + networks: + - embabel-network + volumes: neo4j_data: driver: local @@ -92,6 +133,12 @@ volumes: driver: local neo4j_plugins: driver: local + falkordb_data: + driver: local + memgraph_data: + driver: local + memgraph_log: + driver: local networks: embabel-network: diff --git a/docs/spdd-branch-changes.md b/docs/spdd-branch-changes.md new file mode 100644 index 0000000..faee4f7 --- /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` built from `DynamicType`s — the entity schema, also used to validate label parameters on reads | +| `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..2eb04d4 --- /dev/null +++ b/docs/spdd-projection-ingest.md @@ -0,0 +1,111 @@ +# 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`: + +- `SpddEntityDictionary` — `DataDictionary` (`DynamicType` spike schema) +- `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/logo.png b/logo.png new file mode 100644 index 0000000..ea613ff Binary files /dev/null and b/logo.png differ diff --git a/pom.xml b/pom.xml index c286716..4129578 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,7 @@ 21 - 0.3.5-SNAPSHOT + 1.0.0-RC1-SNAPSHOT 1.1.1 2.2.0 @@ -32,10 +32,14 @@ ${embabel-agent.version} + com.embabel.agent - embabel-agent-rag-neo-drivine - 0.1.2-SNAPSHOT + embabel-agent-rag-graph + 0.2.0-SNAPSHOT org.drivine @@ -47,7 +51,7 @@ org.drivine drivine4j-spring-boot-starter - 0.0.29 + 0.0.46 @@ -65,7 +69,7 @@ com.embabel.agent - embabel-agent-anthropic-autoconfigure + embabel-agent-anthropic ${embabel-agent.version} @@ -82,12 +86,6 @@ ${embabel-agent.version} - - com.embabel.agent - embabel-agent-discord - ${embabel-agent.version} - - com.embabel.agent embabel-agent-starter-mcpserver @@ -169,6 +167,11 @@ jjwt-api 0.12.6 + + com.sendgrid + sendgrid-java + 4.10.2 + io.jsonwebtoken jjwt-impl @@ -231,6 +234,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..338dbf8 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 @@ -56,15 +137,28 @@ export SPRING_PROFILES_ACTIVE="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/run-mcp-guide-against-hub.sh b/scripts/run-mcp-guide-against-hub.sh new file mode 100644 index 0000000..f635f8a --- /dev/null +++ b/scripts/run-mcp-guide-against-hub.sh @@ -0,0 +1,98 @@ +#!/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}" +export SPRING_PROFILES_ACTIVE="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..f497856 100644 --- a/scripts/user-config/README.md +++ b/scripts/user-config/README.md @@ -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 81a8e16..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. @@ -37,7 +41,7 @@ public class DataManager { private final List references; private final ChunkingContentElementRepository store; - private final HierarchicalContentReader hierarchicalContentReader = new TikaHierarchicalContentReader(); + private final HierarchicalContentReader hierarchicalContentReader; // Refresh only snapshots private final ContentRefreshPolicy contentRefreshPolicy = UrlSpecificContentRefreshPolicy.containingAny( @@ -46,10 +50,12 @@ public class DataManager { public DataManager( ChunkingContentElementRepository store, - GuideProperties guideProperties + GuideProperties guideProperties, + HierarchicalContentReader hierarchicalContentReader ) { this.store = store; this.guideProperties = guideProperties; + this.hierarchicalContentReader = hierarchicalContentReader; this.references = LlmReferenceProviders.fromYmlFile(guideProperties.getReferencesFile()); store.provision(); // Ingestion on startup is now handled by IngestionRunner (ApplicationRunner) @@ -96,7 +102,7 @@ public void provisionDatabase() { */ public DirectoryParsingResult ingestDirectory(String dir, List failedDocuments) { var ft = FileTools.readOnly(dir); - var directoryParsingResult = new TikaHierarchicalContentReader() + var directoryParsingResult = hierarchicalContentReader .parseFromDirectory(ft, new DirectoryParsingConfig()); for (var root : directoryParsingResult.getContentRoots()) { String docTitle = "unknown"; @@ -163,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 { @@ -186,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/DataManagerController.java b/src/main/java/com/embabel/guide/rag/DataManagerController.java index c1fb775..e4db940 100644 --- a/src/main/java/com/embabel/guide/rag/DataManagerController.java +++ b/src/main/java/com/embabel/guide/rag/DataManagerController.java @@ -1,6 +1,9 @@ package com.embabel.guide.rag; -import com.embabel.agent.rag.store.ContentElementRepositoryInfo; +import com.embabel.guide.stats.GuideStats; +import com.embabel.guide.stats.GuideStatsService; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -14,14 +17,30 @@ public class DataManagerController { private final DataManager dataManager; + private final GuideStatsService guideStatsService; - public DataManagerController(DataManager dataManager) { + public DataManagerController(DataManager dataManager, GuideStatsService guideStatsService) { this.dataManager = dataManager; + this.guideStatsService = guideStatsService; } + /** + * Public stats. Content counts are returned to everyone; admin callers additionally receive + * user/message figures. The role check is performed in {@link GuideStatsService} from the + * authenticated caller's roles. + */ @GetMapping("/stats") - public ContentElementRepositoryInfo getStats() { - return dataManager.getStats(); + public GuideStats getStats() { + return guideStatsService.stats(currentWebUserId()); + } + + /** The authenticated caller's webUserId (JWT subject), or null when unauthenticated/anonymous. */ + private static String currentWebUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof String principal && !"anonymousUser".equals(principal)) { + return principal; + } + return null; } @PostMapping("/load-references") 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/McpToolExportConfiguration.java b/src/main/java/com/embabel/guide/rag/McpToolExportConfiguration.java index 2afe8b3..537d22f 100644 --- a/src/main/java/com/embabel/guide/rag/McpToolExportConfiguration.java +++ b/src/main/java/com/embabel/guide/rag/McpToolExportConfiguration.java @@ -1,12 +1,15 @@ package com.embabel.guide.rag; +import com.embabel.agent.filter.PropertyFilter; import com.embabel.agent.mcpserver.McpToolExport; -import com.embabel.agent.rag.neo.drivine.DrivineStore; +import com.embabel.agent.rag.graph.DrivineStore; import com.embabel.agent.rag.tools.ToolishRag; import com.embabel.guide.GuideProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.List; + /** * Export MCP tools */ @@ -23,6 +26,14 @@ McpToolExport documentationRagTools( "Embabel docs", drivineStore ); + var activeVersion = properties.getContent().getActiveVersion(); + if (activeVersion != null) { + var versionFilter = new PropertyFilter.In( + "version", + List.of(activeVersion, VersionChunkTransformer.SUPPLEMENTARY) + ); + toolishRag = toolishRag.withMetadataFilter(versionFilter); + } return McpToolExport.fromLlmReference( toolishRag, properties.toolNamingStrategy() diff --git a/src/main/java/com/embabel/guide/rag/RagConfiguration.java b/src/main/java/com/embabel/guide/rag/RagConfiguration.java index 393a3ad..46f3af4 100644 --- a/src/main/java/com/embabel/guide/rag/RagConfiguration.java +++ b/src/main/java/com/embabel/guide/rag/RagConfiguration.java @@ -17,11 +17,23 @@ import com.embabel.agent.rag.ingestion.ChunkTransformer; import com.embabel.agent.rag.ingestion.ContentChunker; -import com.embabel.agent.rag.neo.drivine.DrivineCypherSearch; -import com.embabel.agent.rag.neo.drivine.DrivineStore; -import com.embabel.agent.rag.neo.drivine.NeoRagServiceProperties; +import com.embabel.agent.rag.ingestion.ContentFetcher; +import com.embabel.agent.rag.ingestion.FetchRoute; +import com.embabel.agent.rag.ingestion.HierarchicalContentReader; +import com.embabel.agent.rag.ingestion.HttpContentFetcher; +import com.embabel.agent.rag.ingestion.RoutingContentFetcher; +import com.embabel.agent.rag.ingestion.RssContentFetcher; +import com.embabel.agent.rag.ingestion.TikaHierarchicalContentReader; +import kotlin.Pair; + +import java.util.ArrayList; +import com.embabel.agent.rag.graph.DrivineCypherSearch; +import com.embabel.agent.rag.graph.DrivineStore; +import com.embabel.agent.rag.graph.GraphRagServiceProperties; +import com.embabel.agent.rag.graph.dialect.RagDialect; import com.embabel.common.ai.model.EmbeddingService; import com.embabel.guide.GuideProperties; +import org.drivine.connection.DataSourceMap; import org.drivine.manager.PersistenceManager; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -40,13 +52,32 @@ * runs first so the EmbeddingService bean exists when this configuration is wired. */ @Configuration -@EnableConfigurationProperties(NeoRagServiceProperties.class) +@EnableConfigurationProperties(GraphRagServiceProperties.class) @DependsOn("onnxEmbeddingInitializer") class RagConfiguration { @Bean - ChunkTransformer chunkTransformer() { - return ChunkTransformer.NO_OP; + ChunkTransformer chunkTransformer(GuideProperties guideProperties) { + return new VersionChunkTransformer(guideProperties); + } + + @Bean + HierarchicalContentReader hierarchicalContentReader(GuideProperties guideProperties) { + // Medium's RSS feed only exposes ~10 most recent articles per author. + // Try RSS first; on failure fall back to direct HTTP in case Medium + // is currently lax (its bot-blocking tightens and relaxes over time). + var http = new HttpContentFetcher(); + var mediumRss = new RssContentFetcher( + RssContentFetcher.Companion.templateResolver("https://medium.com/feed/{0}"), + http); + var mediumFetcher = new FallbackContentFetcher(mediumRss, http); + + var routes = new ArrayList>(); + routes.add(new Pair<>("https://medium.com/**", mediumFetcher)); + for (FetchRoute route : guideProperties.getFetchRoutes()) { + routes.add(new Pair<>(route.getPattern(), route.buildFetcher())); + } + return new TikaHierarchicalContentReader(new RoutingContentFetcher(http, routes)); } @Bean @@ -56,19 +87,23 @@ DrivineStore drivineStore( PlatformTransactionManager platformTransactionManager, EmbeddingService embeddingService, ChunkTransformer chunkTransformer, - NeoRagServiceProperties neoRagProperties, - GuideProperties guideProperties) { + GraphRagServiceProperties graphRagProperties, + GuideProperties guideProperties, + DataSourceMap dataSourceMap) { var chunkerConfig = guideProperties.getChunkerConfig() != null ? guideProperties.getChunkerConfig() : new ContentChunker.Config(); + var databaseType = dataSourceMap.getDataSources().get("neo").getType(); + var dialect = RagDialect.Companion.forDatabaseType(databaseType); return new DrivineStore( persistenceManager, - neoRagProperties, + graphRagProperties, chunkerConfig, chunkTransformer, embeddingService, platformTransactionManager, - new DrivineCypherSearch(persistenceManager) + new DrivineCypherSearch(persistenceManager), + dialect ); } } \ No newline at end of file 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/ChatActions.kt b/src/main/kotlin/com/embabel/guide/ChatActions.kt index 81c712d..65828ce 100644 --- a/src/main/kotlin/com/embabel/guide/ChatActions.kt +++ b/src/main/kotlin/com/embabel/guide/ChatActions.kt @@ -5,10 +5,11 @@ import com.embabel.agent.api.annotation.EmbabelComponent import com.embabel.agent.api.common.ActionContext import com.embabel.agent.api.common.PromptRunner import com.embabel.agent.api.identity.User -import com.embabel.agent.discord.DiscordUser -import com.embabel.agent.rag.neo.drivine.DrivineStore +import com.embabel.agent.rag.graph.DrivineStore +import com.embabel.agent.filter.PropertyFilter import com.embabel.agent.rag.tools.ToolishRag import com.embabel.agent.rag.tools.TryHyDE +import com.embabel.guide.rag.VersionChunkTransformer import com.embabel.chat.AssistantMessage import com.embabel.chat.Message import com.embabel.chat.ChatTrigger @@ -25,8 +26,8 @@ import com.embabel.guide.command.CommandTools import com.embabel.guide.domain.GuideUser import com.embabel.guide.domain.GuideUserCache import com.embabel.guide.domain.GuideUserRepository -import com.embabel.guide.util.toDiscordUserInfoData -import com.embabel.guide.util.toGuideUserData +import com.embabel.guide.domain.GuideUserService +import com.embabel.guide.domain.HasGuideUserData import com.embabel.guide.narrator.NarrationCache import com.embabel.guide.narrator.NarratorAgent import com.embabel.guide.rag.DataManager @@ -49,6 +50,7 @@ import java.util.concurrent.ThreadLocalRandom class ChatActions( private val dataManager: DataManager, private val guideUserRepository: GuideUserRepository, + private val guideUserService: GuideUserService, private val guideUserCache: GuideUserCache, private val drivineStore: DrivineStore, private val guideProperties: GuideProperties, @@ -196,18 +198,6 @@ class ChatActions( logger.warn("user is null: Cannot create or fetch GuideUser") null } - is DiscordUser -> { - val cacheKey = "discord:${user.id}" - guideUserCache.get(cacheKey) ?: guideUserRepository.findByDiscordUserId(user.id) - .orElseGet { - val created = guideUserRepository.createWithDiscord( - user.toGuideUserData(), user.toDiscordUserInfoData() - ) - logger.info("Created new Discord user: {}", created) - created - } - .also { guideUserCache.put(cacheKey, it) } - } is GuideUser -> { val webUserId = user.webUser?.id if (webUserId != null) { @@ -219,6 +209,10 @@ class ChatActions( .orElseThrow { RuntimeException("Missing GuideUser with id: ${user.core.id}") } } } + is HasGuideUserData -> { + guideUserRepository.findById(user.guideUserData().id) + .orElseThrow { RuntimeException("Missing GuideUser with id: ${user.guideUserData().id}") } + } else -> throw RuntimeException("Unknown user type: $user") } @@ -232,13 +226,24 @@ class ChatActions( "docs", "Embabel docs", drivineStore, - ).withHint(TryHyDE.usingConversationContext()) + ).let { rag -> + val filter = versionFilter() + if (filter != null) rag.withMetadataFilter(filter) else rag + }.withHint(TryHyDE.usingConversationContext()) ) .rendering("guide_system") } + private fun versionFilter(): PropertyFilter? { + val version = guideProperties.content.activeVersion ?: return null + return PropertyFilter.In( + "version", + listOf(version, VersionChunkTransformer.SUPPLEMENTARY), + ) + } + private fun buildTemplateModel(guideUser: GuideUser, messages: List): MutableMap { - val persona = guideUser.core.persona ?: guideProperties.defaultPersona + val persona = guideUser.persona.id logger.info("[PERSONA] user={} persona={}", guideUser.core.id, persona) val userMap = mutableMapOf() @@ -284,7 +289,7 @@ class ChatActions( )) } try { - val personaId = guideUser.core.persona ?: guideProperties.defaultPersona + val personaId = guideUser.persona.id val personaPrompt = guideUser.core.customPrompt ?: personaService.findPrompt(personaId) val narration = narratorAgent.narrate(assistantMessage.content, personaPrompt, context, guideUser.id) diff --git a/src/main/kotlin/com/embabel/guide/GuideProperties.kt b/src/main/kotlin/com/embabel/guide/GuideProperties.kt index 56b6b8c..83959cc 100644 --- a/src/main/kotlin/com/embabel/guide/GuideProperties.kt +++ b/src/main/kotlin/com/embabel/guide/GuideProperties.kt @@ -1,6 +1,7 @@ package com.embabel.guide import com.embabel.agent.rag.ingestion.ContentChunker +import com.embabel.agent.rag.ingestion.FetchRoute import com.embabel.common.util.StringTransformer import com.embabel.hub.integrations.LlmProvider import jakarta.validation.constraints.NotBlank @@ -10,6 +11,29 @@ import org.springframework.boot.context.properties.bind.DefaultValue import org.springframework.validation.annotation.Validated import java.nio.file.Path +/** + * Versioned content source configuration. + * URLs are built from baseUrl + version + "/". + */ +data class VersionedContentConfig( + val baseUrl: String, + val versions: List = emptyList(), +) { + fun urls(): List = versions.map { version -> "${baseUrl}$version/" } +} + +/** + * Content source configuration, separating versioned docs from supplementary material. + */ +data class ContentConfig( + @NestedConfigurationProperty val versioned: VersionedContentConfig, + val supplementary: List = emptyList(), +) { + fun allUrls(): List = versioned.urls() + supplementary + + val activeVersion: String? get() = versioned.versions.firstOrNull() +} + /** * Configuration properties for the Guide application. * @@ -19,9 +43,11 @@ import java.nio.file.Path * @param projectsPath path to projects root: absolute, or relative to the process working directory (user.dir) * @param chunkerConfig chunker configuration for RAG ingestion * @param referencesFile YML files containing LLM references such as GitHub repositories and classpath info - * @param urls list of URLs to ingest--for example, documentation and blogs + * @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") @@ -36,13 +62,43 @@ data class GuideProperties( @DefaultValue("references.yml") @field:NotBlank(message = "referencesFile must not be blank") val referencesFile: String, - val urls: List, + @NestedConfigurationProperty val content: ContentConfig, @DefaultValue("") val toolPrefix: String, 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/event/MessageEventListener.kt b/src/main/kotlin/com/embabel/guide/chat/event/MessageEventListener.kt index dc902d8..6fe4886 100644 --- a/src/main/kotlin/com/embabel/guide/chat/event/MessageEventListener.kt +++ b/src/main/kotlin/com/embabel/guide/chat/event/MessageEventListener.kt @@ -47,7 +47,7 @@ class MessageEventListener( } // Look up the GuideUser to get their webUserId for WebSocket routing - val guideUser = guideUserRepository.findById(toGuideUserId).orElse(null) + val guideUser = guideUserRepository.findWebUserById(toGuideUserId).orElse(null) if (guideUser == null) { logger.warn("GuideUser not found for id {}, skipping WebSocket delivery", toGuideUserId) return @@ -100,7 +100,7 @@ class MessageEventListener( // If the PERSISTED event has a title (e.g. LLM just generated one), // push a session event so the frontend dropdown updates immediately. if (event.title != null && event.toUserId != null) { - val guideUser = guideUserRepository.findById(event.toUserId!!).orElse(null) + val guideUser = guideUserRepository.findWebUserById(event.toUserId!!).orElse(null) val webUserId = guideUser?.webUser?.id if (webUserId != null) { chatService.sendSessionToUser(webUserId, SessionEvent( diff --git a/src/main/kotlin/com/embabel/guide/chat/model/CommandRequest.kt b/src/main/kotlin/com/embabel/guide/chat/model/CommandRequest.kt index 28f4c51..467ddd8 100644 --- a/src/main/kotlin/com/embabel/guide/chat/model/CommandRequest.kt +++ b/src/main/kotlin/com/embabel/guide/chat/model/CommandRequest.kt @@ -4,7 +4,6 @@ data class CommandRequest( val correlationId: String, val type: String, val value: String, - val clearPrevious: Boolean = false, ) data class CommandResponse( diff --git a/src/main/kotlin/com/embabel/guide/chat/security/RequirePrincipalInterceptor.kt b/src/main/kotlin/com/embabel/guide/chat/security/RequirePrincipalInterceptor.kt new file mode 100644 index 0000000..1b7a636 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/chat/security/RequirePrincipalInterceptor.kt @@ -0,0 +1,34 @@ +package com.embabel.guide.chat.security + +import org.slf4j.LoggerFactory +import org.springframework.messaging.Message +import org.springframework.messaging.MessageChannel +import org.springframework.messaging.simp.stomp.StompCommand +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.ChannelInterceptor +import org.springframework.stereotype.Component + +/** + * Drops inbound STOMP SEND frames that arrive without a principal on the + * WebSocket session. The handshake handler (AnonymousPrincipalHandshakeHandler) + * is supposed to attach one — JWT-derived or anonymous-fallback. If it didn't, + * the connection is in a half-state (e.g. SockJS landed without going through + * the handshake) and dispatching to @MessageMapping handlers would NPE. + */ +@Component +class RequirePrincipalInterceptor : ChannelInterceptor { + + private val logger = LoggerFactory.getLogger(javaClass) + + override fun preSend(message: Message<*>, channel: MessageChannel): Message<*>? { + val accessor = StompHeaderAccessor.wrap(message) + if (accessor.command == StompCommand.SEND && accessor.user == null) { + logger.warn( + "Dropping SEND to {}: no principal on WebSocket session (sessionId={})", + accessor.destination, accessor.sessionId + ) + return null + } + return message + } +} \ No newline at end of file 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 5e80e14..a4c5ced 100644 --- a/src/main/kotlin/com/embabel/guide/chat/security/SecurityConfig.kt +++ b/src/main/kotlin/com/embabel/guide/chat/security/SecurityConfig.kt @@ -49,7 +49,9 @@ class SecurityConfig( val actuatorPatterns = arrayOf( "/actuator", - "/actuator/**", + "/actuator/health", + "/actuator/health/**", + "/actuator/info", ) val permittedPatterns = arrayOf( @@ -91,8 +93,14 @@ class SecurityConfig( "/api/hub/register", "/api/hub/login", "/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() it.requestMatchers( HttpMethod.GET, @@ -100,7 +108,12 @@ class SecurityConfig( "/api/hub/personas", "/api/hub/sessions", "/api/v1/data/stats", - "/api/v1/deepgram/models" + "/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", ).permitAll() it.anyRequest().authenticated() } diff --git a/src/main/kotlin/com/embabel/guide/chat/service/ChatSessionService.kt b/src/main/kotlin/com/embabel/guide/chat/service/ChatSessionService.kt index 7b3cfeb..c9fa842 100644 --- a/src/main/kotlin/com/embabel/guide/chat/service/ChatSessionService.kt +++ b/src/main/kotlin/com/embabel/guide/chat/service/ChatSessionService.kt @@ -72,7 +72,7 @@ class ChatSessionService( authorId: String? = null ): StoredSession { val sessionId = UUIDv7.generateString() - val owner = guideUserRepository.findById(ownerId).orElseThrow { + val owner = guideUserRepository.findWebUserById(ownerId).orElseThrow { IllegalArgumentException("Owner not found: $ownerId") } @@ -85,7 +85,7 @@ class ChatSessionService( // Look up the author if provided val messageAuthor = authorId?.let { id -> - guideUserRepository.findById(id).orElse(null)?.guideUserData() + guideUserRepository.findWebUserById(id).orElse(null)?.guideUserData() } return chatSessionRepository.createSessionWithMessage( @@ -128,6 +128,7 @@ class ChatSessionService( ) // Send trigger — prompt never enters conversation, only the response is stored + // Full GuideUser needed here: ChatActions resolves persona from the trigger's onBehalfOf user. val trigger = ChatTrigger( prompt = WELCOME_PROMPT_TEMPLATE.format(displayName), onBehalfOf = listOf(owner), @@ -199,7 +200,7 @@ class ChatSessionService( if (existing.isPresent) { SessionResult(existing.get(), created = false) } else { - val owner = guideUserRepository.findById(ownerId).orElseThrow { + val owner = guideUserRepository.findWebUserById(ownerId).orElseThrow { IllegalArgumentException("Owner not found: $ownerId") } diff --git a/src/main/kotlin/com/embabel/guide/chat/service/GuideRagServiceAdapter.kt b/src/main/kotlin/com/embabel/guide/chat/service/GuideRagServiceAdapter.kt index c8418a4..1e9c37c 100644 --- a/src/main/kotlin/com/embabel/guide/chat/service/GuideRagServiceAdapter.kt +++ b/src/main/kotlin/com/embabel/guide/chat/service/GuideRagServiceAdapter.kt @@ -83,7 +83,7 @@ class GuideRagServiceAdapter( val messageOutputChannel = createOutputChannel(responseBuilder, onEvent) { isComplete = true } try { - val guideUser = guideUserRepository.findById(fromUserId) + val guideUser = guideUserRepository.findWebUserById(fromUserId) .orElseThrow { RuntimeException("No user found with id: $fromUserId") } // Get or create session context for this thread diff --git a/src/main/kotlin/com/embabel/guide/chat/service/JesseService.kt b/src/main/kotlin/com/embabel/guide/chat/service/JesseService.kt index 20b26d7..e657d35 100644 --- a/src/main/kotlin/com/embabel/guide/chat/service/JesseService.kt +++ b/src/main/kotlin/com/embabel/guide/chat/service/JesseService.kt @@ -46,7 +46,7 @@ class JesseService( logger.info("Initializing Jesse bot") // Get or create Jesse as a GuideUser (SessionUser) for message authorship - jesseUser = guideUserRepository.findById(JESSE_USER_ID) + jesseUser = guideUserRepository.findWebUserById(JESSE_USER_ID) .map { it.core } .orElseGet { logger.info("Creating Jesse user in database") diff --git a/src/main/kotlin/com/embabel/guide/chat/socket/WebSocketConfig.kt b/src/main/kotlin/com/embabel/guide/chat/socket/WebSocketConfig.kt index a611b6c..63775c4 100644 --- a/src/main/kotlin/com/embabel/guide/chat/socket/WebSocketConfig.kt +++ b/src/main/kotlin/com/embabel/guide/chat/socket/WebSocketConfig.kt @@ -1,7 +1,9 @@ package com.embabel.guide.chat.socket import com.embabel.guide.chat.security.AnonymousPrincipalHandshakeHandler +import com.embabel.guide.chat.security.RequirePrincipalInterceptor import org.springframework.context.annotation.Configuration +import org.springframework.messaging.simp.config.ChannelRegistration import org.springframework.messaging.simp.config.MessageBrokerRegistry import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker import org.springframework.web.socket.config.annotation.StompEndpointRegistry @@ -10,7 +12,8 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo @Configuration @EnableWebSocketMessageBroker class WebSocketConfig( - private val handshakeHandler: AnonymousPrincipalHandshakeHandler + private val handshakeHandler: AnonymousPrincipalHandshakeHandler, + private val requirePrincipalInterceptor: RequirePrincipalInterceptor, ) : WebSocketMessageBrokerConfigurer { override fun registerStompEndpoints(registry: StompEndpointRegistry) { @@ -25,4 +28,8 @@ class WebSocketConfig( registry.setApplicationDestinationPrefixes("/app") registry.setUserDestinationPrefix("/user") } + + override fun configureClientInboundChannel(registration: ChannelRegistration) { + registration.interceptors(requirePrincipalInterceptor) + } } diff --git a/src/main/kotlin/com/embabel/guide/command/CommandExecutor.kt b/src/main/kotlin/com/embabel/guide/command/CommandExecutor.kt index fcffe7b..c25d632 100644 --- a/src/main/kotlin/com/embabel/guide/command/CommandExecutor.kt +++ b/src/main/kotlin/com/embabel/guide/command/CommandExecutor.kt @@ -13,7 +13,7 @@ import java.util.concurrent.TimeoutException /** * Handles frontend websocket round-trips for commands that need the browser to execute - * (voice changes, audio effects, etc.). + * (e.g. persona changes). */ @Service class CommandExecutor( @@ -24,31 +24,12 @@ class CommandExecutor( private val pendingCommands = ConcurrentHashMap>() - fun executePersonaChange(persona: String, webUserId: String?): String { + fun executePersonaChange(personaId: String, webUserId: String?): String { if (webUserId == null) return "Persona change is only available for web users." return sendAndWait(webUserId, CommandRequest( correlationId = UUID.randomUUID().toString(), type = "change_persona", - value = persona, - )) - } - - fun executeVoiceChange(voice: String, webUserId: String?): String { - if (webUserId == null) return "Voice change is only available for web users." - return sendAndWait(webUserId, CommandRequest( - correlationId = UUID.randomUUID().toString(), - type = "change_voice", - value = voice, - )) - } - - fun executeEffects(effects: String, clearPrevious: Boolean, webUserId: String?): String { - if (webUserId == null) return "Audio effects are only available for web users." - return sendAndWait(webUserId, CommandRequest( - correlationId = UUID.randomUUID().toString(), - type = "apply_effects", - value = effects, - clearPrevious = clearPrevious, + value = personaId, )) } diff --git a/src/main/kotlin/com/embabel/guide/command/CommandTools.kt b/src/main/kotlin/com/embabel/guide/command/CommandTools.kt index 7a5ba8c..c086ad8 100644 --- a/src/main/kotlin/com/embabel/guide/command/CommandTools.kt +++ b/src/main/kotlin/com/embabel/guide/command/CommandTools.kt @@ -26,21 +26,6 @@ class CommandTools( val match = personas.find { it.name.equals(name, ignoreCase = true) } ?: return "Unknown persona '$name'. Available personas: ${personas.joinToString { it.name }}" - return commandExecutor.executePersonaChange(match.name, webUserId) - } - - @Tool(description = "Change the user's text-to-speech voice. Use this when the user wants a different voice for narration.") - fun changeVoice( - @ToolParam(description = "Name of the voice to use") voice: String, - ): String { - return commandExecutor.executeVoiceChange(voice, webUserId) - } - - @Tool(description = "Apply audio effects to the user's narration. Use this when the user wants to add or change audio effects like echo, reverb, etc.") - fun applyEffects( - @ToolParam(description = "Comma-separated list of effects to apply") effects: String, - @ToolParam(description = "Whether to clear all previous effects before applying new ones") clearPrevious: Boolean, - ): String { - return commandExecutor.executeEffects(effects, clearPrevious, webUserId) + return commandExecutor.executePersonaChange(match.id, webUserId) } } diff --git a/src/main/kotlin/com/embabel/guide/config/ChatStoreConfig.kt b/src/main/kotlin/com/embabel/guide/config/ChatStoreConfig.kt index 40626b2..2732da9 100644 --- a/src/main/kotlin/com/embabel/guide/config/ChatStoreConfig.kt +++ b/src/main/kotlin/com/embabel/guide/config/ChatStoreConfig.kt @@ -43,7 +43,7 @@ class ChatStoreConfig { // userId is the internal GuideUser ID; key store is keyed by web user ID val webUserId = userId?.let { id -> guideUserCache.getByInternalId(id)?.webUser?.id - ?: guideUserService.findById(id).orElse(null)?.webUser?.id + ?: guideUserService.findWebUserById(id).orElse(null)?.webUser?.id } logger.info("[TITLE] resolved webUserId={}", webUserId) val activeKey = webUserId?.let { userKeyStore.getActiveKey(it) } diff --git a/src/main/kotlin/com/embabel/guide/config/WebConfig.kt b/src/main/kotlin/com/embabel/guide/config/WebConfig.kt index 5588240..c34d773 100644 --- a/src/main/kotlin/com/embabel/guide/config/WebConfig.kt +++ b/src/main/kotlin/com/embabel/guide/config/WebConfig.kt @@ -22,11 +22,12 @@ class WebConfig( "http://localhost:6274", // MCP Inspector (npx) "https://embabel.com", // Production domain "https://www.embabel.com", // Production domain with www + "https://hub.embabel.com", // Embabel Hub "app://-" // Electron/Tauri app ) private val allOrigins: List - get() = defaultOrigins + extraOrigins.split(",").map { it.trim() }.filter { it.isNotEmpty() } + get() = defaultOrigins + extraOrigins.split(Regex("[,;]")).map { it.trim() }.filter { it.isNotEmpty() } override fun addCorsMappings(registry: CorsRegistry) { // MCP endpoints - allow all origins for CLI tools like Claude Code diff --git a/src/main/kotlin/com/embabel/guide/domain/AnonymousGuideUser.kt b/src/main/kotlin/com/embabel/guide/domain/AnonymousGuideUser.kt index d3abce1..659485d 100644 --- a/src/main/kotlin/com/embabel/guide/domain/AnonymousGuideUser.kt +++ b/src/main/kotlin/com/embabel/guide/domain/AnonymousGuideUser.kt @@ -16,13 +16,17 @@ data class AnonymousGuideUser( val core: GuideUserData, @GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING) - val webUser: AnonymousWebUserData + val webUser: AnonymousWebUserData, + + @GraphRelationship(type = "USES_PERSONA", direction = Direction.OUTGOING) + val persona: PersonaData, ) { /** * Convert to the unified GuideUser type for consistent API. */ fun toGuideUser(): GuideUser = GuideUser( core = core, - webUser = webUser + webUser = webUser, + persona = persona, ) } diff --git a/src/main/kotlin/com/embabel/guide/domain/DiscordUserInfoData.kt b/src/main/kotlin/com/embabel/guide/domain/DiscordUserInfoData.kt deleted file mode 100644 index c8b050e..0000000 --- a/src/main/kotlin/com/embabel/guide/domain/DiscordUserInfoData.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.embabel.guide.domain - -import com.embabel.agent.discord.DiscordUserInfo -import com.fasterxml.jackson.annotation.JsonIgnoreProperties -import org.drivine.annotation.NodeFragment -import org.drivine.annotation.NodeId - -/** - * Node fragment representing Discord user info in the graph. - */ -@NodeFragment(labels = ["DiscordUserInfo"]) -@JsonIgnoreProperties(ignoreUnknown = true) -data class DiscordUserInfoData( - @NodeId - var id: String? = null, - var username: String? = null, - var discriminator: String? = null, - var displayName: String? = null, - var isBot: Boolean? = null, - var avatarUrl: String? = null -) { - /** - * Convert to DiscordUserInfo interface implementation. - */ - fun toDiscordUserInfo(): DiscordUserInfo = object : DiscordUserInfo { - override val id: String get() = this@DiscordUserInfoData.id!! - override val username: String get() = this@DiscordUserInfoData.username!! - override val displayName: String get() = this@DiscordUserInfoData.displayName!! - override val discriminator: String get() = this@DiscordUserInfoData.discriminator!! - override val avatarUrl: String? get() = this@DiscordUserInfoData.avatarUrl - override val isBot: Boolean get() = this@DiscordUserInfoData.isBot == true - } - - override fun toString(): String = - "DiscordUserInfoData{id='$id', username='$username', displayName='$displayName'}" -} diff --git a/src/main/kotlin/com/embabel/guide/domain/FeedbackData.kt b/src/main/kotlin/com/embabel/guide/domain/FeedbackData.kt new file mode 100644 index 0000000..10cb887 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/domain/FeedbackData.kt @@ -0,0 +1,16 @@ +package com.embabel.guide.domain + +import org.drivine.annotation.NodeFragment +import org.drivine.annotation.NodeId +import java.time.Instant + +@NodeFragment(labels = ["Feedback"]) +data class FeedbackData( + @NodeId + val id: String, + val page: String, + val helpful: Boolean, + val comment: String? = null, + val userId: String? = null, + val createdAt: Instant = Instant.now(), +) diff --git a/src/main/kotlin/com/embabel/guide/domain/FeedbackRepository.kt b/src/main/kotlin/com/embabel/guide/domain/FeedbackRepository.kt new file mode 100644 index 0000000..3333847 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/domain/FeedbackRepository.kt @@ -0,0 +1,16 @@ +package com.embabel.guide.domain + +import org.drivine.manager.GraphObjectManager +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional + +@Repository +class FeedbackRepository( + @param:Qualifier("neoGraphObjectManager") private val graphObjectManager: GraphObjectManager +) { + + @Transactional + fun save(feedbackView: FeedbackView): FeedbackView = + graphObjectManager.save(feedbackView) +} diff --git a/src/main/kotlin/com/embabel/guide/domain/FeedbackView.kt b/src/main/kotlin/com/embabel/guide/domain/FeedbackView.kt new file mode 100644 index 0000000..d299cbb --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/domain/FeedbackView.kt @@ -0,0 +1,15 @@ +package com.embabel.guide.domain + +import org.drivine.annotation.Direction +import org.drivine.annotation.GraphRelationship +import org.drivine.annotation.GraphView +import org.drivine.annotation.Root + +@GraphView +data class FeedbackView( + @Root + val feedback: FeedbackData, + + @GraphRelationship(type = "GAVE", direction = Direction.INCOMING) + val user: GuideUserData? = null, +) diff --git a/src/main/kotlin/com/embabel/guide/domain/GuideUser.kt b/src/main/kotlin/com/embabel/guide/domain/GuideUser.kt index 867d082..98591b8 100644 --- a/src/main/kotlin/com/embabel/guide/domain/GuideUser.kt +++ b/src/main/kotlin/com/embabel/guide/domain/GuideUser.kt @@ -8,8 +8,7 @@ import org.drivine.annotation.Root /** * Unified GraphView for GuideUser with optional identity sources. - * A GuideUser may be linked to a WebUser, DiscordUserInfo, both, or neither - * (e.g., when using spring shell). + * A GuideUser may be linked to a WebUser or neither (e.g., when using spring shell). */ @GraphView data class GuideUser( @@ -19,27 +18,26 @@ data class GuideUser( @GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING) val webUser: WebUserData? = null, - @GraphRelationship(type = "IS_DISCORD_USER", direction = Direction.OUTGOING) - val discordUserInfo: DiscordUserInfoData? = null + @GraphRelationship(type = "HAS_OAUTH_PROVIDER", direction = Direction.OUTGOING) + val oauthProviders: List = emptyList(), + + @GraphRelationship(type = "USES_PERSONA", direction = Direction.OUTGOING) + val persona: PersonaData, ) : User, HasGuideUserData { - // Helper properties val isWebUser: Boolean get() = webUser != null - val isDiscordUser: Boolean get() = discordUserInfo != null - val hasIdentitySource: Boolean get() = isWebUser || isDiscordUser + val hasIdentitySource: Boolean get() = isWebUser - // HasGuideUserData implementation override fun guideUserData(): GuideUserData = core - // User interface implementation - delegate to available identity source override val id: String - get() = webUser?.id ?: discordUserInfo?.id ?: core.id + get() = webUser?.id ?: core.id override val displayName: String - get() = webUser?.displayName ?: discordUserInfo?.displayName ?: "Unknown" + get() = webUser?.displayName ?: "Unknown" override val username: String - get() = webUser?.userName ?: discordUserInfo?.username ?: "unknown" + get() = webUser?.userName ?: "unknown" override val email: String? get() = webUser?.userEmail @@ -47,6 +45,4 @@ data class GuideUser( override fun toString(): String { return "GuideUser(displayName='$displayName')" } - - } diff --git a/src/main/kotlin/com/embabel/guide/domain/GuideUserCache.kt b/src/main/kotlin/com/embabel/guide/domain/GuideUserCache.kt index 2779df1..0109b9e 100644 --- a/src/main/kotlin/com/embabel/guide/domain/GuideUserCache.kt +++ b/src/main/kotlin/com/embabel/guide/domain/GuideUserCache.kt @@ -1,35 +1,62 @@ package com.embabel.guide.domain import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service +import java.time.Duration +import java.time.Instant import java.util.concurrent.ConcurrentHashMap /** - * In-memory cache for GuideUser lookups, keyed by webUserId or Discord user ID. + * In-memory cache for GuideUser lookups, keyed by webUserId. * Eliminates repeated Neo4j reads for the same user within and across requests. * - * Invalidate on any write that changes user state visible to ChatActions: + * Entries carry a TTL ([ttlSeconds]). On read, an expired entry is evicted and treated + * as a miss so the caller reloads from the repository. The TTL exists because some user + * state is changed out-of-band (e.g. `roles` granted via a raw Cypher statement) and so + * never triggers [invalidate]; the TTL bounds how stale such a change can be — it is the + * effective propagation/revocation window for ADMIN role grants. + * + * Invalidate explicitly on any in-app write that changes user state visible to ChatActions: * persona, customPrompt, welcomed flag. */ @Service -class GuideUserCache { +class GuideUserCache( + @Value("\${guide.user-cache.ttl-seconds:300}") ttlSeconds: Long +) { private val logger = LoggerFactory.getLogger(GuideUserCache::class.java) - private val cache = ConcurrentHashMap() - private val byInternalId = ConcurrentHashMap() + private val ttl: Duration = Duration.ofSeconds(ttlSeconds) + private val cache = ConcurrentHashMap() + private val byInternalId = ConcurrentHashMap() + + private data class Entry(val user: GuideUser, val expiresAt: Instant) { + fun isExpired(now: Instant): Boolean = now.isAfter(expiresAt) + } - fun get(key: String): GuideUser? = cache[key] + fun get(key: String): GuideUser? = read(cache, key) - fun getByInternalId(internalId: String): GuideUser? = byInternalId[internalId] + fun getByInternalId(internalId: String): GuideUser? = read(byInternalId, internalId) fun put(key: String, user: GuideUser) { - cache[key] = user - byInternalId[user.core.id] = user + val entry = Entry(user, Instant.now().plus(ttl)) + cache[key] = entry + byInternalId[user.core.id] = entry } fun invalidate(key: String) { - val user = cache.remove(key) - user?.let { byInternalId.remove(it.core.id) } + val entry = cache.remove(key) + entry?.let { byInternalId.remove(it.user.core.id) } logger.debug("Invalidated GuideUser cache for key {}", key) } + + /** Returns the cached user, or null on miss/expiry — evicting the expired entry so the caller reloads. */ + private fun read(index: ConcurrentHashMap, key: String): GuideUser? { + val entry = index[key] ?: return null + if (entry.isExpired(Instant.now())) { + index.remove(key, entry) + return null + } + return entry.user + } } \ No newline at end of file diff --git a/src/main/kotlin/com/embabel/guide/domain/GuideUserData.kt b/src/main/kotlin/com/embabel/guide/domain/GuideUserData.kt index 86e83fc..85ea6b9 100644 --- a/src/main/kotlin/com/embabel/guide/domain/GuideUserData.kt +++ b/src/main/kotlin/com/embabel/guide/domain/GuideUserData.kt @@ -2,6 +2,7 @@ package com.embabel.guide.domain import com.embabel.chat.store.model.StoredUser import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import org.drivine.annotation.Default import org.drivine.annotation.NodeFragment import org.drivine.annotation.NodeId @@ -17,9 +18,13 @@ data class GuideUserData( override var displayName: String = "", override var username: String = displayName, override var email: String? = null, - var persona: String? = null, var customPrompt: String? = null, var welcomed: Boolean = false, + // Authorization roles (e.g. "ADMIN"). Granted out-of-band via Cypher; absent on legacy + // nodes, so @Default coalesces a missing/null property to an empty list. See GuideUserCache + // TTL for how an out-of-band grant becomes visible to the running app. + @Default + var roles: List = emptyList(), ) : HasGuideUserData, StoredUser { override fun guideUserData(): GuideUserData = this diff --git a/src/main/kotlin/com/embabel/guide/domain/GuideUserRepository.kt b/src/main/kotlin/com/embabel/guide/domain/GuideUserRepository.kt index 298eacf..5f0df3b 100644 --- a/src/main/kotlin/com/embabel/guide/domain/GuideUserRepository.kt +++ b/src/main/kotlin/com/embabel/guide/domain/GuideUserRepository.kt @@ -8,11 +8,6 @@ import java.util.Optional */ interface GuideUserRepository { - /** - * Find a GuideUser by Discord user ID - */ - fun findByDiscordUserId(discordUserId: String): Optional - /** * Find a GuideUser by web user ID */ @@ -33,25 +28,38 @@ interface GuideUserRepository { */ fun findByWebUserEmail(userEmail: String): Optional + /** + * Find a GuideUser by OAuth provider and provider user ID + */ + fun findByOAuthProvider(provider: String, providerUserId: String): Optional + + /** + * Find a GuideUser by email verification token + */ + fun findByEmailVerificationToken(token: String): Optional + /** * Find a GuideUser by ID */ fun findById(id: String): Optional /** - * Create a new GuideUser with Discord info + * Lightweight find by ID — skips USES_PERSONA traversal. */ - fun createWithDiscord( - guideUserData: GuideUserData, - discordUserInfo: DiscordUserInfoData - ): GuideUser + fun findWebUserById(id: String): Optional + + /** + * Lightweight find by web user ID — skips USES_PERSONA traversal. + */ + fun findWebUserByWebUserId(webUserId: String): Optional /** * Create a new GuideUser with WebUser info */ fun createWithWebUser( guideUserData: GuideUserData, - webUserData: WebUserData + webUserData: WebUserData, + persona: PersonaData, ): GuideUser /** @@ -62,7 +70,7 @@ interface GuideUserRepository { /** * Update GuideUser persona */ - fun updatePersona(guideUserId: String, persona: String) + fun updatePersona(guideUserId: String, persona: PersonaData) /** * Update GuideUser custom prompt diff --git a/src/main/kotlin/com/embabel/guide/domain/GuideUserRepositoryDefaultImpl.kt b/src/main/kotlin/com/embabel/guide/domain/GuideUserRepositoryDefaultImpl.kt index 6c36a11..3a40386 100644 --- a/src/main/kotlin/com/embabel/guide/domain/GuideUserRepositoryDefaultImpl.kt +++ b/src/main/kotlin/com/embabel/guide/domain/GuideUserRepositoryDefaultImpl.kt @@ -16,16 +16,6 @@ class GuideUserRepositoryDefaultImpl( @param:Qualifier("neoGraphObjectManager") private val graphObjectManager: GraphObjectManager ) : GuideUserRepository { - @Transactional(readOnly = true) - override fun findByDiscordUserId(discordUserId: String): Optional { - val results = graphObjectManager.loadAll { - where { - discordUserInfo.id eq discordUserId - } - } - return Optional.ofNullable(results.firstOrNull()) - } - @Transactional(readOnly = true) override fun findByWebUserId(webUserId: String): Optional { val results = graphObjectManager.loadAll { @@ -63,31 +53,57 @@ class GuideUserRepositoryDefaultImpl( return Optional.ofNullable(results.firstOrNull()) } + @Transactional(readOnly = true) + override fun findByOAuthProvider(provider: String, providerUserId: String): Optional { + val results = graphObjectManager.loadAll { + where { + oauthProviders.provider eq provider + oauthProviders.providerUserId eq providerUserId + } + } + return Optional.ofNullable(results.firstOrNull()) + } + + @Transactional(readOnly = true) + override fun findByEmailVerificationToken(token: String): Optional { + val results = graphObjectManager.loadAll { + where { + webUser.emailVerificationToken eq token + } + } + return Optional.ofNullable(results.firstOrNull()) + } + @Transactional(readOnly = true) override fun findById(id: String): Optional { return Optional.ofNullable(graphObjectManager.load(id)) } - @Transactional - override fun createWithDiscord( - guideUserData: GuideUserData, - discordUserInfo: DiscordUserInfoData - ): GuideUser { - val guideUser = GuideUser( - core = guideUserData, - discordUserInfo = discordUserInfo - ) - return graphObjectManager.save(guideUser) + @Transactional(readOnly = true) + override fun findWebUserById(id: String): Optional { + return Optional.ofNullable(graphObjectManager.load(id)) + } + + @Transactional(readOnly = true) + override fun findWebUserByWebUserId(webUserId: String): Optional { + val results = graphObjectManager.loadAll { + where { + webUser.id eq webUserId + } + } + return Optional.ofNullable(results.firstOrNull()) } @Transactional override fun createWithWebUser( guideUserData: GuideUserData, - webUserData: WebUserData + webUserData: WebUserData, + persona: PersonaData, ): GuideUser { val guideUser = GuideUser( core = guideUserData, - webUser = webUserData + webUser = webUserData, + persona = persona, ) return graphObjectManager.save(guideUser) } @@ -98,11 +114,11 @@ class GuideUserRepositoryDefaultImpl( } @Transactional - override fun updatePersona(guideUserId: String, persona: String) { + override fun updatePersona(guideUserId: String, persona: PersonaData) { val guideUser = findById(guideUserId).orElseThrow { IllegalArgumentException("GuideUser not found: $guideUserId") } - val updated = guideUser.copy(core = guideUser.core.copy(persona = persona)) + val updated = guideUser.copy(persona = persona) graphObjectManager.save(updated) } diff --git a/src/main/kotlin/com/embabel/guide/domain/GuideUserService.kt b/src/main/kotlin/com/embabel/guide/domain/GuideUserService.kt index 63f5390..4a2e8a5 100644 --- a/src/main/kotlin/com/embabel/guide/domain/GuideUserService.kt +++ b/src/main/kotlin/com/embabel/guide/domain/GuideUserService.kt @@ -6,11 +6,12 @@ import java.util.UUID @Service class GuideUserService( - private val guideUserRepository: GuideUserRepository + private val guideUserRepository: GuideUserRepository, + private val personaRepository: PersonaRepository, ) { companion object { - const val DEFAULT_PERSONA = "adaptive" + const val DEFAULT_PERSONA_NAME = "adaptive" } /** @@ -45,7 +46,7 @@ class GuideUserService( null ) - guideUserRepository.createWithWebUser(guideUser, anonymousWebUser) + guideUserRepository.createWithWebUser(guideUser, anonymousWebUser, resolveDefaultPersona()) } } @@ -59,6 +60,13 @@ class GuideUserService( return guideUserRepository.findById(id) } + /** + * Lightweight find by ID — skips USES_PERSONA traversal. + */ + fun findWebUserById(id: String): Optional { + return guideUserRepository.findWebUserById(id) + } + /** * Finds a GuideUser by their WebUser ID. * @@ -79,9 +87,9 @@ class GuideUserService( val guideUser = GuideUserData( id = UUID.randomUUID().toString(), displayName = webUser.displayName, - persona = DEFAULT_PERSONA, ) - return guideUserRepository.createWithWebUser(guideUser, webUser) + val defaultPersona = resolveDefaultPersona() + return guideUserRepository.createWithWebUser(guideUser, webUser, defaultPersona) } /** @@ -104,19 +112,36 @@ class GuideUserService( return guideUserRepository.findByWebUserEmail(userEmail) } + fun findByOAuthProvider(provider: String, providerUserId: String): Optional { + return guideUserRepository.findByOAuthProvider(provider, providerUserId) + } + + fun findByEmailVerificationToken(token: String): Optional { + return guideUserRepository.findByEmailVerificationToken(token) + } + /** * Updates the persona for a user. * - * @param userId the user's ID - * @param persona the persona name to set + * @param userId the user's ID + * @param personaId the persona ID to set * @return the updated GuideUser */ - fun updatePersona(userId: String, persona: String): GuideUser { - guideUserRepository.updatePersona(userId, persona) + fun updatePersona(userId: String, personaId: String): GuideUser { + val personaData = personaRepository.findById(personaId)?.persona + ?: throw IllegalArgumentException("Persona not found: $personaId") + guideUserRepository.updatePersona(userId, personaData) return guideUserRepository.findById(userId) .orElseThrow { IllegalArgumentException("User not found: $userId") } } + private fun resolveDefaultPersona(): PersonaData { + val view = personaRepository.findByNameAndOwner(DEFAULT_PERSONA_NAME, PersonaRepository.SYSTEM_OWNER_ID) + ?: personaRepository.findByOwner(PersonaRepository.SYSTEM_OWNER_ID).firstOrNull() + ?: error("No system personas found — has PersonaSeedingService run?") + return view.persona + } + /** * Saves a GuideUser. * diff --git a/src/main/kotlin/com/embabel/guide/domain/GuideWebUser.kt b/src/main/kotlin/com/embabel/guide/domain/GuideWebUser.kt new file mode 100644 index 0000000..6404928 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/domain/GuideWebUser.kt @@ -0,0 +1,35 @@ +package com.embabel.guide.domain + +import com.embabel.agent.api.identity.User +import org.drivine.annotation.Direction +import org.drivine.annotation.GraphRelationship +import org.drivine.annotation.GraphView +import org.drivine.annotation.Root + +/** + * Lightweight GraphView for GuideUser that skips the USES_PERSONA traversal. + * Use this when only the user's identity data is needed (core + webUser). + */ +@GraphView +data class GuideWebUser( + @Root + val core: GuideUserData, + + @GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING) + val webUser: WebUserData? = null, +) : User, HasGuideUserData { + + override fun guideUserData(): GuideUserData = core + + override val id: String + get() = webUser?.id ?: core.id + + override val displayName: String + get() = webUser?.displayName ?: core.displayName + + override val username: String + get() = webUser?.userName ?: core.username + + override val email: String? + get() = webUser?.userEmail +} diff --git a/src/main/kotlin/com/embabel/guide/domain/OAuthProviderData.kt b/src/main/kotlin/com/embabel/guide/domain/OAuthProviderData.kt new file mode 100644 index 0000000..b1a88e7 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/domain/OAuthProviderData.kt @@ -0,0 +1,21 @@ +package com.embabel.guide.domain + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import org.drivine.annotation.NodeFragment +import org.drivine.annotation.NodeId + +/** + * Node fragment representing a linked OAuth provider identity (e.g. Google, GitHub). + * A GuideUser may have zero or more of these via HAS_OAUTH_PROVIDER relationships. + */ +@NodeFragment(labels = ["OAuthProvider"]) +@JsonIgnoreProperties(ignoreUnknown = true) +data class OAuthProviderData( + @NodeId + var id: String, + var provider: String, + var providerUserId: String, + var email: String?, + var displayName: String?, + var avatarUrl: String?, +) diff --git a/src/main/kotlin/com/embabel/guide/domain/PersonaRepository.kt b/src/main/kotlin/com/embabel/guide/domain/PersonaRepository.kt index 046c540..282d307 100644 --- a/src/main/kotlin/com/embabel/guide/domain/PersonaRepository.kt +++ b/src/main/kotlin/com/embabel/guide/domain/PersonaRepository.kt @@ -17,6 +17,7 @@ interface PersonaRepository { */ fun findForUser(userId: String): List + fun findByNameAndOwner(name: String, ownerId: String): PersonaView? fun existsByNameAndOwner(name: String, ownerId: String): Boolean fun save(persona: PersonaView): PersonaView fun delete(personaId: String) diff --git a/src/main/kotlin/com/embabel/guide/domain/PersonaRepositoryImpl.kt b/src/main/kotlin/com/embabel/guide/domain/PersonaRepositoryImpl.kt index 9a167f5..fe5f6b7 100644 --- a/src/main/kotlin/com/embabel/guide/domain/PersonaRepositoryImpl.kt +++ b/src/main/kotlin/com/embabel/guide/domain/PersonaRepositoryImpl.kt @@ -44,13 +44,17 @@ class PersonaRepositoryImpl( } @Transactional(readOnly = true) - override fun existsByNameAndOwner(name: String, ownerId: String): Boolean = + override fun findByNameAndOwner(name: String, ownerId: String): PersonaView? = graphObjectManager.loadAll { where { persona.name eq name owner.id eq ownerId } - }.isNotEmpty() + }.firstOrNull() + + @Transactional(readOnly = true) + override fun existsByNameAndOwner(name: String, ownerId: String): Boolean = + findByNameAndOwner(name, ownerId) != null @Transactional override fun save(persona: PersonaView): PersonaView = diff --git a/src/main/kotlin/com/embabel/guide/domain/WebUserData.kt b/src/main/kotlin/com/embabel/guide/domain/WebUserData.kt index 6264e39..036f3d9 100644 --- a/src/main/kotlin/com/embabel/guide/domain/WebUserData.kt +++ b/src/main/kotlin/com/embabel/guide/domain/WebUserData.kt @@ -16,7 +16,10 @@ open class WebUserData( var userName: String, var userEmail: String?, var passwordHash: String?, - var refreshToken: String? + var refreshToken: String?, + var emailVerified: Boolean = false, + var emailVerificationToken: String? = null, + var emailVerificationExpiry: java.time.Instant? = null, ) { override fun toString(): String = "WebUserData{userId='$id', userDisplayName='$displayName', userUsername='$userName'}" diff --git a/src/main/kotlin/com/embabel/guide/rag/FallbackContentFetcher.kt b/src/main/kotlin/com/embabel/guide/rag/FallbackContentFetcher.kt new file mode 100644 index 0000000..e8986ad --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/rag/FallbackContentFetcher.kt @@ -0,0 +1,26 @@ +package com.embabel.guide.rag + +import com.embabel.agent.rag.ingestion.ContentFetcher +import com.embabel.agent.rag.ingestion.FetchResult +import org.slf4j.LoggerFactory +import java.net.URI + +/** + * Tries [primary] first; on any exception falls back to [fallback]. + * Useful for sites with unreliable/intermittent blocking: prefer a cheap path + * (e.g. RSS) and only pay the fallback cost (e.g. direct HTTP) when needed. + */ +class FallbackContentFetcher( + private val primary: ContentFetcher, + private val fallback: ContentFetcher, +) : ContentFetcher { + + private val logger = LoggerFactory.getLogger(javaClass) + + override fun fetch(uri: URI): FetchResult = try { + primary.fetch(uri) + } catch (e: Exception) { + logger.info("Primary fetch failed for {} ({}); trying fallback", uri, e.message) + fallback.fetch(uri) + } +} \ No newline at end of file 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/rag/VersionChunkTransformer.kt b/src/main/kotlin/com/embabel/guide/rag/VersionChunkTransformer.kt new file mode 100644 index 0000000..5d07256 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/rag/VersionChunkTransformer.kt @@ -0,0 +1,38 @@ +package com.embabel.guide.rag + +import com.embabel.agent.rag.ingestion.AbstractChunkTransformer +import com.embabel.agent.rag.ingestion.ChunkTransformationContext +import com.embabel.agent.rag.model.Chunk +import com.embabel.guide.GuideProperties + +/** + * Stamps chunks with a `version` metadata property based on the source document URI. + * + * - Versioned docs (URI starts with [ContentConfig.versioned.baseUrl]): version extracted from URI path. + * - Supplementary docs: stamped with "supplementary". + * + * This enables version-aware search filtering via [PropertyFilter]. + */ +class VersionChunkTransformer( + private val guideProperties: GuideProperties, +) : AbstractChunkTransformer() { + + override fun additionalMetadata(chunk: Chunk, context: ChunkTransformationContext): Map { + val uri = context.document?.uri ?: return mapOf("version" to SUPPLEMENTARY) + val baseUrl = guideProperties.content.versioned.baseUrl + + if (uri.startsWith(baseUrl)) { + val afterBase = uri.removePrefix(baseUrl).trimEnd('/') + val version = afterBase.split("/").firstOrNull() + if (!version.isNullOrBlank()) { + return mapOf("version" to version) + } + } + + return mapOf("version" to SUPPLEMENTARY) + } + + companion object { + const val SUPPLEMENTARY = "supplementary" + } +} 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..67a0454 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/spdd/SpddEntityDictionary.kt @@ -0,0 +1,26 @@ +package com.embabel.guide.spdd + +import com.embabel.agent.core.DataDictionary +import com.embabel.agent.core.DynamicType + +/** + * Minimal SDLC-SPDD domain schema for leg 3 entity projection (SPIKE-001). + * Uses [DynamicType] so we can spike without a separate Kotlin NamedEntity module. + */ +object SpddEntityDictionary { + + private val domainTypes = listOf( + DynamicType("WorkId", "A unit of SPDD work (FEAT-, SPIKE-, BUG-, REF-)", emptyList(), emptyList(), true), + DynamicType("Canvas", "REASONS canvas for a Work ID", emptyList(), emptyList(), true), + DynamicType("Area", "Code area bucket or Java package", emptyList(), emptyList(), true), + DynamicType("Operation", "Canvas operation (T01, T02, …)", emptyList(), emptyList(), true), + DynamicType("Decision", "Recorded architecture decision", emptyList(), emptyList(), true), + DynamicType("Pitfall", "Known pitfall", emptyList(), emptyList(), true), + DynamicType("Pattern", "Reusable pattern", emptyList(), emptyList(), true), + ) + + /** Labels of the SPDD domain schema; used to validate retrieve-side label parameters. */ + val knownLabels: Set = domainTypes.map { it.name }.toSet() + + fun create(): DataDictionary = DataDictionary.fromDomainTypes("sdlc-spdd", domainTypes) +} 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/stats/GuideStats.kt b/src/main/kotlin/com/embabel/guide/stats/GuideStats.kt new file mode 100644 index 0000000..8ee7828 --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/stats/GuideStats.kt @@ -0,0 +1,48 @@ +package com.embabel.guide.stats + +import com.embabel.agent.rag.store.ContentElementRepositoryInfo +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonUnwrapped + +/** + * Public stats payload for `GET /api/v1/data/stats`. + * + * The content counts ([content]) are unwrapped to the top level so the JSON is byte-for-byte + * identical to what the endpoint returned before (it serialized the same object directly) — the + * front-end keeps reading `chunkCount` etc. unchanged. + * + * [popularPages]/[unpopularPages] are public — they rank the guide's own pages by reader feedback, + * which is doc-quality signal rather than anything user-sensitive, so everyone sees them. + * + * The remaining fields are admin-only and are populated solely when the caller has the ADMIN role. + * With [JsonInclude.Include.NON_NULL] they are omitted entirely for everyone else, so the default + * (non-admin) response is exactly the legacy payload plus the page rankings — no user/message + * figures leak to the public. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +data class GuideStats( + @get:JsonUnwrapped val content: ContentElementRepositoryInfo, + val popularPages: List? = null, + val unpopularPages: List? = null, + val userCount: Long? = null, + val onlineCount: Int? = null, + val messageCount: Long? = null, + val topUsersThisWeek: List? = null, +) + +/** A user ranked by messages sent in the trailing window. Admin-only. */ +data class TopMessagingUser( + val displayName: String, + val messageCount: Long, +) + +/** + * A page ranked by reader feedback. [likes]/[dislikes] are raw helpful/not-helpful counts (shown so + * readers can judge sample size). A page is eligible only once it has enough total votes, and is + * "popular" when likes outweigh dislikes, "unpopular" when they outweigh likes. + */ +data class PageRating( + val page: String, + val likes: Long, + val dislikes: Long, +) \ No newline at end of file diff --git a/src/main/kotlin/com/embabel/guide/stats/GuideStatsService.kt b/src/main/kotlin/com/embabel/guide/stats/GuideStatsService.kt new file mode 100644 index 0000000..6867bef --- /dev/null +++ b/src/main/kotlin/com/embabel/guide/stats/GuideStatsService.kt @@ -0,0 +1,129 @@ +package com.embabel.guide.stats + +import com.embabel.chat.store.model.MessageData +import com.embabel.guide.chat.service.PresenceService +import com.embabel.guide.domain.GuideUserCache +import com.embabel.guide.domain.GuideUserData +import com.embabel.guide.domain.GuideUserRepository +import com.embabel.guide.rag.DataManager +import org.drivine.manager.GraphObjectManager +import org.drivine.manager.PersistenceManager +import org.drivine.query.QuerySpecification +import org.drivine.query.transform +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Duration +import java.time.Instant + +/** + * Assembles the `GET /api/v1/data/stats` payload. Everyone gets the content counts; the + * people/message figures are added only when the caller holds the ADMIN role. + * + * Admin status is read from the caller's `roles` (granted out-of-band via Cypher). The lookup + * reuses [GuideUserCache]; its TTL is what makes an out-of-band grant/revocation take effect. + * + * Totals use single-label fragment counts. `topUsersThisWeek` is a time-filtered, two-hop, + * group-by ranking — not expressible as a node-rooted view's @Count/@Aggregate — so it uses + * [PersistenceManager] + `.transform()`, per Drivine's own guidance. + */ +@Service +class GuideStatsService( + private val dataManager: DataManager, + private val presenceService: PresenceService, + private val guideUserCache: GuideUserCache, + private val guideUserRepository: GuideUserRepository, + @param:Qualifier("neoGraphObjectManager") private val graphObjectManager: GraphObjectManager, + @param:Qualifier("neo") private val persistenceManager: PersistenceManager, +) { + + /** + * @param webUserId the authenticated caller (JWT subject), or null for anonymous/unauthenticated. + */ + @Transactional(readOnly = true) + fun stats(webUserId: String?): GuideStats { + val content = dataManager.stats + // Page rankings are public: split the eligible pages so the weightier side decides the list + // (ties land in neither). Both lists go to everyone, admin or not. + val ratings = pageRatings() + val popularPages = ratings.filter { it.likes > it.dislikes } + .sortedByDescending { it.likes }.take(TOP_PAGES_LIMIT) + val unpopularPages = ratings.filter { it.dislikes > it.likes } + .sortedByDescending { it.dislikes }.take(TOP_PAGES_LIMIT) + if (!isAdmin(webUserId)) { + return GuideStats(content = content, popularPages = popularPages, unpopularPages = unpopularPages) + } + return GuideStats( + content = content, + popularPages = popularPages, + unpopularPages = unpopularPages, + userCount = graphObjectManager.count(GuideUserData::class.java), + onlineCount = presenceService.onlineUsers().size, + messageCount = graphObjectManager.count(MessageData::class.java), + topUsersThisWeek = topMessagingUsers(since = Instant.now().minus(Duration.ofDays(7))), + ) + } + + private fun isAdmin(webUserId: String?): Boolean { + if (webUserId.isNullOrBlank()) return false + val user = guideUserCache.get(webUserId) + ?: guideUserRepository.findByWebUserId(webUserId).orElse(null) + ?.also { guideUserCache.put(webUserId, it) } + ?: return false + return ADMIN_ROLE in user.core.roles + } + + /** Top users by USER-role messages sent in their sessions since [since]. */ + private fun topMessagingUsers(since: Instant): List = + persistenceManager.query( + QuerySpecification + .withStatement( + """ + MATCH (u:GuideUser)<-[:OWNED_BY]-(:ChatSession)-[:HAS_MESSAGE]->(m:StoredMessage) + // datetime() wrap tolerates legacy rows where createdAt was persisted as an ISO + // STRING (drivine <= 0.0.44). drivine 0.0.45 writes native temporals, and the + // 06-migrate-string-dates.cypher seed converts old rows — once that has run in all + // environments this wrap is redundant (idempotent on a native datetime) and may be + // dropped, ideally alongside a range index on StoredMessage.createdAt. + WHERE m.role = 'USER' AND datetime(m.createdAt) >= ${'$'}since + WITH u, count(m) AS messageCount + ORDER BY messageCount DESC + LIMIT $TOP_USERS_LIMIT + // Single-map projection: .transform() maps one map/scalar per row, not multiple columns. + RETURN { displayName: coalesce(u.username, u.id), messageCount: messageCount } AS user + """.trimIndent() + ) + .bind(mapOf("since" to since)) // Instant -> ZonedDateTime, matching how createdAt is stored + .transform() + ) + + /** + * Every page with at least [FEEDBACK_MIN_VOTES] total votes, with its like/dislike tallies. + * Filtering by total volume here keeps a page with a single vote off the rankings; the caller + * decides which side a page falls on and how many to show. + */ + private fun pageRatings(): List = + persistenceManager.query( + QuerySpecification + .withStatement( + """ + MATCH (f:Feedback) + WITH f.page AS page, + sum(CASE WHEN f.helpful THEN 1 ELSE 0 END) AS likes, + sum(CASE WHEN f.helpful THEN 0 ELSE 1 END) AS dislikes + WHERE likes + dislikes >= ${'$'}minVotes + // Single-map projection: .transform() maps one map/scalar per row, not multiple columns. + RETURN { page: page, likes: likes, dislikes: dislikes } AS row + """.trimIndent() + ) + .bind(mapOf("minVotes" to FEEDBACK_MIN_VOTES)) + .transform() + ) + + companion object { + const val ADMIN_ROLE = "ADMIN" + const val TOP_USERS_LIMIT = 5 + const val TOP_PAGES_LIMIT = 5 + const val FEEDBACK_MIN_VOTES = 5 + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/embabel/guide/util/DiscordUser_GuideMapping.kt b/src/main/kotlin/com/embabel/guide/util/DiscordUser_GuideMapping.kt deleted file mode 100644 index d7b60a9..0000000 --- a/src/main/kotlin/com/embabel/guide/util/DiscordUser_GuideMapping.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.embabel.guide.util - -import com.embabel.agent.discord.DiscordUser -import com.embabel.guide.domain.DiscordUserInfoData -import com.embabel.guide.domain.GuideUserData -import java.util.UUID - -fun DiscordUser.toGuideUserData(): GuideUserData = GuideUserData( - UUID.randomUUID().toString(), - discordUser.displayName, - discordUser.username, - null, // email - null, // persona - null, // customPrompt -) - -fun DiscordUser.toDiscordUserInfoData(): DiscordUserInfoData = DiscordUserInfoData( - discordUser.id, - discordUser.username, - discordUser.discriminator, - discordUser.displayName, - discordUser.isBot, - discordUser.avatarUrl, -) diff --git a/src/main/kotlin/com/embabel/hub/ChangePasswordRequest.kt b/src/main/kotlin/com/embabel/hub/ChangePasswordRequest.kt index 6f7e3b9..c14fd13 100644 --- a/src/main/kotlin/com/embabel/hub/ChangePasswordRequest.kt +++ b/src/main/kotlin/com/embabel/hub/ChangePasswordRequest.kt @@ -1,14 +1,15 @@ package com.embabel.hub /** - * Request data for changing a user's password. + * Request data for setting or changing a user's password. * - * @property currentPassword The user's current password (for verification) + * @property currentPassword The user's current password (for verification). Null/absent when the + * account has no password yet — i.e. an OAuth-only user setting one for the first time. * @property newPassword The new password * @property newPasswordConfirmation The new password confirmation (must match newPassword) */ data class ChangePasswordRequest( - val currentPassword: String, + val currentPassword: String? = null, val newPassword: String, val newPasswordConfirmation: String ) \ No newline at end of file diff --git a/src/main/kotlin/com/embabel/hub/HubApiController.kt b/src/main/kotlin/com/embabel/hub/HubApiController.kt index d6eeb08..fa7f833 100644 --- a/src/main/kotlin/com/embabel/hub/HubApiController.kt +++ b/src/main/kotlin/com/embabel/hub/HubApiController.kt @@ -2,13 +2,18 @@ package com.embabel.hub import com.embabel.guide.chat.model.DeliveredMessage import com.embabel.guide.chat.service.ChatSessionService +import com.embabel.guide.domain.FeedbackData +import com.embabel.guide.domain.FeedbackRepository +import com.embabel.guide.domain.FeedbackView import com.embabel.guide.domain.GuideUser import com.embabel.guide.domain.GuideUserService import io.jsonwebtoken.JwtException import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.core.Authentication import java.time.Instant +import java.util.UUID import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -26,7 +31,8 @@ class HubApiController( private val personaService: PersonaService, private val guideUserService: GuideUserService, private val chatSessionService: ChatSessionService, - private val jwtTokenService: JwtTokenService + private val jwtTokenService: JwtTokenService, + private val feedbackRepository: FeedbackRepository, ) { private val logger = LoggerFactory.getLogger(HubApiController::class.java) @@ -174,6 +180,35 @@ class HubApiController( return chatSession.messages.map { DeliveredMessage.createFrom(it, sessionId, chatSession.session.title) } } + data class FeedbackRequest( + val page: String, + val helpful: Boolean, + val comment: String? = null, + ) + + @PostMapping("/feedback") + fun submitFeedback( + @RequestBody request: FeedbackRequest, + authentication: Authentication?, + ): ResponseEntity { + val userId = authentication?.principal as? String + val guideUser = if (userId != null) { + guideUserService.findByWebUserId(userId).orElse(null) + } else { + guideUserService.findOrCreateAnonymousWebUser() + } + + val feedbackData = FeedbackData( + id = UUID.randomUUID().toString(), + page = request.page, + helpful = request.helpful, + comment = request.comment, + userId = userId, + ) + feedbackRepository.save(FeedbackView(feedback = feedbackData, user = guideUser?.core)) + return ResponseEntity.status(HttpStatus.CREATED).build() + } + /** * Gets the GuideUser for authenticated users only. * Returns null if unauthenticated or user not found. diff --git a/src/main/kotlin/com/embabel/hub/HubExceptionHandler.kt b/src/main/kotlin/com/embabel/hub/HubExceptionHandler.kt index 56b8042..51a2576 100644 --- a/src/main/kotlin/com/embabel/hub/HubExceptionHandler.kt +++ b/src/main/kotlin/com/embabel/hub/HubExceptionHandler.kt @@ -1,5 +1,6 @@ package com.embabel.hub +import com.embabel.hub.oauth.OAuthException import jakarta.servlet.http.HttpServletRequest import org.drivine.DrivineException import org.springframework.core.annotation.Order @@ -38,6 +39,10 @@ class HubExceptionHandler { fun handleForbiddenException(ex: ForbiddenException, request: HttpServletRequest) = buildResponse(HttpStatus.FORBIDDEN, ex.message, request) + @ExceptionHandler(OAuthException::class) + fun handleOAuthException(ex: OAuthException, request: HttpServletRequest) = + buildResponse(HttpStatus.BAD_REQUEST, ex.message, request) + @ExceptionHandler(IllegalArgumentException::class) fun handleIllegalArgumentException(ex: IllegalArgumentException, request: HttpServletRequest) = buildResponse(HttpStatus.BAD_REQUEST, ex.message, request) diff --git a/src/main/kotlin/com/embabel/hub/HubService.kt b/src/main/kotlin/com/embabel/hub/HubService.kt index 6754b89..8bbb271 100644 --- a/src/main/kotlin/com/embabel/hub/HubService.kt +++ b/src/main/kotlin/com/embabel/hub/HubService.kt @@ -6,6 +6,7 @@ import com.embabel.guide.domain.GuideUserCache import com.embabel.guide.domain.GuideUserService import com.embabel.guide.domain.WebUserData import com.embabel.chat.store.util.UUIDv7 +import com.embabel.hub.email.EmailService import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.stereotype.Service @@ -16,6 +17,7 @@ class HubService( private val welcomeGreeter: WelcomeGreeter, private val chatSessionService: ChatSessionService, private val guideUserCache: GuideUserCache, + private val emailService: EmailService, ) { private val passwordEncoder = BCryptPasswordEncoder() @@ -79,7 +81,12 @@ class HubService( ) // Save the user through GuideUserService - return guideUserService.saveFromWebUser(webUser) + val savedUser = guideUserService.saveFromWebUser(webUser) + + // Send verification email (non-blocking, failure doesn't prevent registration) + emailService.sendVerificationEmail(webUser.id, request.userEmail) + + return savedUser } /** @@ -107,7 +114,13 @@ class HubService( val webUser = guideUser.webUser ?: throw LoginException("Invalid username or password") - if (!passwordEncoder.matches(request.password, webUser.passwordHash)) { + // OAuth-only accounts have no password and must not be reachable via password login — + // reject explicitly rather than relying on the encoder returning false for a null hash. + // Generic message to avoid revealing that the account exists / is OAuth-only. + val passwordHash = webUser.passwordHash + ?: throw LoginException("Invalid username or password") + + if (!passwordEncoder.matches(request.password, passwordHash)) { throw LoginException("Invalid username or password") } @@ -125,7 +138,9 @@ class HubService( username = webUser.userName, displayName = webUser.displayName, email = webUser.userEmail ?: "", - persona = guideUser.core.persona + persona = guideUser.persona.id, + emailVerified = webUser.emailVerified, + hasPassword = webUser.passwordHash != null, ) } @@ -146,19 +161,18 @@ class HubService( } /** - * Changes the password for a user. + * Sets or changes the password for a user. * - * Validates the current password, ensures the new password meets requirements, - * and updates the password hash. + * For an account that already has a password, the current password is verified and the new + * password must differ. For an OAuth-only account (no password yet), this sets the first + * password — no current password is required, since the active authenticated session is the + * authorization. * * @param userId the user's ID (from authentication) * @param request the change password request * @throws ChangePasswordException if validation fails */ fun changePassword(userId: String, request: ChangePasswordRequest) { - if (request.currentPassword.isBlank()) { - throw ChangePasswordException("Current password is required") - } if (request.newPassword.isBlank()) { throw ChangePasswordException("New password is required") } @@ -177,21 +191,24 @@ class HubService( val webUser = guideUser.webUser ?: throw ChangePasswordException("User not found") - if (!passwordEncoder.matches(request.currentPassword, webUser.passwordHash)) { - throw ChangePasswordException("Current password is incorrect") - } - - // Check that new password is different from current - if (passwordEncoder.matches(request.newPassword, webUser.passwordHash)) { - throw ChangePasswordException("New password must be different from current password") - } - - val newPasswordHash = passwordEncoder.encode(request.newPassword) - webUser.passwordHash = newPasswordHash - - // TODO: We need a method to update the WebUserData in the repository - // For now, this is a limitation we'll address - throw ChangePasswordException("Password change not yet implemented with new data model") + // Changing an existing password requires verifying the current one; setting the first + // password (OAuth-only account) does not. + if (webUser.passwordHash != null) { + val currentPassword = request.currentPassword + if (currentPassword.isNullOrBlank()) { + throw ChangePasswordException("Current password is required") + } + if (!passwordEncoder.matches(currentPassword, webUser.passwordHash)) { + throw ChangePasswordException("Current password is incorrect") + } + if (passwordEncoder.matches(request.newPassword, webUser.passwordHash)) { + throw ChangePasswordException("New password must be different from current password") + } + } + + webUser.passwordHash = passwordEncoder.encode(request.newPassword) + guideUserService.saveUser(guideUser) + guideUserCache.invalidate(userId) } } diff --git a/src/main/kotlin/com/embabel/hub/JwtAuthenticationFilter.kt b/src/main/kotlin/com/embabel/hub/JwtAuthenticationFilter.kt index bc05901..65d1499 100644 --- a/src/main/kotlin/com/embabel/hub/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/embabel/hub/JwtAuthenticationFilter.kt @@ -27,6 +27,16 @@ class JwtAuthenticationFilter( private val objectMapper: ObjectMapper ) : OncePerRequestFilter() { + override fun shouldNotFilter(request: HttpServletRequest): Boolean { + // SockJS/WebSocket paths handle JWT validation themselves in + // AnonymousPrincipalHandshakeHandler (token in query param or header). + // Running the filter here would 401 the /ws/info poll on an expired + // token and break the SockJS handshake before it can fall back to + // anonymous. + val uri = request.requestURI + return uri == "/ws" || uri.startsWith("/ws/") + } + override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, diff --git a/src/main/kotlin/com/embabel/hub/LoginResponse.kt b/src/main/kotlin/com/embabel/hub/LoginResponse.kt index 01cb5e8..d1349f6 100644 --- a/src/main/kotlin/com/embabel/hub/LoginResponse.kt +++ b/src/main/kotlin/com/embabel/hub/LoginResponse.kt @@ -11,7 +11,8 @@ import java.time.Instant * @property username The username * @property displayName The user's display name * @property email The user's email address - * @property persona The user's selected persona (optional) + * @property persona The user's selected persona ID + * @property hasPassword Whether the account has a password set (false for OAuth-only users) */ data class LoginResponse( val token: String, @@ -20,5 +21,7 @@ data class LoginResponse( val username: String, val displayName: String, val email: String, - val persona: String? + val persona: String, + val emailVerified: Boolean = false, + val hasPassword: Boolean = false, ) \ No newline at end of file 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/kotlin/com/embabel/hub/PersonaService.kt b/src/main/kotlin/com/embabel/hub/PersonaService.kt index 38ef7b1..c35dc2f 100644 --- a/src/main/kotlin/com/embabel/hub/PersonaService.kt +++ b/src/main/kotlin/com/embabel/hub/PersonaService.kt @@ -2,6 +2,7 @@ package com.embabel.hub import com.embabel.guide.domain.GuideUserCache import com.embabel.guide.domain.GuideUserRepository +import com.embabel.guide.domain.GuideUserService import com.embabel.guide.domain.PersonaRepository import com.embabel.guide.domain.PersonaRepository.Companion.SYSTEM_OWNER_ID import com.embabel.guide.domain.PersonaView @@ -38,14 +39,23 @@ class PersonaService( /** * Looks up the prompt text for a persona by ID. Cache-first. + * + * If the persona can't be resolved — e.g. it points at a retired persona that has since been + * deleted (see the `07-retire-personas` migration) — falls back to the system "adaptive" + * persona so chat doesn't break. */ fun findPrompt(personaId: String): String? { personaPromptCache.get(personaId)?.let { return it } - val prompt = personaRepository.findById(personaId)?.persona?.prompt ?: return null + val prompt = personaRepository.findById(personaId)?.persona?.prompt + ?: adaptivePrompt() + ?: return null personaPromptCache.put(personaId, prompt) return prompt } + private fun adaptivePrompt(): String? = + personaRepository.findByNameAndOwner(GuideUserService.DEFAULT_PERSONA_NAME, SYSTEM_OWNER_ID)?.persona?.prompt + /** * Resolves a web user ID to the internal GuideUser ID. * Cache-first to avoid DB round trips on hot paths. @@ -53,7 +63,7 @@ class PersonaService( private fun resolveInternalId(webUserId: String?): String { if (webUserId == null) return SYSTEM_OWNER_ID guideUserCache.get(webUserId)?.let { return it.core.id } - return guideUserRepository.findByWebUserId(webUserId) + return guideUserRepository.findWebUserByWebUserId(webUserId) .map { it.core.id } .orElse(SYSTEM_OWNER_ID) } @@ -71,7 +81,7 @@ class PersonaService( throw IllegalArgumentException(result.reason) } val personaData = (result as PersonaIngestionService.IngestionResult.Success).personaData - val ownerData = guideUserRepository.findByWebUserId(userId) + val ownerData = guideUserRepository.findWebUserByWebUserId(userId) .orElseThrow { NotFoundException("User not found: $userId") } .core val saved = personaRepository.save(PersonaView(persona = personaData, owner = ownerData)) @@ -92,7 +102,7 @@ class PersonaService( name = copyName, isSystem = false, ) - val ownerData = guideUserRepository.findByWebUserId(userId) + val ownerData = guideUserRepository.findWebUserByWebUserId(userId) .orElseThrow { NotFoundException("User not found: $userId") } .core val saved = personaRepository.save(PersonaView(persona = copy, owner = ownerData)) diff --git a/src/main/kotlin/com/embabel/hub/email/EmailController.kt b/src/main/kotlin/com/embabel/hub/email/EmailController.kt new file mode 100644 index 0000000..c4349cd --- /dev/null +++ b/src/main/kotlin/com/embabel/hub/email/EmailController.kt @@ -0,0 +1,29 @@ +package com.embabel.hub.email + +import org.springframework.http.ResponseEntity +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/hub/email") +class EmailController( + private val emailService: EmailService, +) { + + @GetMapping("/verify") + fun verifyEmail(@RequestParam token: String): ResponseEntity> { + val success = emailService.verifyEmail(token) + return ResponseEntity.ok(mapOf("verified" to success)) + } + + @PostMapping("/resend-verification") + fun resendVerification(authentication: Authentication): ResponseEntity { + val userId = authentication.principal as String + emailService.resendVerification(userId) + return ResponseEntity.ok().build() + } +} diff --git a/src/main/kotlin/com/embabel/hub/email/EmailService.kt b/src/main/kotlin/com/embabel/hub/email/EmailService.kt new file mode 100644 index 0000000..905f0bd --- /dev/null +++ b/src/main/kotlin/com/embabel/hub/email/EmailService.kt @@ -0,0 +1,123 @@ +package com.embabel.hub.email + +import com.embabel.guide.domain.GuideUserService +import com.sendgrid.Method +import com.sendgrid.Request +import com.sendgrid.SendGrid +import com.sendgrid.helpers.mail.Mail +import com.sendgrid.helpers.mail.objects.Content +import com.sendgrid.helpers.mail.objects.Email +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.UUID + +@Service +class EmailService( + private val guideUserService: GuideUserService, + @Value("\${guide.email.sendgrid-api-key:}") private val sendGridApiKey: String, + @Value("\${guide.email.from-email:noreply@embabel.com}") private val fromEmail: String, + @Value("\${guide.email.frontend-url:http://localhost:3000}") private val frontendUrl: String, +) { + + private val logger = LoggerFactory.getLogger(EmailService::class.java) + + fun sendVerificationEmail(webUserId: String, email: String?) { + if (email.isNullOrBlank()) return + if (sendGridApiKey.isBlank()) { + logger.warn("SendGrid API key not configured — skipping verification email for {}", webUserId) + return + } + + val token = UUID.randomUUID().toString() + val expiry = Instant.now().plus(24, ChronoUnit.HOURS) + + // Store verification token on the user + val guideUser = guideUserService.findByWebUserId(webUserId).orElse(null) ?: return + val webUser = guideUser.webUser ?: return + webUser.emailVerificationToken = token + webUser.emailVerificationExpiry = expiry + guideUserService.saveUser(guideUser) + + // Send email via SendGrid + val verifyUrl = "$frontendUrl/auth/verify-email?token=$token" + val subject = "Verify your Embabel email" + val body = """ + + +

Welcome to Embabel!

+

Click the button below to verify your email address.

+

+ + Verify Email + +

+

+ Or copy this link: $verifyUrl +

+

This link expires in 24 hours.

+ + + """.trimIndent() + + sendEmail(email, subject, body) + } + + fun verifyEmail(token: String): Boolean { + val guideUser = guideUserService.findByEmailVerificationToken(token).orElse(null) + ?: return false + val webUser = guideUser.webUser ?: return false + + val expiry = webUser.emailVerificationExpiry + if (expiry != null && Instant.now().isAfter(expiry)) { + logger.info("Verification token expired for user {}", webUser.id) + return false + } + + webUser.emailVerified = true + webUser.emailVerificationToken = null + webUser.emailVerificationExpiry = null + guideUserService.saveUser(guideUser) + logger.info("Email verified for user {}", webUser.id) + return true + } + + fun resendVerification(webUserId: String) { + val guideUser = guideUserService.findByWebUserId(webUserId).orElseThrow { + IllegalArgumentException("User not found: $webUserId") + } + val webUser = guideUser.webUser + ?: throw IllegalArgumentException("Not a web user: $webUserId") + + if (webUser.emailVerified) return + + sendVerificationEmail(webUserId, webUser.userEmail) + } + + private fun sendEmail(to: String, subject: String, htmlBody: String) { + try { + val from = Email(fromEmail, "Embabel") + val toEmail = Email(to) + val content = Content("text/html", htmlBody) + val mail = Mail(from, subject, toEmail, content) + + val sg = SendGrid(sendGridApiKey) + val request = Request().apply { + method = Method.POST + endpoint = "mail/send" + body = mail.build() + } + val response = sg.api(request) + if (response.statusCode in 200..299) { + logger.info("Verification email sent to {}", to) + } else { + logger.error("SendGrid returned {} for {}: {}", response.statusCode, to, response.body) + } + } catch (e: Exception) { + logger.error("Failed to send verification email to {}: {}", to, e.message, e) + } + } +} diff --git a/src/main/kotlin/com/embabel/hub/integrations/UserModelFactory.kt b/src/main/kotlin/com/embabel/hub/integrations/UserModelFactory.kt index 31ac7d8..38180b3 100644 --- a/src/main/kotlin/com/embabel/hub/integrations/UserModelFactory.kt +++ b/src/main/kotlin/com/embabel/hub/integrations/UserModelFactory.kt @@ -3,9 +3,9 @@ package com.embabel.hub.integrations import com.embabel.agent.api.models.DeepSeekModels import com.embabel.agent.api.models.MistralAiModels import com.embabel.agent.api.models.OpenAiModels -import com.embabel.agent.config.models.anthropic.AnthropicModelFactory +import com.embabel.agent.anthropic.AnthropicModelFactory import com.embabel.agent.openai.OpenAiCompatibleModelFactory -import com.embabel.agent.spi.InvalidApiKeyException +import com.embabel.common.byok.InvalidApiKeyException import com.embabel.agent.spi.LlmService import com.embabel.common.ai.model.PricingModel import org.slf4j.LoggerFactory diff --git a/src/main/kotlin/com/embabel/hub/oauth/GitHubOAuthService.kt b/src/main/kotlin/com/embabel/hub/oauth/GitHubOAuthService.kt new file mode 100644 index 0000000..a95b72d --- /dev/null +++ b/src/main/kotlin/com/embabel/hub/oauth/GitHubOAuthService.kt @@ -0,0 +1,88 @@ +package com.embabel.hub.oauth + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient +import java.net.URLEncoder + +@Service +class GitHubOAuthService( + @Value("\${guide.oauth.github.client-id:}") private val clientId: String, + @Value("\${guide.oauth.github.client-secret:}") private val clientSecret: String, + @Value("\${guide.oauth.github.redirect-uri:}") private val redirectUri: String, +) { + + private val logger = LoggerFactory.getLogger(GitHubOAuthService::class.java) + private val restClient = RestClient.create() + + val isConfigured: Boolean get() = clientId.isNotBlank() && clientSecret.isNotBlank() + + fun getAuthorizationUrl(state: String): String { + val params = mapOf( + "client_id" to clientId, + "redirect_uri" to redirectUri, + "scope" to "user:email", + "state" to state, + ) + val query = params.entries.joinToString("&") { (k, v) -> + "$k=${URLEncoder.encode(v, Charsets.UTF_8)}" + } + return "https://github.com/login/oauth/authorize?$query" + } + + @Suppress("UNCHECKED_CAST") + fun exchangeCodeForUserInfo(code: String): OAuthUserInfo { + // Exchange authorization code for access token + val tokenResponse = restClient.post() + .uri("https://github.com/login/oauth/access_token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .accept(MediaType.APPLICATION_JSON) + .body( + "code=${URLEncoder.encode(code, Charsets.UTF_8)}" + + "&client_id=${URLEncoder.encode(clientId, Charsets.UTF_8)}" + + "&client_secret=${URLEncoder.encode(clientSecret, Charsets.UTF_8)}" + + "&redirect_uri=${URLEncoder.encode(redirectUri, Charsets.UTF_8)}" + ) + .retrieve() + .body(Map::class.java) ?: throw OAuthException("Failed to exchange code with GitHub") + + val accessToken = tokenResponse["access_token"] as? String + ?: throw OAuthException("No access_token in GitHub response: ${tokenResponse["error_description"] ?: tokenResponse["error"]}") + + // Fetch user profile + val profile = restClient.get() + .uri("https://api.github.com/user") + .header("Authorization", "Bearer $accessToken") + .header("Accept", "application/vnd.github+json") + .retrieve() + .body(Map::class.java) ?: throw OAuthException("Failed to fetch GitHub user profile") + + // Fetch verified primary email + val emails = restClient.get() + .uri("https://api.github.com/user/emails") + .header("Authorization", "Bearer $accessToken") + .header("Accept", "application/vnd.github+json") + .retrieve() + .body(List::class.java) as? List> ?: emptyList() + + val primaryEmail = emails.firstOrNull { it["primary"] == true && it["verified"] == true } + val email = primaryEmail?.get("email") as? String + val emailVerified = primaryEmail?.get("verified") as? Boolean ?: false + + val userId = profile["id"]?.toString() + ?: throw OAuthException("Missing id in GitHub response") + + logger.info("GitHub user info: id={}, login={}, email={}", userId, profile["login"], email) + + return OAuthUserInfo( + provider = "github", + providerUserId = userId, + email = email, + emailVerified = emailVerified, + displayName = profile["name"] as? String ?: profile["login"] as? String, + avatarUrl = profile["avatar_url"] as? String, + ) + } +} diff --git a/src/main/kotlin/com/embabel/hub/oauth/GoogleOAuthService.kt b/src/main/kotlin/com/embabel/hub/oauth/GoogleOAuthService.kt new file mode 100644 index 0000000..4a613b3 --- /dev/null +++ b/src/main/kotlin/com/embabel/hub/oauth/GoogleOAuthService.kt @@ -0,0 +1,74 @@ +package com.embabel.hub.oauth + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient +import java.net.URLEncoder + +@Service +class GoogleOAuthService( + @Value("\${guide.oauth.google.client-id:}") private val clientId: String, + @Value("\${guide.oauth.google.client-secret:}") private val clientSecret: String, + @Value("\${guide.oauth.google.redirect-uri:}") private val redirectUri: String, +) { + + private val logger = LoggerFactory.getLogger(GoogleOAuthService::class.java) + private val restClient = RestClient.create() + + val isConfigured: Boolean get() = clientId.isNotBlank() && clientSecret.isNotBlank() + + fun getAuthorizationUrl(state: String): String { + val params = mapOf( + "client_id" to clientId, + "redirect_uri" to redirectUri, + "response_type" to "code", + "scope" to "openid email profile", + "state" to state, + "access_type" to "offline", + "prompt" to "select_account", + ) + val query = params.entries.joinToString("&") { (k, v) -> + "$k=${URLEncoder.encode(v, Charsets.UTF_8)}" + } + return "https://accounts.google.com/o/oauth2/v2/auth?$query" + } + + fun exchangeCodeForUserInfo(code: String): OAuthUserInfo { + // Exchange authorization code for tokens + val tokenResponse = restClient.post() + .uri("https://oauth2.googleapis.com/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body( + "code=${URLEncoder.encode(code, Charsets.UTF_8)}" + + "&client_id=${URLEncoder.encode(clientId, Charsets.UTF_8)}" + + "&client_secret=${URLEncoder.encode(clientSecret, Charsets.UTF_8)}" + + "&redirect_uri=${URLEncoder.encode(redirectUri, Charsets.UTF_8)}" + + "&grant_type=authorization_code" + ) + .retrieve() + .body(Map::class.java) ?: throw OAuthException("Failed to exchange code with Google") + + val accessToken = tokenResponse["access_token"] as? String + ?: throw OAuthException("No access_token in Google response") + + // Fetch user info + val userInfo = restClient.get() + .uri("https://www.googleapis.com/oauth2/v3/userinfo") + .header("Authorization", "Bearer $accessToken") + .retrieve() + .body(Map::class.java) ?: throw OAuthException("Failed to fetch Google user info") + + logger.info("Google user info: sub={}, email={}", userInfo["sub"], userInfo["email"]) + + return OAuthUserInfo( + provider = "google", + providerUserId = userInfo["sub"] as? String ?: throw OAuthException("Missing sub in Google response"), + email = userInfo["email"] as? String, + emailVerified = userInfo["email_verified"] as? Boolean ?: false, + displayName = userInfo["name"] as? String, + avatarUrl = userInfo["picture"] as? String, + ) + } +} diff --git a/src/main/kotlin/com/embabel/hub/oauth/OAuthController.kt b/src/main/kotlin/com/embabel/hub/oauth/OAuthController.kt new file mode 100644 index 0000000..7779b0e --- /dev/null +++ b/src/main/kotlin/com/embabel/hub/oauth/OAuthController.kt @@ -0,0 +1,78 @@ +package com.embabel.hub.oauth + +import com.embabel.hub.LoginResponse +import org.slf4j.LoggerFactory +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.RestController +import java.security.SecureRandom +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap + +@RestController +@RequestMapping("/api/hub/oauth") +class OAuthController( + private val oAuthLoginService: OAuthLoginService, + private val googleOAuthService: GoogleOAuthService, + private val gitHubOAuthService: GitHubOAuthService, +) { + + private val logger = LoggerFactory.getLogger(OAuthController::class.java) + + // Short-lived state store for CSRF protection (10 minute TTL) + private val pendingStates = ConcurrentHashMap() + private val stateTtlMs = 10 * 60 * 1000L + + @GetMapping("/{provider}/authorize") + fun authorize(@PathVariable provider: String): AuthorizeResponse { + val state = generateState() + pendingStates[state] = System.currentTimeMillis() + cleanExpiredStates() + + val url = when (provider) { + "google" -> { + if (!googleOAuthService.isConfigured) throw OAuthException("Google OAuth not configured") + googleOAuthService.getAuthorizationUrl(state) + } + "github" -> { + if (!gitHubOAuthService.isConfigured) throw OAuthException("GitHub OAuth not configured") + gitHubOAuthService.getAuthorizationUrl(state) + } + else -> throw OAuthException("Unsupported OAuth provider: $provider") + } + + logger.info("Generated OAuth authorize URL for provider={}", provider) + return AuthorizeResponse(url, state) + } + + @PostMapping("/{provider}/callback") + fun callback( + @PathVariable provider: String, + @RequestBody request: OAuthCallbackRequest, + ): LoginResponse { + // Validate state + val stateTimestamp = pendingStates.remove(request.state) + if (stateTimestamp == null || System.currentTimeMillis() - stateTimestamp > stateTtlMs) { + throw OAuthException("Invalid or expired OAuth state parameter") + } + + return oAuthLoginService.handleOAuthCallback(provider, request.code) + } + + private fun generateState(): String { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + + private fun cleanExpiredStates() { + val now = System.currentTimeMillis() + pendingStates.entries.removeIf { now - it.value > stateTtlMs } + } +} + +data class AuthorizeResponse(val url: String, val state: String) +data class OAuthCallbackRequest(val code: String, val state: String) diff --git a/src/main/kotlin/com/embabel/hub/oauth/OAuthException.kt b/src/main/kotlin/com/embabel/hub/oauth/OAuthException.kt new file mode 100644 index 0000000..082c9c9 --- /dev/null +++ b/src/main/kotlin/com/embabel/hub/oauth/OAuthException.kt @@ -0,0 +1,3 @@ +package com.embabel.hub.oauth + +class OAuthException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/src/main/kotlin/com/embabel/hub/oauth/OAuthLoginService.kt b/src/main/kotlin/com/embabel/hub/oauth/OAuthLoginService.kt new file mode 100644 index 0000000..634f176 --- /dev/null +++ b/src/main/kotlin/com/embabel/hub/oauth/OAuthLoginService.kt @@ -0,0 +1,136 @@ +package com.embabel.hub.oauth + +import com.embabel.chat.store.util.UUIDv7 +import com.embabel.guide.domain.GuideUserService +import com.embabel.guide.domain.OAuthProviderData +import com.embabel.guide.domain.WebUserData +import com.embabel.hub.JwtTokenService +import com.embabel.hub.LoginResponse +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class OAuthLoginService( + private val guideUserService: GuideUserService, + private val jwtTokenService: JwtTokenService, + private val googleOAuthService: GoogleOAuthService, + private val gitHubOAuthService: GitHubOAuthService, +) { + + private val logger = LoggerFactory.getLogger(OAuthLoginService::class.java) + + fun handleOAuthCallback(provider: String, code: String): LoginResponse { + val userInfo = when (provider) { + "google" -> googleOAuthService.exchangeCodeForUserInfo(code) + "github" -> gitHubOAuthService.exchangeCodeForUserInfo(code) + else -> throw OAuthException("Unsupported OAuth provider: $provider") + } + + // 1. Check if user already linked via this OAuth provider + val existingByProvider = guideUserService.findByOAuthProvider(userInfo.provider, userInfo.providerUserId) + if (existingByProvider.isPresent) { + val guideUser = existingByProvider.get() + val webUser = guideUser.webUser!! + logger.info("OAuth login: existing user {} via {}", webUser.id, provider) + return buildLoginResponse(webUser.id, webUser, guideUser.persona.id) + } + + // 2. Check if user exists with matching email (auto-link) + if (userInfo.email != null && userInfo.emailVerified) { + val existingByEmail = guideUserService.findByWebUserEmail(userInfo.email) + if (existingByEmail.isPresent) { + val guideUser = existingByEmail.get() + val webUser = guideUser.webUser!! + + // Link OAuth provider to existing user + val oauthData = buildOAuthProviderData(userInfo) + val updated = guideUser.copy( + oauthProviders = guideUser.oauthProviders + oauthData, + ) + // Also mark email as verified since the OAuth provider confirmed it + webUser.emailVerified = true + guideUserService.saveUser(updated) + + logger.info("OAuth login: linked {} to existing user {} by email", provider, webUser.id) + return buildLoginResponse(webUser.id, webUser, guideUser.persona.id) + } + } + + // 3. Create new user + val userId = UUIDv7.generateString() + val username = generateUsername(userInfo.email, userInfo.displayName) + val webUser = WebUserData( + id = userId, + displayName = userInfo.displayName ?: username, + userName = username, + userEmail = userInfo.email, + passwordHash = null, + refreshToken = null, + emailVerified = userInfo.emailVerified, + ) + val savedUser = guideUserService.saveFromWebUser(webUser) + + // Link OAuth provider + val oauthData = buildOAuthProviderData(userInfo) + val updated = savedUser.copy( + oauthProviders = savedUser.oauthProviders + oauthData, + ) + guideUserService.saveUser(updated) + + logger.info("OAuth login: created new user {} via {}", userId, provider) + return buildLoginResponse(userId, webUser, savedUser.persona.id) + } + + private fun buildOAuthProviderData(userInfo: OAuthUserInfo) = OAuthProviderData( + id = "${userInfo.provider}_${userInfo.providerUserId}", + provider = userInfo.provider, + providerUserId = userInfo.providerUserId, + email = userInfo.email, + displayName = userInfo.displayName, + avatarUrl = userInfo.avatarUrl, + ) + + private fun buildLoginResponse(webUserId: String, webUser: WebUserData, personaId: String): LoginResponse { + val token = jwtTokenService.generateRefreshToken(webUserId) + val expiresAt = Instant.now().plusSeconds(jwtTokenService.tokenExpirationSeconds) + return LoginResponse( + token = token, + expiresAt = expiresAt, + userId = webUserId, + username = webUser.userName, + displayName = webUser.displayName, + email = webUser.userEmail ?: "", + persona = personaId, + emailVerified = webUser.emailVerified, + hasPassword = webUser.passwordHash != null, + ) + } + + private fun generateUsername(email: String?, displayName: String?): String { + val base = when { + email != null -> email.substringBefore('@').lowercase() + .replace(Regex("[^a-z0-9_]"), "_") + .take(20) + displayName != null -> displayName.lowercase() + .replace(Regex("[^a-z0-9_]"), "_") + .take(20) + else -> "user" + } + + // Check uniqueness, append random suffix if taken + if (guideUserService.findByWebUserName(base).isEmpty) return base + + repeat(10) { + val candidate = "${base}_${randomSuffix()}" + if (guideUserService.findByWebUserName(candidate).isEmpty) return candidate + } + + return "${base}_${System.currentTimeMillis()}" + } + + private fun randomSuffix(): String { + val chars = "abcdefghijklmnopqrstuvwxyz0123456789" + return (1..4).map { chars.random() }.joinToString("") + } +} diff --git a/src/main/kotlin/com/embabel/hub/oauth/OAuthUserInfo.kt b/src/main/kotlin/com/embabel/hub/oauth/OAuthUserInfo.kt new file mode 100644 index 0000000..dba4db0 --- /dev/null +++ b/src/main/kotlin/com/embabel/hub/oauth/OAuthUserInfo.kt @@ -0,0 +1,10 @@ +package com.embabel.hub.oauth + +data class OAuthUserInfo( + val provider: String, + val providerUserId: String, + val email: String?, + val emailVerified: Boolean, + val displayName: String?, + val avatarUrl: String?, +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e2b5c8a..3aa7d69 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,11 @@ server: port: 1337 + tomcat: + threads: + max: 50 + min-spare: 5 + max-connections: 100 + connection-timeout: 20s # Actuator endpoints for health checks and monitoring management: @@ -38,40 +44,76 @@ guide: embedding-batch-size: 50 # Ingested via load-references (or on startup if reload-content-on-startup is true) - urls: - - https://docs.embabel.com/embabel-agent/guide/0.3.5-SNAPSHOT/ - - https://raw.githubusercontent.com/liberation-data/drivine4j/refs/heads/main/README.md - # @springrod blog posts - - https://medium.com/@springrod/embabel-a-new-agent-platform-for-the-jvm-1c83402e0014 - - https://medium.com/@springrod/production-ready-gen-ai-beyond-the-python-prototype-439bf213a8c0 - - https://medium.com/@springrod/building-reliable-agentic-systems-part-i-b056d5b59392 - - https://medium.com/@springrod/the-embabel-vision-967654f13793 - - https://medium.com/@springrod/ai-for-your-gen-ai-how-and-why-embabel-plans-3930244218f6 - - https://medium.com/@springrod/build-better-agents-in-java-vs-python-embabel-vs-langgraph-f7951a0d855c - - https://medium.com/@springrod/ground-your-ai-transformation-on-what-works-today-bfc525418118 - - https://medium.com/@springrod/from-alchemy-to-engineering-building-type-safe-gen-ai-applications-with-embabel-c3d89b7c989f - - https://medium.com/@springrod/building-a-chatbot-with-embabel-agentic-rag-b26a8346cb16 - - https://medium.com/@springrod/rethinking-rag-pipelines-are-the-past-agentic-is-the-future-77c887414621 - - https://medium.com/@springrod/dont-talk-english-to-your-llm-ecbfe954bea1 - - https://medium.com/@springrod/why-you-should-use-local-models-a3fce1124c94 - - https://medium.com/@springrod/you-can-build-better-ai-agents-in-java-than-python-868eaf008493 - - https://medium.com/@springrod/embabel-year-end-update-building-the-best-agent-framework-25ed98728e79 - - https://medium.com/@springrod/building-stateful-ai-agents-how-to-create-interactive-mcp-tools-with-embabel-0dfbd3037cf7 - - https://medium.com/@springrod/context-engineering-needs-domain-understanding-b4387e8e4bf8 - - https://medium.com/the-composition/software-that-writes-and-evolves-software-953578a6fc36 - # Embabel publication - - https://medium.com/embabel/agent-memory-is-not-a-greenfield-problem-ground-it-in-your-existing-data-9272cabe1561 - # @jasper.blues / Embabel publication - - https://medium.com/embabel/from-docs-to-dialog-2b613901db60 - - https://medium.com/embabel/the-very-slowly-ticking-time-bomb-in-your-graph-persistence-stack-6da1a8da9515 - - https://medium.com/embabel/the-voice-the-word-and-the-wheel-d6e2ef2ab26e - - # 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. + content: + versioned: + base-url: https://docs.embabel.com/embabel-agent/guide/ + versions: + - 0.4.0-SNAPSHOT + supplementary: + - https://raw.githubusercontent.com/liberation-data/drivine4j/refs/heads/main/README.md + # @springrod blog posts + - https://medium.com/@springrod/embabel-a-new-agent-platform-for-the-jvm-1c83402e0014 + - https://medium.com/@springrod/production-ready-gen-ai-beyond-the-python-prototype-439bf213a8c0 + - https://medium.com/@springrod/building-reliable-agentic-systems-part-i-b056d5b59392 + - https://medium.com/@springrod/the-embabel-vision-967654f13793 + - https://medium.com/@springrod/ai-for-your-gen-ai-how-and-why-embabel-plans-3930244218f6 + - https://medium.com/@springrod/build-better-agents-in-java-vs-python-embabel-vs-langgraph-f7951a0d855c + - https://medium.com/@springrod/ground-your-ai-transformation-on-what-works-today-bfc525418118 + - https://medium.com/@springrod/from-alchemy-to-engineering-building-type-safe-gen-ai-applications-with-embabel-c3d89b7c989f + - https://medium.com/@springrod/building-a-chatbot-with-embabel-agentic-rag-b26a8346cb16 + - https://medium.com/@springrod/rethinking-rag-pipelines-are-the-past-agentic-is-the-future-77c887414621 + - https://medium.com/@springrod/dont-talk-english-to-your-llm-ecbfe954bea1 + - https://medium.com/@springrod/why-you-should-use-local-models-a3fce1124c94 + - https://medium.com/@springrod/you-can-build-better-ai-agents-in-java-than-python-868eaf008493 + - https://medium.com/@springrod/embabel-year-end-update-building-the-best-agent-framework-25ed98728e79 + - https://medium.com/@springrod/building-stateful-ai-agents-how-to-create-interactive-mcp-tools-with-embabel-0dfbd3037cf7 + - https://medium.com/@springrod/context-engineering-needs-domain-understanding-b4387e8e4bf8 + - https://medium.com/the-composition/software-that-writes-and-evolves-software-953578a6fc36 + # Embabel publication + - https://medium.com/embabel/agent-memory-is-not-a-greenfield-problem-ground-it-in-your-existing-data-9272cabe1561 + # @jasper.blues / Embabel publication + - https://medium.com/embabel/from-docs-to-dialog-2b613901db60 + - https://medium.com/embabel/the-very-slowly-ticking-time-bomb-in-your-graph-persistence-stack-6da1a8da9515 + - https://medium.com/embabel/the-voice-the-word-and-the-wheel-d6e2ef2ab26e + + # Route specific URL patterns through alternate fetchers. + # Medium is handled programmatically by MediumArchiveContentFetcher + # (registered in RagConfiguration), which walks per-author monthly RSS + # archives to retrieve articles aged out of the main feed. + fetch-routes: [] + + # 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: + sendgrid-api-key: ${SENDGRID_API_KEY:} + from-email: ${SENDGRID_FROM_EMAIL:noreply@embabel.com} + frontend-url: ${FRONTEND_URL:http://localhost:3000} + + oauth: + google: + client-id: ${GOOGLE_CLIENT_ID:} + client-secret: ${GOOGLE_CLIENT_SECRET:} + redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:3000/auth/callback/google} + github: + client-id: ${GITHUB_CLIENT_ID:} + client-secret: ${GITHUB_CLIENT_SECRET:} + redirect-uri: ${GITHUB_REDIRECT_URI:http://localhost:3000/auth/callback/github} + # Spring configuration # Force web application type (needed because Spring Shell is present) @@ -82,11 +124,20 @@ 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: local # Change to 'default' in tests to use TestContainers datasource + active: neo4j # Change to 'neo4j', 'falkordb', 'memgraph', or 'default' for TestContainers config: activate: - on-profile: local + on-profile: neo4j # RAG Adapter Configuration (use "guide" to enable real Guide chatbot, "fake" for testing) @@ -115,7 +166,7 @@ embabel: cache-dir: ${user.home}/.embabel/models rag: - neo: + graph: uri: ${NEO4J_URI:bolt://localhost:7687} username: ${NEO4J_USERNAME:neo4j} password: ${NEO4J_PASSWORD:brahmsian} @@ -126,9 +177,6 @@ embabel: personality: starwars - discord: - token: ${DISCORD_TOKEN} - shell: line-length: 140 @@ -149,7 +197,7 @@ logging: org.springframework.messaging: INFO com.embabel.guide.chat: DEBUG -# Data-source below is used then the 'local' config is active +# Data-source below is used when the 'neo4j' profile is active (default) database: dataSources: @@ -158,6 +206,41 @@ 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 + +--- +# FalkorDB profile — activate with: spring.profiles.active=falkordb +spring: + config: + activate: + on-profile: falkordb + +database: + dataSources: + neo: + type: FALKORDB + host: ${FALKORDB_HOST:localhost} + port: ${FALKORDB_PORT:6379} + databaseName: embabel + +--- +# Memgraph profile — activate with: spring.profiles.active=memgraph +# Memgraph speaks Bolt; the docker-compose service maps host port 7688 to container 7687 +# to avoid clashing with neo4j. +spring: + config: + activate: + on-profile: memgraph + +database: + dataSources: + neo: + type: MEMGRAPH + host: ${MEMGRAPH_HOST:localhost} + port: ${MEMGRAPH_BOLT_PORT:7688} + userName: "" + password: "" + databaseName: memgraph diff --git a/src/main/resources/prompts/classifier.jinja b/src/main/resources/prompts/classifier.jinja index e5e6239..f6b546f 100644 --- a/src/main/resources/prompts/classifier.jinja +++ b/src/main/resources/prompts/classifier.jinja @@ -13,10 +13,10 @@ Examples: "thanks!", "nice", "hello", "haha", "goodbye" category = COMMAND: The message is asking to change a setting or control the experience. -This includes changing persona/character, voice, or audio effects. +This includes changing persona/character. Available personas: {{ personaList }} -Examples: "switch to shakespeare", "give me something old fashioned", "change my voice to jupiter", "add echo effect" +Examples: "switch to shakespeare", "give me something old fashioned" category = INFORMATIONAL: The message needs information lookup, code generation, or the assistant to DO something substantive. diff --git a/src/main/resources/prompts/command_executor.jinja b/src/main/resources/prompts/command_executor.jinja index 5e1e57c..2ebf897 100644 --- a/src/main/resources/prompts/command_executor.jinja +++ b/src/main/resources/prompts/command_executor.jinja @@ -10,8 +10,6 @@ pick the best match from the list above based on the descriptions. Execute the appropriate commands using the available tools: - changePersona: to switch the user's persona/character -- changeVoice: to change the text-to-speech voice -- applyEffects: to add or change audio effects Call the relevant tools now. After you receive the tool results, compose your response: diff --git a/src/main/resources/prompts/elements/personalization.jinja b/src/main/resources/prompts/elements/personalization.jinja index 074b3aa..27b2bf0 100644 --- a/src/main/resources/prompts/elements/personalization.jinja +++ b/src/main/resources/prompts/elements/personalization.jinja @@ -4,6 +4,7 @@ {% if user.customPersona is defined and user.customPersona %} {{ user.customPersona }} + Do NOT adopt the tone, vocabulary, or style of earlier messages in this conversation. Only use the voice described above. {% endif %} {% if user.displayName is defined and user.displayName %} diff --git a/src/main/resources/prompts/persona/edward_lear.jinja b/src/main/resources/prompts/persona/edward_lear.jinja deleted file mode 100644 index 5a41249..0000000 --- a/src/main/resources/prompts/persona/edward_lear.jinja +++ /dev/null @@ -1,4 +0,0 @@ -You speak like Edward Lear. -Lear invented the limerick, so you must conclude -every response with a limerick. -Besides that, you use playful 19th century English. \ No newline at end of file diff --git a/src/main/resources/prompts/persona/edward_lear.json b/src/main/resources/prompts/persona/edward_lear.json deleted file mode 100644 index d33b7a7..0000000 --- a/src/main/resources/prompts/persona/edward_lear.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "description": "Responds in playful 19th-century English and ends every reply with a limerick.", - "voice": "arcas", - "effects": [] -} diff --git a/src/main/resources/prompts/persona/eec.jinja b/src/main/resources/prompts/persona/eec.jinja deleted file mode 100644 index cbcdc6f..0000000 --- a/src/main/resources/prompts/persona/eec.jinja +++ /dev/null @@ -1 +0,0 @@ -you write like ee cummings \ No newline at end of file diff --git a/src/main/resources/prompts/persona/eec.json b/src/main/resources/prompts/persona/eec.json deleted file mode 100644 index b83b20b..0000000 --- a/src/main/resources/prompts/persona/eec.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "description": "writes like e.e. cummings — lowercase, unconventional punctuation, poetic.", - "voice": "luna", - "effects": [] -} 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/config/ChatStoreConfigTitleGeneratorTest.kt b/src/test/kotlin/com/embabel/guide/config/ChatStoreConfigTitleGeneratorTest.kt index 713b5b2..e8639ee 100644 --- a/src/test/kotlin/com/embabel/guide/config/ChatStoreConfigTitleGeneratorTest.kt +++ b/src/test/kotlin/com/embabel/guide/config/ChatStoreConfigTitleGeneratorTest.kt @@ -7,6 +7,7 @@ import com.embabel.guide.domain.GuideUser import com.embabel.guide.domain.GuideUserCache import com.embabel.guide.domain.GuideUserData import com.embabel.guide.domain.GuideUserService +import com.embabel.guide.domain.GuideWebUser import com.embabel.guide.domain.WebUserData import com.embabel.hub.integrations.LlmProvider import com.embabel.hub.integrations.UserKeyStore @@ -27,7 +28,7 @@ class ChatStoreConfigTitleGeneratorTest { private val userKeyStore = mock(UserKeyStore::class.java) private val userModelFactory = mock(UserModelFactory::class.java) - private val guideUserCache = GuideUserCache() + private val guideUserCache = GuideUserCache(ttlSeconds = 300L) private val guideUserService = mock(GuideUserService::class.java) private val titleGenerator = ChatStoreConfig().titleGenerator( @@ -155,19 +156,19 @@ class ChatStoreConfigTitleGeneratorTest { @Test fun `falls back to GuideUserService when not in cache`() { // Don't seed cache — force DB fallback - val guideUser = mock(GuideUser::class.java) + val guideUser = mock(GuideWebUser::class.java) val core = GuideUserData(id = internalId) val webUser = mock(WebUserData::class.java) `when`(webUser.id).thenReturn(webUserId) `when`(guideUser.core).thenReturn(core) `when`(guideUser.webUser).thenReturn(webUser) - `when`(guideUserService.findById(internalId)).thenReturn(Optional.of(guideUser)) + `when`(guideUserService.findWebUserById(internalId)).thenReturn(Optional.of(guideUser)) `when`(userKeyStore.getActiveKey(webUserId)).thenReturn(null) val result = runBlocking { titleGenerator.generate(listOf(userMessage("Hello")), currentTitle = null, userId = internalId) } assertEquals(TitleGenerator.DEFAULT_FALLBACK, result) - verify(guideUserService).findById(internalId) + verify(guideUserService).findWebUserById(internalId) } } \ No newline at end of file diff --git a/src/test/kotlin/com/embabel/guide/domain/GuideUserServiceTest.kt b/src/test/kotlin/com/embabel/guide/domain/GuideUserServiceTest.kt index 9d119bf..1a389ae 100644 --- a/src/test/kotlin/com/embabel/guide/domain/GuideUserServiceTest.kt +++ b/src/test/kotlin/com/embabel/guide/domain/GuideUserServiceTest.kt @@ -41,8 +41,8 @@ class GuideUserServiceTest { @Test fun `test getOrCreateAnonymousWebUser creates new user when none exists`() { - // Given: No anonymous web user exists - guideUserRepository.deleteAll() + // Given: fresh transaction — no anonymous web user exists yet + // (system personas are seeded at startup by PersonaSeedingService) // When: We request the anonymous web user val anonymousUser = guideUserService.findOrCreateAnonymousWebUser() @@ -59,7 +59,6 @@ class GuideUserServiceTest { @Test fun `test getOrCreateAnonymousWebUser returns existing user when one exists`() { // Given: An anonymous web user already exists - guideUserRepository.deleteAll() val firstCall = guideUserService.findOrCreateAnonymousWebUser() val firstUserId = firstCall.guideUserData().id @@ -68,17 +67,10 @@ class GuideUserServiceTest { // Then: The same user is returned assertEquals(firstUserId, secondCall.guideUserData().id) - - // Verify only one GuideUser exists in the database - val allUsers = guideUserRepository.findAll() - assertEquals(1, allUsers.size) } @Test fun `test anonymous web user has correct display name`() { - // Given: We create an anonymous web user - guideUserRepository.deleteAll() - // When: We request the anonymous web user val anonymousUser = guideUserService.findOrCreateAnonymousWebUser() @@ -86,4 +78,4 @@ class GuideUserServiceTest { val found = guideUserRepository.findAnonymousWebUser().orElseThrow() assertEquals("Friend", found.core.displayName) } -} +} \ No newline at end of file diff --git a/src/test/kotlin/com/embabel/guide/domain/drivine/GuideUserRepositoryDefaultImplTest.kt b/src/test/kotlin/com/embabel/guide/domain/drivine/GuideUserRepositoryDefaultImplTest.kt index 2437ca3..03578ea 100644 --- a/src/test/kotlin/com/embabel/guide/domain/drivine/GuideUserRepositoryDefaultImplTest.kt +++ b/src/test/kotlin/com/embabel/guide/domain/drivine/GuideUserRepositoryDefaultImplTest.kt @@ -17,11 +17,13 @@ package com.embabel.guide.domain.drivine import com.embabel.guide.Neo4jPropertiesInitializer import com.embabel.guide.domain.* +import org.drivine.manager.GraphObjectManager import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.autoconfigure.ImportAutoConfiguration import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles @@ -45,48 +47,26 @@ class GuideUserRepositoryDefaultImplTest { @Autowired private lateinit var repository: GuideUserRepositoryDefaultImpl - @Test - fun `test create and find GuideUser with Discord info`() { - // Given: We create GuideUser data with Discord info - val guideUserData = GuideUserData( - id = UUID.randomUUID().toString(), - displayName = "Test User", - persona = "adaptive" - ) - - val discordInfo = DiscordUserInfoData( - "graphobj-discord-${UUID.randomUUID()}", - "testuser", - "1234", - "Test User", - false, - "https://example.com/avatar.png" - ) - - // When: We create the user via the repository - val created = repository.createWithDiscord(guideUserData, discordInfo) - - // Then: The user is created with composed data - assertNotNull(created) - assertEquals(guideUserData.id, created.guideUserData().id) - assertEquals("adaptive", created.guideUserData().persona) - assertEquals(discordInfo.id, created.discordUserInfo?.id) - assertEquals("testuser", created.discordUserInfo?.username) + @Autowired + @Qualifier("neoGraphObjectManager") + private lateinit var graphObjectManager: GraphObjectManager - // And: We can find it by Discord user ID - val found = repository.findByDiscordUserId(discordInfo.id!!) - assertTrue(found.isPresent) - assertEquals(guideUserData.id, found.get().guideUserData().id) - assertEquals("testuser", found.get().discordUserInfo?.username) + private fun createPersona(name: String): PersonaData { + return graphObjectManager.save(PersonaData( + id = UUID.randomUUID().toString(), + name = name, + prompt = "Test prompt for $name", + isSystem = true, + )) } @Test fun `test create and find GuideUser with WebUser info`() { // Given: We create GuideUser data with WebUser info + val persona = createPersona("adaptive") val guideUserData = GuideUserData( id = UUID.randomUUID().toString(), displayName = "Web Test User", - persona = "adaptive", customPrompt = "Answer questions about embabel" ) @@ -100,7 +80,7 @@ class GuideUserRepositoryDefaultImplTest { ) // When: We create the user via the repository - val created = repository.createWithWebUser(guideUserData, webUserData) + val created = repository.createWithWebUser(guideUserData, webUserData, persona) // Then: The user is created with composed data assertNotNull(created) @@ -119,6 +99,7 @@ class GuideUserRepositoryDefaultImplTest { @Test fun `test find by web username`() { // Given: We create a GuideUser with a specific username + val persona = createPersona("adaptive") val guideUserData = GuideUserData( id = UUID.randomUUID().toString(), displayName = "Username Test" @@ -133,7 +114,7 @@ class GuideUserRepositoryDefaultImplTest { null ) - repository.createWithWebUser(guideUserData, webUserData) + repository.createWithWebUser(guideUserData, webUserData, persona) // When: We search by username val found = repository.findByWebUserName(webUserData.userName) @@ -144,39 +125,11 @@ class GuideUserRepositoryDefaultImplTest { assertEquals(webUserData.userName, found.get().webUser?.userName) } - @Test - fun `test update persona`() { - // Given: We create a GuideUser - val guideUserData = GuideUserData( - id = UUID.randomUUID().toString(), - displayName = "Persona Test", - persona = "adaptive" - ) - - val discordInfo = DiscordUserInfoData( - "graphobj-discord-${UUID.randomUUID()}", - "personatest", - "0001", - "Persona Test", - false, - null - ) - - val created = repository.createWithDiscord(guideUserData, discordInfo) - val userId = created.guideUserData().id - - // When: We update the persona - repository.updatePersona(userId, "expert") - - // Then: The persona is updated - val found = repository.findByDiscordUserId(discordInfo.id!!) - assertTrue(found.isPresent) - assertEquals("expert", found.get().guideUserData().persona) - } - @Test fun `test update persona for web user`() { // Given: We create a GuideUser with a WebUser (same as frontend flow) + val adaptive = createPersona("adaptive") + val expert = createPersona("expert") val guideUserData = GuideUserData( id = UUID.randomUUID().toString(), displayName = "Web Persona Test" @@ -191,27 +144,28 @@ class GuideUserRepositoryDefaultImplTest { null ) - val created = repository.createWithWebUser(guideUserData, webUserData) + val created = repository.createWithWebUser(guideUserData, webUserData, adaptive) val userId = created.guideUserData().id - assertNull(created.guideUserData().persona) + assertEquals("adaptive", created.persona.name) // When: We update the persona (same code path as HubService) - repository.updatePersona(userId, "expert") + repository.updatePersona(userId, expert) // Then: The persona is updated when reading by core ID val foundById = repository.findById(userId) assertTrue(foundById.isPresent) - assertEquals("expert", foundById.get().guideUserData().persona) + assertEquals("expert", foundById.get().persona.name) // And: Also updated when reading by web user ID (the frontend lookup path) val foundByWebId = repository.findByWebUserId(webUserData.id) assertTrue(foundByWebId.isPresent) - assertEquals("expert", foundByWebId.get().guideUserData().persona) + assertEquals("expert", foundByWebId.get().persona.name) } @Test fun `test update custom prompt`() { // Given: We create a GuideUser + val persona = createPersona("adaptive") val guideUserData = GuideUserData( id = UUID.randomUUID().toString(), displayName = "Prompt Test" @@ -226,7 +180,7 @@ class GuideUserRepositoryDefaultImplTest { null ) - val created = repository.createWithWebUser(guideUserData, webUserData) + val created = repository.createWithWebUser(guideUserData, webUserData, persona) val userId = created.guideUserData().id // When: We update the custom prompt @@ -238,15 +192,6 @@ class GuideUserRepositoryDefaultImplTest { assertEquals("Updated prompt", found.get().guideUserData().customPrompt) } - @Test - fun `test findByDiscordUserId returns empty when not found`() { - // When: We search for a non-existent Discord user - val found = repository.findByDiscordUserId("nonexistent") - - // Then: An empty Optional is returned - assertFalse(found.isPresent) - } - @Test fun `test findByWebUserId returns empty when not found`() { // When: We search for a non-existent web user @@ -258,79 +203,78 @@ class GuideUserRepositoryDefaultImplTest { @Test fun `test findById returns GuideUser`() { - // Given: We create a GuideUser + val jesse = createPersona("jesse") val guideUserData = GuideUserData( id = UUID.randomUUID().toString(), displayName = "FindById Test", - persona = "jesse" ) - - val discordInfo = DiscordUserInfoData( - "graphobj-discord-${UUID.randomUUID()}", - "findbyidtest", - "0001", + val webUserData = WebUserData( + "graphobj-web-${UUID.randomUUID()}", "FindById Test", - false, + "findbyidtest", + "findbyid@example.com", + "hash", null ) - repository.createWithDiscord(guideUserData, discordInfo) + repository.createWithWebUser(guideUserData, webUserData, jesse) - // When: We find by GuideUser ID val found = repository.findById(guideUserData.id) - // Then: The user is found assertTrue(found.isPresent) assertEquals(guideUserData.id, found.get().guideUserData().id) - assertEquals("jesse", found.get().guideUserData().persona) + assertEquals("jesse", found.get().persona.name) } @Test fun `test save updates GuideUser`() { - // Given: We create a GuideUser + val original = createPersona("original") + val modified = createPersona("modified") val guideUserData = GuideUserData( id = UUID.randomUUID().toString(), displayName = "Save Test", - persona = "original" ) - - val discordInfo = DiscordUserInfoData( - "graphobj-discord-${UUID.randomUUID()}", - "savetest", - "0001", + val webUserData = WebUserData( + "graphobj-web-${UUID.randomUUID()}", "Save Test", - false, + "savetest", + "save@example.com", + "hash", null ) - val created = repository.createWithDiscord(guideUserData, discordInfo) + val created = repository.createWithWebUser(guideUserData, webUserData, original) // When: We modify and save - val modified = created.copy( - core = created.core.copy(persona = "modified", customPrompt = "new prompt") + val updated = created.copy( + core = created.core.copy(customPrompt = "new prompt"), + persona = modified, ) - val saved = repository.save(modified) + val saved = repository.save(updated) // Then: The changes are persisted - assertEquals("modified", saved.guideUserData().persona) + assertEquals("modified", saved.persona.name) assertEquals("new prompt", saved.guideUserData().customPrompt) // And: Can be retrieved val found = repository.findById(guideUserData.id) assertTrue(found.isPresent) - assertEquals("modified", found.get().guideUserData().persona) + assertEquals("modified", found.get().persona.name) } @Test fun `test findAll returns all GuideUsers`() { // Given: We create multiple GuideUsers - val user1 = repository.createWithDiscord( - GuideUserData(id = UUID.randomUUID().toString(), displayName = "User 1", persona = "user1"), - DiscordUserInfoData("graphobj-discord-${UUID.randomUUID()}", "user1", "0001", "User 1", false, null) + val persona = createPersona("adaptive") + val user1 = repository.createWithWebUser( + GuideUserData(id = UUID.randomUUID().toString(), displayName = "User 1"), + WebUserData("graphobj-web-${UUID.randomUUID()}", "User 1", "user1", "user1a@test.com", "hash", null), + persona, ) val user2 = repository.createWithWebUser( - GuideUserData(id = UUID.randomUUID().toString(), displayName = "User 2", persona = "user2"), - WebUserData("graphobj-web-${UUID.randomUUID()}", "User 2", "user2", "user2@test.com", "hash", null) + GuideUserData(id = UUID.randomUUID().toString(), displayName = "User 2"), + WebUserData("graphobj-web-${UUID.randomUUID()}", "User 2", "user2", "user2@test.com", "hash", null), + persona, ) // When: We find all @@ -344,17 +288,17 @@ class GuideUserRepositoryDefaultImplTest { @Test fun `test deleteAll removes all GuideUsers`() { - // Given: We create a GuideUser + val persona = createPersona("adaptive") val guideUserData = GuideUserData(id = UUID.randomUUID().toString(), displayName = "Delete Test") - val discordInfo = DiscordUserInfoData( - "graphobj-discord-${UUID.randomUUID()}", - "deletetest", - "0001", + val webUserData = WebUserData( + "graphobj-web-${UUID.randomUUID()}", "Delete Test", - false, + "deletetest", + "delete@example.com", + "hash", null ) - repository.createWithDiscord(guideUserData, discordInfo) + repository.createWithWebUser(guideUserData, webUserData, persona) // When: We delete all repository.deleteAll() @@ -369,17 +313,21 @@ class GuideUserRepositoryDefaultImplTest { // Given: We create users with specific username prefixes val prefix = "graphobj-deleteprefix-${UUID.randomUUID()}" + val persona = createPersona("adaptive") repository.createWithWebUser( GuideUserData(id = UUID.randomUUID().toString(), displayName = "User 1"), - WebUserData("web1", "User 1", "${prefix}-user1", "user1@test.com", "hash", null) + WebUserData("web1", "User 1", "${prefix}-user1", "user1@test.com", "hash", null), + persona, ) repository.createWithWebUser( GuideUserData(id = UUID.randomUUID().toString(), displayName = "User 2"), - WebUserData("web2", "User 2", "${prefix}-user2", "user2@test.com", "hash", null) + WebUserData("web2", "User 2", "${prefix}-user2", "user2@test.com", "hash", null), + persona, ) repository.createWithWebUser( GuideUserData(id = UUID.randomUUID().toString(), displayName = "Other User"), - WebUserData("web3", "Other User", "other-user", "other@test.com", "hash", null) + WebUserData("web3", "Other User", "other-user", "other@test.com", "hash", null), + persona, ) // When: We delete by prefix @@ -394,6 +342,7 @@ class GuideUserRepositoryDefaultImplTest { @Test fun `test find anonymous web user`() { // Given: We create an anonymous web user + val persona = createPersona("adaptive") val guideUserData = GuideUserData(id = UUID.randomUUID().toString(), displayName = "Friend") val anonymousUser = AnonymousWebUserData( "anon-${UUID.randomUUID()}", @@ -403,12 +352,13 @@ class GuideUserRepositoryDefaultImplTest { null, null ) - repository.createWithWebUser(guideUserData, anonymousUser) + repository.createWithWebUser(guideUserData, anonymousUser, persona) // And: A regular web user repository.createWithWebUser( GuideUserData(id = UUID.randomUUID().toString(), displayName = "Regular User"), - WebUserData("regular-${UUID.randomUUID()}", "Regular User", "regular", null, null, null) + WebUserData("regular-${UUID.randomUUID()}", "Regular User", "regular", null, null, null), + persona, ) // When: We search for the anonymous web user @@ -424,9 +374,11 @@ class GuideUserRepositoryDefaultImplTest { @Test fun `test findAnonymousWebUser returns empty when none exists`() { // Given: Only regular web users exist + val persona = createPersona("adaptive") repository.createWithWebUser( GuideUserData(id = UUID.randomUUID().toString(), displayName = "Regular User"), - WebUserData("regular-${UUID.randomUUID()}", "Regular User", "regular", null, null, null) + WebUserData("regular-${UUID.randomUUID()}", "Regular User", "regular", null, null, null), + persona, ) // When: We search for anonymous user diff --git a/src/test/kotlin/com/embabel/guide/domain/drivine/PersonaRepositoryImplTest.kt b/src/test/kotlin/com/embabel/guide/domain/drivine/PersonaRepositoryImplTest.kt index 909a795..868d8ae 100644 --- a/src/test/kotlin/com/embabel/guide/domain/drivine/PersonaRepositoryImplTest.kt +++ b/src/test/kotlin/com/embabel/guide/domain/drivine/PersonaRepositoryImplTest.kt @@ -3,11 +3,13 @@ package com.embabel.guide.domain.drivine import com.embabel.guide.Neo4jPropertiesInitializer import com.embabel.guide.domain.* import com.embabel.hub.AudioEffect +import org.drivine.manager.GraphObjectManager import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.autoconfigure.ImportAutoConfiguration import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles @@ -28,8 +30,13 @@ class PersonaRepositoryImplTest { @Autowired private lateinit var guideUserRepository: GuideUserRepositoryDefaultImpl + @Autowired + @Qualifier("neoGraphObjectManager") + private lateinit var graphObjectManager: GraphObjectManager + private lateinit var systemOwner: GuideUserData private lateinit var testUser: GuideUserData + private lateinit var bootstrapPersona: PersonaData companion object { const val SYSTEM_OWNER_ID = PersonaRepository.SYSTEM_OWNER_ID @@ -37,18 +44,26 @@ class PersonaRepositoryImplTest { @BeforeEach fun setUp() { + // Bootstrap persona needed to satisfy GuideUser's non-null persona relationship + bootstrapPersona = graphObjectManager.save(PersonaData( + id = UUID.randomUUID().toString(), + name = "bootstrap", + prompt = "bootstrap persona for tests", + isSystem = true, + )) + // Ensure system owner (bot:jesse) exists systemOwner = guideUserRepository.findById(SYSTEM_OWNER_ID) .map { it.core } .orElseGet { val u = GuideUserData(id = SYSTEM_OWNER_ID, displayName = "Jesse") - guideUserRepository.save(GuideUser(core = u)) + guideUserRepository.save(GuideUser(core = u, persona = bootstrapPersona)) u } // Create a test user testUser = GuideUserData(id = "test-user-${UUID.randomUUID()}", displayName = "Test User") - guideUserRepository.save(GuideUser(core = testUser)) + guideUserRepository.save(GuideUser(core = testUser, persona = bootstrapPersona)) } private fun makePersonaView( diff --git a/src/test/kotlin/com/embabel/guide/rag/DataManagerControllerTest.kt b/src/test/kotlin/com/embabel/guide/rag/DataManagerControllerTest.kt index b6ba6ee..669603a 100644 --- a/src/test/kotlin/com/embabel/guide/rag/DataManagerControllerTest.kt +++ b/src/test/kotlin/com/embabel/guide/rag/DataManagerControllerTest.kt @@ -1,6 +1,8 @@ package com.embabel.guide.rag -import com.embabel.agent.rag.neo.drivine.model.ContentElementRepositoryInfoImpl +import com.embabel.agent.rag.graph.model.ContentElementRepositoryInfoImpl +import com.embabel.guide.stats.GuideStats +import com.embabel.guide.stats.GuideStatsService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.mockito.Mockito.* @@ -9,17 +11,19 @@ import java.time.Duration class DataManagerControllerTest { private val dataManager = mock(DataManager::class.java) - private val controller = DataManagerController(dataManager) + private val guideStatsService = mock(GuideStatsService::class.java) + private val controller = DataManagerController(dataManager, guideStatsService) @Test - fun `getStats delegates to dataManager`() { - val stats = ContentElementRepositoryInfoImpl(10, 3, 20, false, true) - `when`(dataManager.getStats()).thenReturn(stats) + fun `getStats delegates to guideStatsService for the current caller`() { + // No SecurityContext in a plain unit test -> caller resolves to null (anonymous). + val stats = GuideStats(content = ContentElementRepositoryInfoImpl(10, 3, 20, false, true)) + `when`(guideStatsService.stats(null)).thenReturn(stats) val result = controller.getStats() assertEquals(stats, result) - verify(dataManager).getStats() + verify(guideStatsService).stats(null) } @Test 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/DataManagerLoadReferencesIntegrationTest.kt b/src/test/kotlin/com/embabel/guide/rag/DataManagerLoadReferencesIntegrationTest.kt index 56ef3e0..9d88b50 100644 --- a/src/test/kotlin/com/embabel/guide/rag/DataManagerLoadReferencesIntegrationTest.kt +++ b/src/test/kotlin/com/embabel/guide/rag/DataManagerLoadReferencesIntegrationTest.kt @@ -38,7 +38,9 @@ import org.springframework.test.context.TestPropertySource @ImportAutoConfiguration(exclude = [McpClientAutoConfiguration::class]) @TestPropertySource( properties = [ - "guide.urls=", + "guide.content.versioned.base-url=https://docs.embabel.com/embabel-agent/guide/", + "guide.content.versioned.versions=", + "guide.content.supplementary=", "guide.directories[0]=./src/test/resources/sample-repo-for-ingestion" ] ) diff --git a/src/test/kotlin/com/embabel/guide/rag/IngestionRunnerTest.kt b/src/test/kotlin/com/embabel/guide/rag/IngestionRunnerTest.kt index e28b434..07c0809 100644 --- a/src/test/kotlin/com/embabel/guide/rag/IngestionRunnerTest.kt +++ b/src/test/kotlin/com/embabel/guide/rag/IngestionRunnerTest.kt @@ -1,9 +1,11 @@ package com.embabel.guide.rag -import com.embabel.agent.rag.neo.drivine.model.ContentElementRepositoryInfoImpl +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/kotlin/com/embabel/hub/HubServiceTest.kt b/src/test/kotlin/com/embabel/hub/HubServiceTest.kt index 1828ed7..68ee62e 100644 --- a/src/test/kotlin/com/embabel/hub/HubServiceTest.kt +++ b/src/test/kotlin/com/embabel/hub/HubServiceTest.kt @@ -2,6 +2,7 @@ package com.embabel.hub import com.embabel.guide.Neo4jPropertiesInitializer import com.embabel.guide.domain.GuideUserRepository +import com.embabel.guide.domain.PersonaRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -28,6 +29,9 @@ class HubServiceTest { @Autowired lateinit var guideUserRepository: GuideUserRepository + @Autowired + lateinit var personaRepository: PersonaRepository + private val passwordEncoder = BCryptPasswordEncoder() companion object { @@ -179,17 +183,18 @@ class HubServiceTest { // Verify initial persona is the default val before = guideUserRepository.findByWebUserId(webUserId).orElseThrow() - assertEquals("adaptive", before.core.persona) + assertEquals("adaptive", before.persona.name) - // When: Update persona via the same path the frontend uses - val updatedUser = service.updatePersona(webUserId, "expert") + // When: Update persona via the same path the frontend uses (by persona ID) + val jessePersona = personaRepository.findByNameAndOwner("jesse", PersonaRepository.SYSTEM_OWNER_ID)!! + val updatedUser = service.updatePersona(webUserId, jessePersona.persona.id) // Then: The returned user has the new persona - assertEquals("expert", updatedUser.core.persona) + assertEquals("jesse", updatedUser.persona.name) // And: Re-reading from the DB also shows the new persona val reloaded = guideUserRepository.findByWebUserId(webUserId).orElseThrow() - assertEquals("expert", reloaded.core.persona, + assertEquals("jesse", reloaded.persona.name, "Persona should be persisted in Neo4j and visible on re-read") } @@ -207,13 +212,15 @@ class HubServiceTest { val registeredUser = service.registerUser(request) val webUserId = registeredUser.webUser!!.id - // When: Update persona twice - service.updatePersona(webUserId, "expert") - service.updatePersona(webUserId, "adaptive") + // When: Update persona twice (by persona ID) + val jessePersona = personaRepository.findByNameAndOwner("jesse", PersonaRepository.SYSTEM_OWNER_ID)!! + val adaptivePersona = personaRepository.findByNameAndOwner("adaptive", PersonaRepository.SYSTEM_OWNER_ID)!! + service.updatePersona(webUserId, jessePersona.persona.id) + service.updatePersona(webUserId, adaptivePersona.persona.id) // Then: The final value sticks val reloaded = guideUserRepository.findByWebUserId(webUserId).orElseThrow() - assertEquals("adaptive", reloaded.core.persona) + assertEquals("adaptive", reloaded.persona.name) } } 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