feat(router): Task 3.2 — NarrationTrigger + buildNarrationContext + RouterFacade.narrate
- Add NarrationTrigger(kind, instruction) model to core:router - Add RouterContextBuilder.buildNarrationContext: minimal ContextPack (system + workflow status + L2 + trigger instruction); excludes conversationHistory and L3 recalled memory - Add RouterFacade.narrate: emits RouterNarrationEvent with content, latencyMs, tokensUsed; skips event on blank inference; never writes ChatTurnEvent or l3MemoryStore.store - Add RouterNarrationTest covering all acceptance criteria - Add narrate default to RouterContextBuilder interface so existing anonymous test stubs compile without a stub override
This commit is contained in:
@@ -11,6 +11,7 @@ import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.router.model.NarrationTrigger
|
||||
import com.correx.core.router.model.RouterConfig
|
||||
import com.correx.core.router.model.RouterL2Entry
|
||||
import com.correx.core.router.model.RouterState
|
||||
@@ -21,6 +22,17 @@ import kotlinx.coroutines.CancellationException
|
||||
|
||||
interface RouterContextBuilder {
|
||||
suspend fun build(state: RouterState, budget: TokenBudget): ContextPack
|
||||
|
||||
/**
|
||||
* Builds a minimal [ContextPack] for a narration inference call.
|
||||
* Implementations should include system prompt, workflow status, recent L2, and the trigger
|
||||
* instruction, but must NOT include conversation history or L3 recalled memory.
|
||||
*
|
||||
* Default delegates to [build] so that anonymous test stubs that only override [build] compile
|
||||
* without needing a stub override.
|
||||
*/
|
||||
suspend fun buildNarrationContext(state: RouterState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack =
|
||||
build(state, budget)
|
||||
}
|
||||
|
||||
class DefaultRouterContextBuilder(
|
||||
@@ -256,4 +268,101 @@ class DefaultRouterContextBuilder(
|
||||
private fun fallbackEstimate(content: String): Int {
|
||||
return (content.length / 4).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal [ContextPack] for a single narration inference call.
|
||||
*
|
||||
* Contains (in order, subject to [budget]):
|
||||
* 1. L0 — system prompt (always present)
|
||||
* 2. L0 — workflow status (always present)
|
||||
* 3. L2 — recent stage summaries (oldest dropped first when tight)
|
||||
* 4. L0 — trigger instruction (always present as the final user-facing entry)
|
||||
*
|
||||
* Conversation history and L3 recalled memory are intentionally excluded so
|
||||
* narration lines never enter the conversation flow.
|
||||
*/
|
||||
override suspend fun buildNarrationContext(
|
||||
state: RouterState,
|
||||
trigger: NarrationTrigger,
|
||||
budget: TokenBudget,
|
||||
): ContextPack {
|
||||
val systemPromptEntry = buildContextEntry(
|
||||
sourceType = "systemPrompt",
|
||||
sourceId = "router-system",
|
||||
content = SYSTEM_PROMPT,
|
||||
layer = ContextLayer.L0,
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
val workflowStatusEntry = buildContextEntry(
|
||||
sourceType = "workflowStatus",
|
||||
sourceId = state.currentStageId?.value ?: "none",
|
||||
content = buildWorkflowStatusContent(state),
|
||||
layer = ContextLayer.L0,
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
val triggerEntry = buildContextEntry(
|
||||
sourceType = "narrationTrigger",
|
||||
sourceId = trigger.kind,
|
||||
content = trigger.instruction,
|
||||
layer = ContextLayer.L0,
|
||||
role = EntryRole.USER,
|
||||
)
|
||||
|
||||
val protectedTokens = systemPromptEntry.tokenEstimate +
|
||||
workflowStatusEntry.tokenEstimate +
|
||||
triggerEntry.tokenEstimate
|
||||
var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0)
|
||||
|
||||
val allEntries = mutableListOf<ContextEntry>()
|
||||
allEntries += systemPromptEntry
|
||||
allEntries += workflowStatusEntry
|
||||
|
||||
var droppedCount = 0
|
||||
val truncatedLayers = mutableSetOf<ContextLayer>()
|
||||
|
||||
// L2 stage summaries — newest-to-oldest so oldest get dropped first when tight.
|
||||
val fittedL2 = mutableListOf<ContextEntry>()
|
||||
for (l2Entry in state.l2Memory.asReversed()) {
|
||||
val content = buildL2Content(l2Entry)
|
||||
val entry = buildContextEntry(
|
||||
sourceType = "stageSummary",
|
||||
sourceId = l2Entry.stageId.value,
|
||||
content = content,
|
||||
layer = ContextLayer.L2,
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
if (remainingBudget >= entry.tokenEstimate) {
|
||||
remainingBudget -= entry.tokenEstimate
|
||||
fittedL2.add(entry)
|
||||
} else {
|
||||
droppedCount++
|
||||
truncatedLayers.add(ContextLayer.L2)
|
||||
}
|
||||
}
|
||||
fittedL2.reverse()
|
||||
allEntries.addAll(fittedL2)
|
||||
|
||||
// Trigger instruction is always last.
|
||||
allEntries += triggerEntry
|
||||
|
||||
val layers = allEntries
|
||||
.groupBy { it.layer }
|
||||
.toSortedMap(compareBy { it.ordinal })
|
||||
|
||||
val budgetUsed = allEntries.sumOf { it.tokenEstimate }
|
||||
|
||||
return ContextPack(
|
||||
id = ContextPackId("${state.sessionId?.value ?: "unknown"}-narration-pack"),
|
||||
sessionId = state.sessionId ?: SessionId("unknown"),
|
||||
stageId = state.currentStageId ?: StageId.NONE,
|
||||
layers = layers,
|
||||
budgetUsed = budgetUsed,
|
||||
budgetLimit = budget.limit,
|
||||
compressionMetadata = CompressionMetadata(
|
||||
appliedStrategies = listOf("L0Immutable", "NarrationContext"),
|
||||
truncatedLayers = truncatedLayers.toList(),
|
||||
entriesDropped = droppedCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
||||
import com.correx.core.events.events.L3RetrievedHit
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.RouterNarrationEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
@@ -22,6 +23,7 @@ import com.correx.core.inference.TokenUsage
|
||||
import com.correx.core.router.l3.L3MemoryEntry
|
||||
import com.correx.core.router.l3.L3MemoryStore
|
||||
import com.correx.core.router.l3.L3Query
|
||||
import com.correx.core.router.model.NarrationTrigger
|
||||
import com.correx.core.router.model.RouterConfig
|
||||
import com.correx.core.router.model.RouterResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -37,6 +39,8 @@ interface RouterFacade {
|
||||
input: String,
|
||||
mode: ChatMode = ChatMode.CHAT,
|
||||
): RouterResponse
|
||||
|
||||
suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger)
|
||||
}
|
||||
|
||||
class DefaultRouterFacade(
|
||||
@@ -165,6 +169,52 @@ class DefaultRouterFacade(
|
||||
return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING))
|
||||
}
|
||||
|
||||
override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) {
|
||||
val state = routerRepository.getRouterState(sessionId)
|
||||
val effectiveStageId = state.currentStageId ?: StageId.NONE
|
||||
|
||||
val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget)
|
||||
|
||||
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General))
|
||||
val inferenceRequest = InferenceRequest(
|
||||
requestId = InferenceRequestId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = effectiveStageId,
|
||||
contextPack = contextPack,
|
||||
generationConfig = config.generationConfig,
|
||||
responseFormat = ResponseFormat.Text,
|
||||
)
|
||||
val inferenceResponse = provider.infer(inferenceRequest)
|
||||
val content = inferenceResponse.text
|
||||
|
||||
if (content.isBlank()) return
|
||||
|
||||
val narrationId = UUID.randomUUID().toString()
|
||||
val now = Clock.System.now()
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = now,
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = RouterNarrationEvent(
|
||||
sessionId = sessionId,
|
||||
narrationId = narrationId,
|
||||
trigger = trigger.kind,
|
||||
stageId = effectiveStageId.takeIf { it != StageId.NONE }?.value,
|
||||
content = content,
|
||||
latencyMs = inferenceResponse.latencyMs,
|
||||
tokensUsed = inferenceResponse.tokensUsed,
|
||||
timestampMs = now.toEpochMilliseconds(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitChatTurn(
|
||||
sessionId: SessionId,
|
||||
content: String,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.correx.core.router.model
|
||||
|
||||
/**
|
||||
* Describes the event or condition that prompted a narration inference call.
|
||||
*
|
||||
* The trigger is stored verbatim in [RouterNarrationEvent.trigger] so it can be
|
||||
* replayed and displayed without re-querying the environment.
|
||||
*/
|
||||
data class NarrationTrigger(
|
||||
/** Short label identifying the trigger kind (e.g. "stage_completed", "tool_executed"). */
|
||||
val kind: String,
|
||||
/** Human-readable instruction injected as the final context entry for this narration. */
|
||||
val instruction: String,
|
||||
)
|
||||
Reference in New Issue
Block a user