From bf4fa742c19f5eda5a22500e31d662d74d845e5e Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Thu, 4 Jun 2026 15:36:39 +0000 Subject: [PATCH] Unify assistant auto-capture policy --- .../application/rag/LessonAutoCapture.kt | 135 ++++++++++++++++-- .../application/rag/LessonExtractor.kt | 117 ++++++++++++++- .../assistant/config/RagProperties.kt | 7 + .../assistant/domain/port/RetrievalPort.kt | 32 ++++- .../integration/KnowledgeMcpClient.kt | 38 +++-- .../application/rag/LessonAutoCaptureTest.kt | 80 ++++++++++- .../application/rag/LessonExtractorTest.kt | 26 +++- .../integration/KnowledgeMcpClientTest.kt | 63 +++++++- .../knowledge/mcp/CaptureMcpTools.kt | 7 + .../knowledge/mcp/McpToolsTest.kt | 11 +- 10 files changed, 459 insertions(+), 57 deletions(-) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCapture.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCapture.kt index 4eed4f0c..71d270de 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCapture.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCapture.kt @@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Component import java.time.Duration +import java.util.Locale /** * Orchestrates the auto-capture pipeline: @@ -21,9 +22,10 @@ import java.time.Duration * * `capture(sessionId)` is the public entrypoint and is `@Async` so * the WS handler's hot path stays out of the LLM/MCP round trip. - * The token bucket holds capacity = 3 with a 15-minute refill, so a - * single session can't flood the KB even if every turn is - * marker-flagged. + * The token bucket defaults to capacity = 3 with a 15-minute refill, + * so a single session can't flood the KB even if every turn is + * marker-flagged. Dedupe runs before token consumption so skipped + * candidates do not burn the write budget. */ @Component open class LessonAutoCapture( @@ -37,8 +39,8 @@ open class LessonAutoCapture( private val log = LoggerFactory.getLogger(LessonAutoCapture::class.java) private val bucket = TokenBucket( - capacity = BUCKET_CAPACITY, - refillInterval = Duration.ofMinutes(BUCKET_REFILL_MINUTES), + capacity = rag.autoCaptureSessionCapacity, + refillInterval = Duration.ofMinutes(rag.autoCaptureBucketRefillMinutes), ) @Async @@ -48,7 +50,7 @@ open class LessonAutoCapture( val history = turns.findBySessionId(sessionId, limit = TURN_FETCH_LIMIT) val candidates = extractor.extract(resolved.workspace, history) if (candidates.isEmpty()) return - ingestUpToBucket(sessionId, resolved.workspace, candidates) + ingestUpToBucket(resolved.session, resolved.workspace, candidates) } private data class Resolved( @@ -56,6 +58,12 @@ open class LessonAutoCapture( val workspace: Workspace, ) + private data class CapturePolicyContext( + val sessionId: String, + val agentKind: String, + val inferredScope: String, + ) + private fun resolveSession(sessionId: WorkspaceAgentSessionId): Resolved? { val session = sessions.findById(sessionId) ?: return null val workspace = workspaces.findById(session.workspaceId) ?: return null @@ -63,25 +71,122 @@ open class LessonAutoCapture( } private fun ingestUpToBucket( - sessionId: WorkspaceAgentSessionId, + session: WorkspaceAgentSession, workspace: Workspace, candidates: List, ) { - val key = sessionId.toString() - val scope = ScopeInference.scopeFor(workspace) + val context = + CapturePolicyContext( + sessionId = session.id.toString(), + agentKind = session.kind.name.lowercase(), + inferredScope = ScopeInference.scopeFor(workspace), + ) for (c in candidates) { - if (!bucket.tryAcquire(key)) { - log.info("auto-capture bucket exhausted for session {}", key) + if (isDuplicate(context, c)) continue + if (!bucket.tryAcquire(context.sessionId)) { + log.info("auto-capture bucket exhausted for session {}", context.sessionId) return } - runCatching { knowledgeWrite.ingestNote(c.title, c.body, scope, c.tags) } - .onFailure { log.warn("auto-capture ingest failed: {}", it.message) } + ingestCandidate(context, c) } } + private fun isDuplicate( + context: CapturePolicyContext, + candidate: LessonExtractor.Candidate, + ): Boolean { + val duplicate = + runCatching { knowledgeWrite.findDuplicateEvidence(candidate.dedupeQuery, rag.autoCaptureDedupeScore) } + .onFailure { log.warn("auto-capture dedupe failed: {}", it.message) } + .getOrNull() + if (duplicate == null) return false + log.info( + "auto-capture skipped duplicate for session {} source={} score={}", + context.sessionId, + duplicate.source, + duplicate.score, + ) + return true + } + + private fun ingestCandidate( + context: CapturePolicyContext, + candidate: LessonExtractor.Candidate, + ) { + val request = captureRequest(context, candidate) + runCatching { knowledgeWrite.ingestNote(request) } + .onFailure { log.warn("auto-capture ingest failed: {}", it.message) } + } + + private fun captureRequest( + context: CapturePolicyContext, + candidate: LessonExtractor.Candidate, + ): KnowledgeWritePort.CaptureRequest { + val scope = captureScope(context, candidate) + return KnowledgeWritePort.CaptureRequest( + title = candidate.title, + body = captureBody(context, candidate, scope), + scope = scope, + tags = captureTags(context, candidate), + source = "assistant-ui:auto-capture:${context.sessionId}", + sessionId = context.sessionId, + confidence = candidate.confidence, + ) + } + + private fun captureScope( + context: CapturePolicyContext, + candidate: LessonExtractor.Candidate, + ): String = + if (candidate.confidence >= rag.autoCaptureScopedMinConfidence) { + context.inferredScope + } else { + INBOX_SCOPE + } + + private fun captureTags( + context: CapturePolicyContext, + candidate: LessonExtractor.Candidate, + ): List = + ( + candidate.tags + + listOf( + "agent:${context.agentKind}", + confidenceTag(candidate.confidence), + "dedupe:checked", + ) + + candidate.triggerTerms.take(TRIGGER_TAG_LIMIT).map { "trigger:$it" } + ).distinct() + + private fun captureBody( + context: CapturePolicyContext, + candidate: LessonExtractor.Candidate, + scope: String, + ): String = + buildString { + append(candidate.body) + append("\n\nCapture policy:\n") + append("- source: assistant-ui:auto-capture:${context.sessionId}\n") + append("- session_id: ${context.sessionId}\n") + append("- agent_kind: ${context.agentKind}\n") + append("- inferred_scope: ${context.inferredScope}\n") + append("- capture_scope: $scope\n") + append("- confidence: ${String.format(Locale.ROOT, "%.2f", candidate.confidence)}\n") + append("- dedupe: checked recall threshold ${rag.autoCaptureDedupeScore}; no duplicate hit\n") + } + + private fun confidenceTag(confidence: Double): String = + when { + confidence >= HIGH_CONFIDENCE -> "confidence:high" + confidence >= MEDIUM_CONFIDENCE -> "confidence:medium" + else -> "confidence:low" + } + companion object { - private const val BUCKET_CAPACITY = 3 - private const val BUCKET_REFILL_MINUTES = 15L private const val TURN_FETCH_LIMIT = 50 + private const val INBOX_SCOPE = "_inbox" + private const val HIGH_CONFIDENCE = 0.75 + private const val MEDIUM_CONFIDENCE = 0.55 + private const val TRIGGER_TAG_LIMIT = 6 } } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractor.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractor.kt index 4280220a..3ef9d204 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractor.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractor.kt @@ -15,9 +15,9 @@ import org.springframework.stereotype.Component * (>= minBodyChars) AND either contains an explicit lesson * marker ("TIL:", "Note:", "Lesson:") OR the user turn is a * question (ends with "?" or starts with "how/why/what/..."). - * - The title is the user's prompt, truncated; the body is - * "Q: \nA: " so the captured note has enough - * context to be useful in a future recall. + * - The title is the user's prompt, truncated; the body includes + * trigger terms, short evidence excerpts, and a Q/A lesson section + * so the captured note can be reviewed and recalled later. * * The actual quality bar is enforced at the curator step downstream * (it routes low-confidence notes into `_inbox/_needs-review`), so @@ -37,6 +37,9 @@ class LessonExtractor( val body: String, val tags: List, val confidence: Double, + val triggerTerms: List, + val excerpts: List, + val dedupeQuery: String, ) private data class Pair( @@ -92,16 +95,23 @@ class LessonExtractor( ): Candidate { val tags = buildList { - add("source:agents-ui") + add("auto-capture") + add("assistant-ui") add("kind:turn-pair") if (markerRegex.containsMatchIn(pair.agentBody)) add("has-marker") workspace.repoUrl?.let { add("repo:${ScopeInference.repoSlug(it)}") } } + val triggerTerms = triggerTerms(workspace, pair.user.body, pair.agentBody) + val excerpts = excerpts(pair.user.body, pair.agentBody) + val title = makeTitle(workspace, pair.user.body) return Candidate( - title = makeTitle(workspace, pair.user.body), - body = makeBody(pair.user.body, pair.agentBody), + title = title, + body = makeBody(pair.user.body, pair.agentBody, triggerTerms, excerpts), tags = tags, confidence = scoreFor(pair.agentBody), + triggerTerms = triggerTerms, + excerpts = excerpts, + dedupeQuery = (listOf(title) + triggerTerms + excerpts).joinToString(" ").take(DEDUPE_QUERY_CHARS), ) } @@ -132,10 +142,20 @@ class LessonExtractor( private fun makeBody( userBody: String, agentBody: String, + triggerTerms: List, + excerpts: List, ): String { val q = userBody.trim().take(maxBodyChars / Q_FRACTION_DENOMINATOR) val a = agentBody.trim().take(maxBodyChars * A_FRACTION_NUMERATOR / A_FRACTION_DENOMINATOR) - return "Q: $q\n\nA: $a" + return buildString { + append("Trigger:\n") + triggerTerms.forEach { append("- ").append(it).append('\n') } + append("\nEvidence:\n") + excerpts.forEach { append("- ").append(it).append('\n') } + append("\nLesson:\n") + append("Q: ").append(q).append("\n\n") + append("A: ").append(a) + } } private fun scoreFor(agentBody: String): Double { @@ -145,6 +165,52 @@ class LessonExtractor( return (BASELINE + markerBonus + lengthBonus + codeFenceBonus).coerceIn(0.0, MAX_CONFIDENCE) } + private fun triggerTerms( + workspace: Workspace, + userBody: String, + agentBody: String, + ): List { + val text = "$userBody\n$agentBody" + val triggers = linkedSetOf() + workspace.repoUrl?.let { triggers += ScopeInference.repoSlug(it) } + pathRegex.findAll(text).take(MAX_PATH_TRIGGERS).forEach { triggers += it.value } + commandRegex.findAll(text).take(MAX_COMMAND_TRIGGERS).forEach { triggers += it.value } + wordRegex.findAll(userBody).forEach { match -> + val word = match.value.lowercase() + if (word !in stopWords) triggers += word + if (triggers.size >= MAX_TRIGGER_TERMS) return triggers.toList() + } + wordRegex.findAll(agentBody).forEach { match -> + val word = match.value.lowercase() + if (word !in stopWords) triggers += word + if (triggers.size >= MAX_TRIGGER_TERMS) return triggers.toList() + } + return triggers.take(MAX_TRIGGER_TERMS) + } + + private fun excerpts( + userBody: String, + agentBody: String, + ): List = + listOfNotNull( + compactExcerpt("user", userBody), + compactExcerpt("agent", agentBody), + ) + + private fun compactExcerpt( + label: String, + text: String, + ): String? { + val normalized = + text + .lines() + .map(String::trim) + .filter(String::isNotBlank) + .joinToString(" ") + if (normalized.isBlank()) return null + return "$label: ${normalized.take(EXCERPT_CHARS)}" + } + companion object { const val MIN_BODY_CHARS = 240 const val MAX_BODY_CHARS = 4_000 @@ -161,5 +227,42 @@ class LessonExtractor( private const val LENGTH_DIVISOR = 4_000.0 private const val CODE_FENCE_BONUS = 0.05 private const val MAX_CONFIDENCE = 0.85 + private const val MAX_TRIGGER_TERMS = 14 + private const val MAX_PATH_TRIGGERS = 4 + private const val MAX_COMMAND_TRIGGERS = 4 + private const val EXCERPT_CHARS = 320 + private const val DEDUPE_QUERY_CHARS = 1_200 + + private val pathRegex = Regex("""[\w./-]+\.(?:kt|kts|ts|tsx|vue|py|ya?ml|sh|nix|md|sql|json|toml)""") + private val commandRegex = + Regex("""(?:\./gradlew|gradle|npm|pnpm|kubectl|helm|flux|kustomize|docker|git|gh|nix|uv|python3?)\b""") + private val wordRegex = Regex("""[A-Za-z][A-Za-z0-9_-]{3,}""") + private val stopWords = + setOf( + "about", + "after", + "agent", + "also", + "answer", + "because", + "before", + "does", + "from", + "have", + "into", + "should", + "that", + "their", + "there", + "this", + "turn", + "what", + "when", + "where", + "which", + "with", + "work", + "would", + ) } } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/RagProperties.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/RagProperties.kt index ce6848ff..b276f4c8 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/RagProperties.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/RagProperties.kt @@ -20,4 +20,11 @@ data class RagProperties( // Passed to knowledge.recall as the `mode` parameter. `deep` runs // hybrid retrieval + listwise reranker on the knowledge-api side. val recallMode: String = "deep", + // Assistant-ui auto-capture uses the same policy shape as CLI + // digest hooks: bounded writes, duplicate suppression, explicit + // confidence, and review routing for weak candidates. + val autoCaptureSessionCapacity: Int = 3, + val autoCaptureBucketRefillMinutes: Long = 15, + val autoCaptureDedupeScore: Double = 0.86, + val autoCaptureScopedMinConfidence: Double = 0.55, ) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/RetrievalPort.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/RetrievalPort.kt index 2fdb6815..3b66e045 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/RetrievalPort.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/RetrievalPort.kt @@ -21,16 +21,38 @@ interface RetrievalPort { } /** - * Driven port for capturing what an agent learned. Right now we - * ingest the user's prompt + agent's turn as a single note; the - * Block protocol will eventually let us emit a structured "lesson" - * block that lands here directly. + * Driven port for capturing what an agent learned. Auto-capture + * sends explicit provenance, confidence, and duplicate-check context; + * manual callers can still use the simple convenience overload. */ interface KnowledgeWritePort { + data class CaptureRequest( + val title: String, + val body: String, + val scope: String, + val tags: List = emptyList(), + val source: String? = null, + val sessionId: String? = null, + val confidence: Double? = null, + ) + + data class DuplicateEvidence( + val id: String?, + val source: String, + val score: Double, + ) + fun ingestNote( title: String, body: String, scope: String, tags: List = emptyList(), - ) + ) = ingestNote(CaptureRequest(title = title, body = body, scope = scope, tags = tags)) + + fun ingestNote(request: CaptureRequest) + + fun findDuplicateEvidence( + query: String, + minScore: Double, + ): DuplicateEvidence? = null } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClient.kt index bf6825e7..23d7f5fa 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClient.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClient.kt @@ -79,26 +79,40 @@ class KnowledgeMcpClient( ) } - override fun ingestNote( - title: String, - body: String, - scope: String, - tags: List, - ) { + override fun ingestNote(request: KnowledgeWritePort.CaptureRequest) { if (!props.enabled) return runCatching { + val args = + mutableMapOf( + "title" to request.title, + "body" to request.body, + "scope" to request.scope, + "tags" to request.tags, + ) + request.source?.let { args["source"] = it } + request.sessionId?.let { args["session_id"] = it } + request.confidence?.let { args["confidence"] = it } callTool( CAPTURE_LESSON_TOOL, - mapOf( - "title" to title, - "body" to body, - "scope" to scope, - "tags" to tags, - ), + args, ) }.onFailure { log.warn("knowledge.capture failed: {}", it.message) } } + override fun findDuplicateEvidence( + query: String, + minScore: Double, + ): KnowledgeWritePort.DuplicateEvidence? = + retrieve(query, limit = 1) + .firstOrNull { it.score >= minScore } + ?.let { hit -> + KnowledgeWritePort.DuplicateEvidence( + id = hit.id, + source = hit.source, + score = hit.score, + ) + } + private fun callTool( name: String, arguments: Map, diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCaptureTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCaptureTest.kt index 09a4867b..8d1bcaa9 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCaptureTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCaptureTest.kt @@ -43,20 +43,35 @@ class LessonAutoCaptureTest { val s = session(ws.id) every { sessions.findById(s.id) } returns s every { workspaces.findById(ws.id) } returns ws + every { kbWrite.findDuplicateEvidence(any(), any()) } returns null every { turns.findBySessionId(s.id, any()) } returns listOf( turn(TurnRole.USER, "how does flannel work over tailscale?", 1, s.id), - turn(TurnRole.AGENT, "It uses --flannel-iface=tailscale0. ".repeat(20), 2, s.id), + turn(TurnRole.AGENT, "Lesson: It uses --flannel-iface=tailscale0. ".repeat(40), 2, s.id), ) capture.capture(s.id) verify { kbWrite.ingestNote( - match { it.contains("how does flannel") }, - match { it.startsWith("Q: how does flannel") }, - "project:personal-stack", - match { it.contains("source:agents-ui") }, + match { + it.title.contains("how does flannel") && + it.body.contains("Trigger:") && + it.body.contains("Capture policy:") && + it.scope == "project:personal-stack" && + it.source == "assistant-ui:auto-capture:${s.id}" && + it.sessionId == s.id.toString() && + (it.confidence ?: 0.0) >= 0.55 && + it.tags.containsAll( + listOf( + "auto-capture", + "assistant-ui", + "agent:claude", + "dedupe:checked", + "repo:personal-stack", + ), + ) + }, ) } } @@ -66,7 +81,7 @@ class LessonAutoCaptureTest { val disabledRag = rag.copy(enabled = false) val withDisabled = LessonAutoCapture(workspaces, sessions, turns, extractor, kbWrite, disabledRag) withDisabled.capture(WorkspaceAgentSessionId.random()) - verify(exactly = 0) { kbWrite.ingestNote(any(), any(), any(), any()) } + verify(exactly = 0) { kbWrite.ingestNote(any()) } } @Test @@ -75,6 +90,7 @@ class LessonAutoCaptureTest { val s = session(ws.id) every { sessions.findById(s.id) } returns s every { workspaces.findById(ws.id) } returns ws + every { kbWrite.findDuplicateEvidence(any(), any()) } returns null // Five capture-worthy pairs in a row, bucket capacity is 3. val pairs = (1..5).flatMap { i -> @@ -87,7 +103,57 @@ class LessonAutoCaptureTest { capture.capture(s.id) - verify(exactly = 3) { kbWrite.ingestNote(any(), any(), any(), any()) } + verify(exactly = 3) { kbWrite.ingestNote(any()) } + } + + @Test + fun `capture skips likely duplicates before consuming write budget`() { + val ws = workspace(repoUrl = "git@github.com:owner/personal-stack.git") + val s = session(ws.id) + every { sessions.findById(s.id) } returns s + every { workspaces.findById(ws.id) } returns ws + every { kbWrite.findDuplicateEvidence(any(), 0.86) } returns + KnowledgeWritePort.DuplicateEvidence( + id = "01KDUPLICATE", + source = "kb:project:personal-stack:Existing lesson", + score = 0.91, + ) + every { turns.findBySessionId(s.id, any()) } returns + listOf( + turn(TurnRole.USER, "how does duplicate capture work?", 1, s.id), + turn(TurnRole.AGENT, "Lesson: duplicate answer. ".repeat(40), 2, s.id), + ) + + capture.capture(s.id) + + verify(exactly = 0) { kbWrite.ingestNote(any()) } + } + + @Test + fun `capture routes weak candidates to inbox for curator review`() { + val ws = workspace(repoUrl = "git@github.com:owner/personal-stack.git") + val s = session(ws.id) + every { sessions.findById(s.id) } returns s + every { workspaces.findById(ws.id) } returns ws + every { kbWrite.findDuplicateEvidence(any(), any()) } returns null + every { turns.findBySessionId(s.id, any()) } returns + listOf( + turn(TurnRole.USER, "how does low confidence capture work?", 1, s.id), + turn(TurnRole.AGENT, "short but still capture-worthy explanation. ".repeat(8), 2, s.id), + ) + + capture.capture(s.id) + + verify { + kbWrite.ingestNote( + match { + it.scope == "_inbox" && + it.body.contains("inferred_scope: project:personal-stack") && + it.body.contains("capture_scope: _inbox") && + it.tags.contains("confidence:low") + }, + ) + } } private fun workspace(repoUrl: String? = null) = diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractorTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractorTest.kt index 7c96f937..8f0fed56 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractorTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractorTest.kt @@ -25,8 +25,15 @@ class LessonExtractorTest { val out = extractor.extract(workspace(), turns) assertThat(out).hasSize(1) assertThat(out[0].title).contains("how do I configure flannel") - assertThat(out[0].body).contains("Q: how do I").contains("A: Use --flannel") - assertThat(out[0].tags).contains("source:agents-ui", "kind:turn-pair") + assertThat(out[0].body) + .contains("Trigger:") + .contains("Evidence:") + .contains("Q: how do I") + .contains("A: Use --flannel") + assertThat(out[0].triggerTerms).contains("flannel", "tailscale") + assertThat(out[0].excerpts).hasSize(2) + assertThat(out[0].dedupeQuery).contains("flannel") + assertThat(out[0].tags).contains("auto-capture", "assistant-ui", "kind:turn-pair") } @Test @@ -83,11 +90,18 @@ class LessonExtractorTest { val ws = workspace(repoUrl = "git@github.com:owner/personal-stack.git") val turns = listOf( - turn(TurnRole.USER, "how does this work?", 1), - turn(TurnRole.AGENT, "long substantive answer. ".repeat(20), 2), + turn(TurnRole.USER, "how does services/assistant-api/src/main/App.kt work with kubectl?", 1), + turn(TurnRole.AGENT, "long substantive answer using ./gradlew and kubectl. ".repeat(20), 2), + ) + val out = extractor.extract(ws, turns).single() + assertThat(out.tags).contains("repo:personal-stack") + assertThat(out.triggerTerms) + .contains( + "personal-stack", + "services/assistant-api/src/main/App.kt", + "kubectl", + "./gradlew", ) - val tags = extractor.extract(ws, turns).single().tags - assertThat(tags).contains("repo:personal-stack") } private fun turn( diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClientTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClientTest.kt index 5765a745..f4494353 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClientTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClientTest.kt @@ -1,6 +1,7 @@ package com.jorisjonkers.personalstack.assistant.infrastructure.integration import com.jorisjonkers.personalstack.assistant.config.RagProperties +import com.jorisjonkers.personalstack.assistant.domain.port.KnowledgeWritePort import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.springframework.http.HttpHeaders @@ -90,6 +91,9 @@ class KnowledgeMcpClientTest { .andExpect(jsonPath("$.params.arguments.title").value("Canonical MCP names")) .andExpect(jsonPath("$.params.arguments.body").value("Assistant API uses dot-form tool names.")) .andExpect(jsonPath("$.params.arguments.scope").value("project:personal-stack")) + .andExpect(jsonPath("$.params.arguments.source").value("assistant-ui:auto-capture:session-1")) + .andExpect(jsonPath("$.params.arguments.session_id").value("session-1")) + .andExpect(jsonPath("$.params.arguments.confidence").value(0.74)) .andExpect(jsonPath("$.params.arguments.tags[0]").value("mcp")) .andRespond( withSuccess( @@ -99,15 +103,66 @@ class KnowledgeMcpClientTest { ) client.ingestNote( - title = "Canonical MCP names", - body = "Assistant API uses dot-form tool names.", - scope = "project:personal-stack", - tags = listOf("mcp"), + KnowledgeWritePort.CaptureRequest( + title = "Canonical MCP names", + body = "Assistant API uses dot-form tool names.", + scope = "project:personal-stack", + tags = listOf("mcp"), + source = "assistant-ui:auto-capture:session-1", + sessionId = "session-1", + confidence = 0.74, + ), ) server.verify() } + @Test + fun `findDuplicateEvidence returns the top recall hit when it clears the threshold`() { + val builder = RestClient.builder() + val server = MockRestServiceServer.bindTo(builder).build() + val client = KnowledgeMcpClient(builder.build(), props()) + + server + .expect(requestTo("http://kb/mcp")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.params.name").value("knowledge.recall")) + .andExpect(jsonPath("$.params.arguments.query").value("duplicate query")) + .andExpect(jsonPath("$.params.arguments.limit").value(1)) + .andRespond( + withSuccess( + """ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "structuredContent": { + "hits": [ + { + "id": "01KDUPLICATE", + "scope": "project:personal-stack", + "title": "Existing capture", + "snippet": "Existing body.", + "score": 0.9 + } + ] + } + } + } + """.trimIndent(), + MediaType.APPLICATION_JSON, + ), + ) + + val duplicate = client.findDuplicateEvidence("duplicate query", minScore = 0.86) + + assertThat(duplicate).isNotNull + assertThat(duplicate!!.id).isEqualTo("01KDUPLICATE") + assertThat(duplicate.source).isEqualTo("kb:project:personal-stack:Existing capture") + assertThat(duplicate.score).isEqualTo(0.9) + server.verify() + } + @Test fun `disabled client does not call the MCP endpoint`() { val builder = RestClient.builder() diff --git a/services/knowledge-api/src/main/kotlin/com/jorisjonkers/personalstack/knowledge/mcp/CaptureMcpTools.kt b/services/knowledge-api/src/main/kotlin/com/jorisjonkers/personalstack/knowledge/mcp/CaptureMcpTools.kt index 5a2711b4..a1bb9673 100644 --- a/services/knowledge-api/src/main/kotlin/com/jorisjonkers/personalstack/knowledge/mcp/CaptureMcpTools.kt +++ b/services/knowledge-api/src/main/kotlin/com/jorisjonkers/personalstack/knowledge/mcp/CaptureMcpTools.kt @@ -128,6 +128,13 @@ class CaptureMcpTools( "scope" to mapOf("type" to "string", "description" to SCOPE_DESCRIPTION), "title" to mapOf("type" to "string"), "body" to mapOf("type" to "string"), + "source" to + mapOf( + "type" to "string", + "description" to + "Capture provenance marker, for example " + + "`assistant-ui:auto-capture:` or `codex:auto-capture:git-commit`.", + ), "session_id" to mapOf( "type" to "string", diff --git a/services/knowledge-api/src/test/kotlin/com/jorisjonkers/personalstack/knowledge/mcp/McpToolsTest.kt b/services/knowledge-api/src/test/kotlin/com/jorisjonkers/personalstack/knowledge/mcp/McpToolsTest.kt index 6b0722a2..7d44e7dd 100644 --- a/services/knowledge-api/src/test/kotlin/com/jorisjonkers/personalstack/knowledge/mcp/McpToolsTest.kt +++ b/services/knowledge-api/src/test/kotlin/com/jorisjonkers/personalstack/knowledge/mcp/McpToolsTest.kt @@ -217,7 +217,16 @@ class McpToolsTest { assertThat(required).containsExactlyInAnyOrder("title", "body") @Suppress("UNCHECKED_CAST") val properties = schema["properties"] as Map - assertThat(properties.keys).contains("scope", "title", "body", "session_id", "confidence", "vault_path") + assertThat(properties.keys) + .contains( + "scope", + "title", + "body", + "source", + "session_id", + "confidence", + "vault_path", + ) } }