embeddingModel) {
this.dataManager = dataManager;
+ this.embeddingModel = embeddingModel;
}
@Override
@@ -92,6 +96,8 @@ private void printSummary(IngestionResult result, ContentElementRepositoryInfo s
sb.append(" Documents: ").append(stats.getDocumentCount()).append("\n");
sb.append(" Chunks: ").append(stats.getChunkCount()).append("\n");
sb.append(" Elements: ").append(stats.getContentElementCount()).append("\n");
+ embeddingModel.ifAvailable(model ->
+ sb.append(" Embedding dimensions (runtime model): ").append(model.dimensions()).append("\n"));
sb.append("\n");
sb.append(" Guide is running on port ").append(serverPort).append("\n");
diff --git a/src/main/java/com/embabel/guide/rag/RagMaintenanceController.java b/src/main/java/com/embabel/guide/rag/RagMaintenanceController.java
new file mode 100644
index 0000000..c8014e7
--- /dev/null
+++ b/src/main/java/com/embabel/guide/rag/RagMaintenanceController.java
@@ -0,0 +1,71 @@
+package com.embabel.guide.rag;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * Operator endpoints for shared Neo4j: scoped ContentElement purge and git revision reset.
+ *
+ * These are {@code permitAll} in {@link com.embabel.guide.chat.security.SecurityConfig} like
+ * {@code /api/v1/data/load-references}; do not expose Guide to untrusted networks without a reverse proxy.
+ */
+@RestController
+@RequestMapping("/api/v1/data")
+public class RagMaintenanceController {
+
+ private final RagContentMaintenanceService maintenanceService;
+
+ public RagMaintenanceController(RagContentMaintenanceService maintenanceService) {
+ this.maintenanceService = maintenanceService;
+ }
+
+ @PostMapping("/content-elements/purge-preview")
+ public ResponseEntity purgePreview(@RequestBody PurgePreviewRequest body) {
+ int limit = body.sampleLimit() != null ? body.sampleLimit() : 10;
+ RagContentMaintenanceService.PurgePreviewResult r = maintenanceService.previewPurge(
+ body.uriPrefix(), body.directory(), limit);
+ return ResponseEntity.ok(new PurgePreviewResponse(
+ r.getAppliedUriPrefix(), r.getMatchCount(), r.getSampleUris()));
+ }
+
+ @PostMapping("/content-elements/purge")
+ public ResponseEntity purge(@RequestBody PurgeExecuteRequest body) {
+ RagContentMaintenanceService.PurgeExecuteResult r = maintenanceService.executePurge(
+ body.uriPrefix(), body.directory(), body.confirm());
+ return ResponseEntity.ok(new PurgeExecuteResponse(r.getAppliedUriPrefix(), r.getDeletedCount()));
+ }
+
+ @PostMapping("/git-ingestion/revision/reset")
+ public ResponseEntity resetGitRevision(@RequestBody GitRevisionResetRequest body) {
+ if (body.directory() == null || body.directory().isBlank()) {
+ return ResponseEntity.badRequest().body(new GitRevisionResetResponse(
+ null, false, "directory is required"));
+ }
+ try {
+ RagContentMaintenanceService.GitRevisionResetResult r =
+ maintenanceService.resetGitIngestionRevision(body.directory());
+ return ResponseEntity.ok(new GitRevisionResetResponse(
+ r.getAbsolutePath(), r.getRemoved(), r.getMessage()));
+ } catch (java.io.IOException e) {
+ return ResponseEntity.internalServerError().body(new GitRevisionResetResponse(
+ null, false, "Failed to save revision file: " + e.getMessage()));
+ }
+ }
+
+ public record PurgePreviewRequest(String uriPrefix, String directory, Integer sampleLimit) {}
+
+ public record PurgePreviewResponse(String appliedUriPrefix, long matchCount, List sampleUris) {}
+
+ public record PurgeExecuteRequest(String uriPrefix, String directory, boolean confirm) {}
+
+ public record PurgeExecuteResponse(String appliedUriPrefix, long deletedCount) {}
+
+ public record GitRevisionResetRequest(String directory) {}
+
+ public record GitRevisionResetResponse(String absolutePath, boolean removed, String message) {}
+}
diff --git a/src/main/kotlin/com/embabel/guide/GuideProperties.kt b/src/main/kotlin/com/embabel/guide/GuideProperties.kt
index 77fcab7..83959cc 100644
--- a/src/main/kotlin/com/embabel/guide/GuideProperties.kt
+++ b/src/main/kotlin/com/embabel/guide/GuideProperties.kt
@@ -46,6 +46,8 @@ data class ContentConfig(
* @param content content source configuration (versioned docs + supplementary)
* @param directories optional list of local directory paths to ingest (full tree); resolved like projectsPath
* @param toolGroups toolGroups, such as "web", that are allowed
+ * @param fetchRoutes optional content-fetcher route patterns
+ * @param gitIngestion optional git-based incremental ingestion for configured directories
*/
@Validated
@ConfigurationProperties(prefix = "guide")
@@ -66,11 +68,37 @@ data class GuideProperties(
val directories: List?,
val toolGroups: Set,
val fetchRoutes: List = emptyList(),
+ @NestedConfigurationProperty val gitIngestion: GitIngestion? = null,
+ @NestedConfigurationProperty val spddProjection: SpddProjection = SpddProjection(),
) {
/** All URLs to ingest (versioned + supplementary). */
val urls: List get() = content.allUrls()
+ /**
+ * Leg 3 SPDD entity projection (SPIKE-001). Independent of leg 2 RAG directory ingest.
+ *
+ * @param enabled activates the projection beans, HTTP operator API, and MCP tools
+ * @param defaultRootPath project root scanned when the load request carries no rootPath
+ * @param allowedRoots additional roots a load request may override to; the resolved
+ * override must live under one of these (or under [defaultRootPath]).
+ * Guards the permit-all operator endpoint against arbitrary path scans.
+ */
+ data class SpddProjection(
+ val enabled: Boolean = false,
+ val defaultRootPath: String = ".",
+ val allowedRoots: List = emptyList(),
+ )
+
+ /**
+ * When [enabled], each configured directory that is a git work tree ingests only files changed since the
+ * last stored commit (see [stateFile]). Non-git directories are always fully ingested.
+ */
+ data class GitIngestion(
+ val enabled: Boolean = false,
+ val stateFile: String = "scripts/user-config/ingestion-git-revisions.json",
+ )
+
fun toolNamingStrategy(): StringTransformer = StringTransformer { name -> toolPrefix + name }
/**
diff --git a/src/main/kotlin/com/embabel/guide/chat/security/SecurityConfig.kt b/src/main/kotlin/com/embabel/guide/chat/security/SecurityConfig.kt
index f51b3a6..a4c5ced 100644
--- a/src/main/kotlin/com/embabel/guide/chat/security/SecurityConfig.kt
+++ b/src/main/kotlin/com/embabel/guide/chat/security/SecurityConfig.kt
@@ -95,6 +95,10 @@ class SecurityConfig(
"/api/hub/refresh",
"/api/hub/feedback",
"/api/v1/data/load-references",
+ "/api/v1/data/content-elements/purge-preview",
+ "/api/v1/data/content-elements/purge",
+ "/api/v1/data/git-ingestion/revision/reset",
+ "/api/v1/data/spdd-projection/load",
"/api/hub/integrations/keys/validate",
"/api/hub/oauth/*/callback",
).permitAll()
@@ -104,6 +108,9 @@ class SecurityConfig(
"/api/hub/personas",
"/api/hub/sessions",
"/api/v1/data/stats",
+ "/api/v1/data/spdd-projection/stats",
+ "/api/v1/data/spdd-projection/work/*",
+ "/api/v1/data/spdd-projection/area",
"/api/v1/deepgram/models",
"/api/hub/oauth/*/authorize",
"/api/hub/email/verify",
diff --git a/src/main/kotlin/com/embabel/guide/rag/RagContentMaintenanceService.kt b/src/main/kotlin/com/embabel/guide/rag/RagContentMaintenanceService.kt
new file mode 100644
index 0000000..72edaba
--- /dev/null
+++ b/src/main/kotlin/com/embabel/guide/rag/RagContentMaintenanceService.kt
@@ -0,0 +1,158 @@
+package com.embabel.guide.rag
+
+import com.embabel.agent.rag.graph.DrivineCypherSearch
+import com.embabel.guide.GuideProperties
+import org.drivine.query.QuerySpecification
+import org.drivine.manager.PersistenceManager
+import org.slf4j.LoggerFactory
+import org.springframework.beans.factory.annotation.Qualifier
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+import java.io.File
+import java.io.IOException
+import java.nio.file.Path
+
+/**
+ * Operator helpers for shared Neo4j: purge ContentElement nodes by URI prefix and reset git-ingestion state.
+ */
+@Service
+class RagContentMaintenanceService(
+ private val guideProperties: GuideProperties,
+ @Qualifier("neo") private val persistenceManager: PersistenceManager,
+) {
+
+ private val log = LoggerFactory.getLogger(javaClass)
+
+ /** Not a Spring bean: [DrivineCypherSearch] is final and cannot be proxied. */
+ private val cypherSearch = DrivineCypherSearch(persistenceManager)
+
+ /**
+ * Resolves [directory] (YAML-style path) to a [file:] URI prefix, or trims [uriPrefix].
+ * Leading/trailing whitespace is trimmed. At least one of the two must be non-blank.
+ */
+ fun resolveAppliedPrefix(uriPrefix: String?, directory: String?): String {
+ val trimmedPrefix = uriPrefix?.trim().orEmpty()
+ val trimmedDir = directory?.trim().orEmpty()
+ if (trimmedPrefix.isNotEmpty() && trimmedDir.isNotEmpty()) {
+ throw IllegalArgumentException("Provide only one of uriPrefix or directory, not both")
+ }
+ if (trimmedPrefix.isEmpty() && trimmedDir.isEmpty()) {
+ throw IllegalArgumentException("Provide uriPrefix or directory")
+ }
+ if (trimmedDir.isNotEmpty()) {
+ val abs = guideProperties.resolvePath(trimmedDir)
+ return File(abs).toURI().toString()
+ }
+ return trimmedPrefix
+ }
+
+ fun previewPurge(uriPrefix: String?, directory: String?, sampleLimit: Int): PurgePreviewResult {
+ val prefix = resolveAppliedPrefix(uriPrefix, directory)
+ validatePrefixSafety(prefix)
+ val count = countByUriPrefix(prefix)
+ val samples = sampleUris(prefix, sampleLimit.coerceIn(1, 50))
+ return PurgePreviewResult(prefix, count, samples)
+ }
+
+ @Transactional(transactionManager = "drivineTransactionManager")
+ fun executePurge(uriPrefix: String?, directory: String?, confirm: Boolean): PurgeExecuteResult {
+ if (!confirm) {
+ throw IllegalArgumentException("confirm must be true to delete content")
+ }
+ val prefix = resolveAppliedPrefix(uriPrefix, directory)
+ validatePrefixSafety(prefix)
+ val before = countByUriPrefix(prefix)
+ deleteByUriPrefix(prefix)
+ log.warn("Purged {} ContentElement node(s) with uri STARTS WITH '{}'", before, prefix)
+ return PurgeExecuteResult(prefix, before)
+ }
+
+ /**
+ * Clears stored git HEAD for [directory] so the next incremental ingest does a full tree
+ * (or first-time behavior). Requires [GuideProperties.gitIngestion] enabled.
+ */
+ @Throws(IOException::class)
+ fun resetGitIngestionRevision(directory: String): GitRevisionResetResult {
+ val git = guideProperties.gitIngestion
+ ?: return GitRevisionResetResult(null, false, "guide.git-ingestion is not configured")
+ if (!git.enabled) {
+ return GitRevisionResetResult(null, false, "guide.git-ingestion.enabled is false")
+ }
+ val abs = guideProperties.resolvePath(directory.trim())
+ val store = GitIngestionRevisionStore(Path.of(guideProperties.resolvePath(git.stateFile)))
+ store.load()
+ val removed = store.removeRevision(abs)
+ if (store.isDirty) {
+ store.save()
+ }
+ val msg = if (removed) {
+ "Removed revision entry for $abs"
+ } else {
+ "No revision entry existed for $abs"
+ }
+ log.info("Git ingestion revision reset: {}", msg)
+ return GitRevisionResetResult(abs, removed, msg)
+ }
+
+ private fun validatePrefixSafety(prefix: String) {
+ require(prefix.length >= MIN_PREFIX_LENGTH) {
+ "uriPrefix too short (min $MIN_PREFIX_LENGTH chars) — refusing to avoid accidental mass delete"
+ }
+ }
+
+ private fun countByUriPrefix(prefix: String): Long {
+ val cypher = """
+ MATCH (n:ContentElement)
+ WHERE n.uri STARTS WITH ${'$'}prefix
+ RETURN count(n) AS cnt
+ """.trimIndent().let { "\n$it" }
+ return cypherSearch.queryForInt(cypher, mapOf("prefix" to prefix)).toLong()
+ }
+
+ private fun sampleUris(prefix: String, limit: Int): List {
+ val cypher = """
+ MATCH (n:ContentElement)
+ WHERE n.uri STARTS WITH ${'$'}prefix
+ RETURN n.uri AS uri
+ LIMIT ${'$'}lim
+ """.trimIndent().let { "\n$it" }
+ val qr = cypherSearch.query(
+ "purge preview sample uris",
+ cypher,
+ mapOf("prefix" to prefix, "lim" to limit),
+ log,
+ )
+ return qr.items().mapNotNull { row -> row["uri"]?.toString() }.distinct()
+ }
+
+ private fun deleteByUriPrefix(prefix: String) {
+ val cypher = """
+ MATCH (n:ContentElement)
+ WHERE n.uri STARTS WITH ${'$'}prefix
+ DETACH DELETE n
+ """.trimIndent().let { "\n$it" }
+ val spec = QuerySpecification.withStatement(cypher).bind(mapOf("prefix" to prefix))
+ persistenceManager.execute(spec)
+ }
+
+ data class PurgePreviewResult(
+ val appliedUriPrefix: String,
+ val matchCount: Long,
+ val sampleUris: List,
+ )
+
+ data class PurgeExecuteResult(
+ val appliedUriPrefix: String,
+ val deletedCount: Long,
+ )
+
+ data class GitRevisionResetResult(
+ val absolutePath: String?,
+ val removed: Boolean,
+ val message: String,
+ )
+
+ companion object {
+ private const val MIN_PREFIX_LENGTH = 8
+ }
+}
diff --git a/src/main/kotlin/com/embabel/guide/rag/RagMaintenanceExceptionHandler.kt b/src/main/kotlin/com/embabel/guide/rag/RagMaintenanceExceptionHandler.kt
new file mode 100644
index 0000000..bcf3df2
--- /dev/null
+++ b/src/main/kotlin/com/embabel/guide/rag/RagMaintenanceExceptionHandler.kt
@@ -0,0 +1,14 @@
+package com.embabel.guide.rag
+
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.ExceptionHandler
+import org.springframework.web.bind.annotation.RestControllerAdvice
+
+@RestControllerAdvice(assignableTypes = [RagMaintenanceController::class])
+class RagMaintenanceExceptionHandler {
+
+ @ExceptionHandler(IllegalArgumentException::class)
+ fun badRequest(ex: IllegalArgumentException): ResponseEntity