From df0144582a66972599f129ad1ba9d9bd020aebcb Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 14 Jun 2026 03:31:30 +0400 Subject: [PATCH] feat(events): clarification questions model + events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../core/events/events/ClarificationEvents.kt | 56 ++++++++++++++++ .../events/events/ClarificationQuestions.kt | 48 ++++++++++++++ .../events/serialization/Serialization.kt | 4 ++ .../correx/core/events/types/IdentityTypes.kt | 1 + .../events/ClarificationQuestionsTest.kt | 66 +++++++++++++++++++ .../ClarificationEventSerializationTest.kt | 56 ++++++++++++++++ 6 files changed, 231 insertions(+) create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/ClarificationEvents.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/ClarificationQuestions.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/events/ClarificationQuestionsTest.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/ClarificationEventSerializationTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ClarificationEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ClarificationEvents.kt new file mode 100644 index 00000000..0d2060df --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ClarificationEvents.kt @@ -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 = 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, +) : 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, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ClarificationQuestions.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ClarificationQuestions.kt new file mode 100644 index 00000000..f4fc552d --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ClarificationQuestions.kt @@ -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 = 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) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 6a0e4353..34d2a4e6 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -10,6 +10,8 @@ import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatingEvent import com.correx.core.events.events.BriefGroundingCheckedEvent 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.RouterNarrationEvent import com.correx.core.events.events.OperatorProfileBoundEvent @@ -117,6 +119,8 @@ val eventModule = SerializersModule { subclass(JournalCompactedEvent::class) subclass(HealthDegradedEvent::class) subclass(HealthRestoredEvent::class) + subclass(ClarificationRequestedEvent::class) + subclass(ClarificationAnsweredEvent::class) } } diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt b/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt index 5919814a..dcc3e58d 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt @@ -31,6 +31,7 @@ typealias GrantId = TypeId typealias ApprovalRequestId = TypeId typealias ProjectId = TypeId typealias ApprovalDecisionId = TypeId +typealias ClarificationRequestId = TypeId // Tools types diff --git a/core/events/src/test/kotlin/com/correx/core/events/events/ClarificationQuestionsTest.kt b/core/events/src/test/kotlin/com/correx/core/events/events/ClarificationQuestionsTest.kt new file mode 100644 index 00000000..eb03ba9c --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/events/ClarificationQuestionsTest.kt @@ -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) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ClarificationEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ClarificationEventSerializationTest.kt new file mode 100644 index 00000000..552d073e --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ClarificationEventSerializationTest.kt @@ -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) + } +}