feat(events): clarification questions model + events

Adds the vocabulary for a stage raising open questions to the operator:
ClarificationQuestion/ClarificationAnswer value types, the
ClarificationRequested/ClarificationAnswered events (registered in the
event module), a ClarificationRequestId, and ClarificationQuestions.parse
— a lenient, fence-tolerant reader that pulls an optional questions array
out of an LLM artifact (shared by orchestrator and server).
This commit is contained in:
2026-06-14 03:31:30 +04:00
parent a38ac1b3c9
commit df0144582a
6 changed files with 231 additions and 0 deletions
@@ -0,0 +1,56 @@
package com.correx.core.events.events
import com.correx.core.events.types.ClarificationRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One open question a stage (e.g. the analyst) raised about the request, surfaced to the operator
* before the stage's output is considered settled. [options] empty → free-text answer; non-empty →
* the client renders selectable choices. [multiSelect] permits more than one choice. [header] is a
* short chip label for the question.
*/
@Serializable
data class ClarificationQuestion(
val id: String,
val prompt: String,
val options: List<String> = emptyList(),
val multiSelect: Boolean = false,
val header: String = "",
)
/** The operator's answer to a single [ClarificationQuestion], keyed by [questionId]. */
@Serializable
data class ClarificationAnswer(
val questionId: String,
val value: String,
)
/**
* A stage produced an artifact carrying open questions; the orchestrator paused the stage and is
* awaiting operator answers. The parsed [questions] are recorded at the moment they are observed
* (invariant #9), so replay reads the recorded list rather than re-parsing artifact content.
*/
@Serializable
@SerialName("ClarificationRequested")
data class ClarificationRequestedEvent(
val requestId: ClarificationRequestId,
val sessionId: SessionId,
val stageId: StageId,
val questions: List<ClarificationQuestion>,
) : EventPayload
/**
* The operator answered a [ClarificationRequestedEvent]. The orchestrator injects these answers as
* context and re-runs the producing stage. Terminal for the clarification request.
*/
@Serializable
@SerialName("ClarificationAnswered")
data class ClarificationAnsweredEvent(
val requestId: ClarificationRequestId,
val sessionId: SessionId,
val stageId: StageId,
val answers: List<ClarificationAnswer>,
) : EventPayload
@@ -0,0 +1,48 @@
package com.correx.core.events.events
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Parses an optional `questions` array out of an LLM artifact's JSON body (e.g. the analyst's
* analysis). Lenient and fence-tolerant: any parse failure, or an absent/empty array, yields an
* empty list (meaning "no clarification needed"). Each element needs at least a prompt (accepts
* `prompt` or `question`); `id`/`options`/`multiSelect`/`header` are optional, matching the analyst
* prompt convention. Lives next to [ClarificationQuestion] so the orchestrator and the server parse
* questions identically.
*/
object ClarificationQuestions {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
fun parse(content: String): List<ClarificationQuestion> = runCatching {
val root = json.parseToJsonElement(stripFence(content)).jsonObject
val array = root["questions"]?.jsonArray ?: return emptyList()
array.mapIndexedNotNull { index, element ->
val obj = element as? JsonObject ?: return@mapIndexedNotNull null
val prompt = (obj["prompt"] ?: obj["question"])?.jsonPrimitive?.contentOrNull
?: return@mapIndexedNotNull null
ClarificationQuestion(
id = obj["id"]?.jsonPrimitive?.contentOrNull ?: "q${index + 1}",
prompt = prompt,
options = obj["options"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
multiSelect = obj["multiSelect"]?.jsonPrimitive?.booleanOrNull ?: false,
header = obj["header"]?.jsonPrimitive?.contentOrNull ?: "",
)
}
}.getOrElse { emptyList() }
/** Drops a single outer ```` ```lang … ``` ```` fence when the whole body is one fenced block. */
private fun stripFence(text: String): String {
val trimmed = text.trim()
if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return text
val withoutClose = trimmed.removeSuffix("```").trimEnd()
val firstNewline = withoutClose.indexOf('\n')
return if (firstNewline < 0) text else withoutClose.substring(firstNewline + 1)
}
}
@@ -10,6 +10,8 @@ import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent import com.correx.core.events.events.BriefGroundingCheckedEvent
import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.ChatTurnEvent import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.RouterNarrationEvent import com.correx.core.events.events.RouterNarrationEvent
import com.correx.core.events.events.OperatorProfileBoundEvent import com.correx.core.events.events.OperatorProfileBoundEvent
@@ -117,6 +119,8 @@ val eventModule = SerializersModule {
subclass(JournalCompactedEvent::class) subclass(JournalCompactedEvent::class)
subclass(HealthDegradedEvent::class) subclass(HealthDegradedEvent::class)
subclass(HealthRestoredEvent::class) subclass(HealthRestoredEvent::class)
subclass(ClarificationRequestedEvent::class)
subclass(ClarificationAnsweredEvent::class)
} }
} }
@@ -31,6 +31,7 @@ typealias GrantId = TypeId
typealias ApprovalRequestId = TypeId typealias ApprovalRequestId = TypeId
typealias ProjectId = TypeId typealias ProjectId = TypeId
typealias ApprovalDecisionId = TypeId typealias ApprovalDecisionId = TypeId
typealias ClarificationRequestId = TypeId
// Tools types // Tools types
@@ -0,0 +1,66 @@
package com.correx.core.events.events
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ClarificationQuestionsTest {
@Test
fun `parses structured questions with options and multiSelect`() {
val json = """
{
"summary": "build a UI",
"questions": [
{"id": "stack", "prompt": "Which frontend stack?", "header": "Stack",
"options": ["React", "Vue", "Svelte"]},
{"id": "shape", "prompt": "SPA or multi-page?", "multiSelect": true,
"options": ["SPA", "MPA"]}
]
}
""".trimIndent()
val questions = ClarificationQuestions.parse(json)
assertEquals(2, questions.size)
assertEquals("Which frontend stack?", questions[0].prompt)
assertEquals(listOf("React", "Vue", "Svelte"), questions[0].options)
assertEquals("Stack", questions[0].header)
assertTrue(questions[1].multiSelect)
}
@Test
fun `accepts question alias and synthesises ids, defaults free-text`() {
val json = """{"questions": [{"question": "What deadline?"}]}"""
val questions = ClarificationQuestions.parse(json)
assertEquals(1, questions.size)
assertEquals("What deadline?", questions[0].prompt)
assertEquals("q1", questions[0].id)
assertTrue(questions[0].options.isEmpty(), "no options → free-text answer")
assertEquals(false, questions[0].multiSelect)
}
@Test
fun `tolerates a markdown code fence around the artifact`() {
val fenced = "```json\n{\"questions\": [{\"prompt\": \"Q?\"}]}\n```"
assertEquals(1, ClarificationQuestions.parse(fenced).size)
}
@Test
fun `absent or empty questions array yields no clarification`() {
assertTrue(ClarificationQuestions.parse("""{"summary": "no questions here"}""").isEmpty())
assertTrue(ClarificationQuestions.parse("""{"questions": []}""").isEmpty())
}
@Test
fun `malformed json yields empty rather than throwing`() {
assertTrue(ClarificationQuestions.parse("not json at all").isEmpty())
assertTrue(ClarificationQuestions.parse("").isEmpty())
}
@Test
fun `skips elements lacking a prompt`() {
val json = """{"questions": [{"id": "x"}, {"prompt": "real?"}]}"""
val questions = ClarificationQuestions.parse(json)
assertEquals(1, questions.size)
assertEquals("real?", questions[0].prompt)
}
}
@@ -0,0 +1,56 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationQuestion
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.types.ClarificationRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ClarificationEventSerializationTest {
@Test
fun `ClarificationRequestedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = ClarificationRequestedEvent(
requestId = ClarificationRequestId("c1"),
sessionId = SessionId("s"),
stageId = StageId("analyst"),
questions = listOf(
ClarificationQuestion(
id = "q1",
prompt = "Which frontend stack?",
options = listOf("React", "Vue", "Svelte"),
multiSelect = false,
header = "Stack",
),
ClarificationQuestion(id = "q2", prompt = "SPA or multi-page?"),
),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"ClarificationRequested\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `ClarificationAnsweredEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = ClarificationAnsweredEvent(
requestId = ClarificationRequestId("c1"),
sessionId = SessionId("s"),
stageId = StageId("analyst"),
answers = listOf(
ClarificationAnswer(questionId = "q1", value = "React"),
ClarificationAnswer(questionId = "q2", value = "SPA"),
),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"ClarificationAnswered\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}