Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -48,40 +50,143 @@ 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(
val session: WorkspaceAgentSession,
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
return Resolved(session, workspace)
}

private fun ingestUpToBucket(
sessionId: WorkspaceAgentSessionId,
session: WorkspaceAgentSession,
workspace: Workspace,
candidates: List<LessonExtractor.Candidate>,
) {
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<String> =
(
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: <user>\nA: <agent>" 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
Expand All @@ -37,6 +37,9 @@ class LessonExtractor(
val body: String,
val tags: List<String>,
val confidence: Double,
val triggerTerms: List<String>,
val excerpts: List<String>,
val dedupeQuery: String,
)

private data class Pair(
Expand Down Expand Up @@ -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),
)
}

Expand Down Expand Up @@ -132,10 +142,20 @@ class LessonExtractor(
private fun makeBody(
userBody: String,
agentBody: String,
triggerTerms: List<String>,
excerpts: List<String>,
): 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 {
Expand All @@ -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<String> {
val text = "$userBody\n$agentBody"
val triggers = linkedSetOf<String>()
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<String> =
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
Expand All @@ -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",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Loading
Loading