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:
@@ -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.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ typealias GrantId = TypeId
|
||||
typealias ApprovalRequestId = TypeId
|
||||
typealias ProjectId = TypeId
|
||||
typealias ApprovalDecisionId = TypeId
|
||||
typealias ClarificationRequestId = TypeId
|
||||
|
||||
|
||||
// Tools types
|
||||
|
||||
+66
@@ -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)
|
||||
}
|
||||
}
|
||||
+56
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user