>();
+ 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