feat(artifacts): config-driven custom artifact kinds with schema validation
Users declare artifact kinds in [[artifacts]] (id + schema_path to a JSON-schema file + llm_emitted); ConfigArtifactKind registers them at startup via createWorkflowLoader(extraKinds). New JsonSchemaValidator validates any non-built-in kind generically against its declared deriveJsonSchema(), so an LLM-emitted custom kind is checked against its shape, never trusted. Removed the dead payloadSerializer from the ArtifactKind contract. Schema source is path-to-file (not inline TOML — the hand-rolled parser can't nest).
This commit is contained in:
@@ -4,6 +4,9 @@ import com.correx.apps.server.logging.LoggingEventStore
|
|||||||
import com.correx.apps.server.registry.FileSystemWorkflowRegistry
|
import com.correx.apps.server.registry.FileSystemWorkflowRegistry
|
||||||
import com.correx.apps.server.registry.ProviderRegistry
|
import com.correx.apps.server.registry.ProviderRegistry
|
||||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||||
|
import com.correx.core.artifacts.kind.ArtifactKind
|
||||||
|
import com.correx.core.artifacts.kind.ConfigArtifactKind
|
||||||
|
import com.correx.core.artifacts.kind.JsonSchema
|
||||||
import com.correx.core.artifactstore.ArtifactStore
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
import com.correx.core.config.ConfigLoader
|
import com.correx.core.config.ConfigLoader
|
||||||
import com.correx.core.config.CorrexConfig
|
import com.correx.core.config.CorrexConfig
|
||||||
@@ -62,7 +65,9 @@ import com.correx.infrastructure.tools.ToolConfig
|
|||||||
import io.ktor.server.engine.embeddedServer
|
import io.ktor.server.engine.embeddedServer
|
||||||
import io.ktor.server.netty.Netty
|
import io.ktor.server.netty.Netty
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
|
||||||
@@ -244,7 +249,9 @@ fun main() {
|
|||||||
eventStore = eventStore,
|
eventStore = eventStore,
|
||||||
artifactStore = artifactStore,
|
artifactStore = artifactStore,
|
||||||
sessionRepository = repositories.sessionRepository,
|
sessionRepository = repositories.sessionRepository,
|
||||||
workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader()),
|
workflowRegistry = FileSystemWorkflowRegistry(
|
||||||
|
InfrastructureModule.createWorkflowLoader(loadConfigArtifactKinds(correxConfig)),
|
||||||
|
),
|
||||||
providerRegistry = infraRegistry.asServerRegistry(),
|
providerRegistry = infraRegistry.asServerRegistry(),
|
||||||
defaultOrchestrationConfig = defaultOrchestrationConfig,
|
defaultOrchestrationConfig = defaultOrchestrationConfig,
|
||||||
routerFacade = routerFacade,
|
routerFacade = routerFacade,
|
||||||
@@ -294,6 +301,39 @@ private fun logStoresInfo(
|
|||||||
log.info(" artifact store: {}", artifactStore::class.simpleName)
|
log.info(" artifact store: {}", artifactStore::class.simpleName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build user-declared artifact kinds from config. Each [[artifacts]] entry points to a JSON
|
||||||
|
// schema file; we parse it into a JsonSchema and wrap it in a ConfigArtifactKind. A schema that
|
||||||
|
// can't be loaded is skipped with a warning — any workflow that references the kind then fails
|
||||||
|
// loudly at load time ("Unknown artifact kind"), rather than running with an unvalidated kind.
|
||||||
|
private fun loadConfigArtifactKinds(config: CorrexConfig): List<ArtifactKind> {
|
||||||
|
val configDir = ConfigLoader.configPath().parent
|
||||||
|
return config.artifacts.mapNotNull { decl ->
|
||||||
|
val schemaPath = resolveConfigRelativePath(decl.schemaPath, configDir)
|
||||||
|
val schema = runCatching {
|
||||||
|
Json.decodeFromString(JsonSchema.serializer(), Files.readString(schemaPath))
|
||||||
|
}.getOrElse { e ->
|
||||||
|
System.err.println(
|
||||||
|
"Warning: artifact kind '${decl.id}' schema '$schemaPath' failed to load: ${e.message}",
|
||||||
|
)
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
ConfigArtifactKind(id = decl.id, schema = schema, llmEmitted = decl.llmEmitted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveConfigRelativePath(raw: String, configDir: Path?): Path {
|
||||||
|
val expanded = if (raw.startsWith("~/")) {
|
||||||
|
Paths.get(System.getProperty("user.home"), raw.removePrefix("~/"))
|
||||||
|
} else {
|
||||||
|
Path.of(raw)
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
expanded.isAbsolute -> expanded
|
||||||
|
configDir != null -> configDir.resolve(expanded)
|
||||||
|
else -> expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildProviders(config: CorrexConfig): List<LlamaCppInferenceProvider> {
|
private fun buildProviders(config: CorrexConfig): List<LlamaCppInferenceProvider> {
|
||||||
return if (config.providers.isNotEmpty()) {
|
return if (config.providers.isNotEmpty()) {
|
||||||
log.info("Loading {} provider(s) from config", config.providers.size)
|
log.info("Loading {} provider(s) from config", config.providers.size)
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package com.correx.core.artifacts.kind
|
package com.correx.core.artifacts.kind
|
||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
|
|
||||||
interface ArtifactKind {
|
interface ArtifactKind {
|
||||||
val id: String
|
val id: String
|
||||||
val payloadSerializer: KSerializer<*>
|
|
||||||
fun deriveJsonSchema(): JsonSchema
|
fun deriveJsonSchema(): JsonSchema
|
||||||
val llmEmitted: Boolean
|
val llmEmitted: Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.correx.core.artifacts.kind
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user-declared artifact kind whose schema comes from configuration rather than
|
||||||
|
* a compile-time Kotlin type. The declared [schema] is authoritative: content is
|
||||||
|
* validated against it generically (see the artifact-payload validator), so an
|
||||||
|
* [llmEmitted] config kind is checked against its declared shape, never a
|
||||||
|
* hardcoded type check.
|
||||||
|
*/
|
||||||
|
data class ConfigArtifactKind(
|
||||||
|
override val id: String,
|
||||||
|
private val schema: JsonSchema,
|
||||||
|
override val llmEmitted: Boolean = false,
|
||||||
|
) : ArtifactKind {
|
||||||
|
override fun deriveJsonSchema(): JsonSchema = schema
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package com.correx.core.artifacts.kind
|
package com.correx.core.artifacts.kind
|
||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
|
|
||||||
object FileWrittenKind : ArtifactKind {
|
object FileWrittenKind : ArtifactKind {
|
||||||
override val id: String = "file_written"
|
override val id: String = "file_written"
|
||||||
override val payloadSerializer: KSerializer<*> = FileWrittenPayload.serializer()
|
|
||||||
override val llmEmitted: Boolean = false
|
override val llmEmitted: Boolean = false
|
||||||
|
|
||||||
override fun deriveJsonSchema(): JsonSchema = JsonSchema(
|
override fun deriveJsonSchema(): JsonSchema = JsonSchema(
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package com.correx.core.artifacts.kind
|
package com.correx.core.artifacts.kind
|
||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
|
|
||||||
object ProcessResultKind : ArtifactKind {
|
object ProcessResultKind : ArtifactKind {
|
||||||
override val id: String = "process_result"
|
override val id: String = "process_result"
|
||||||
override val payloadSerializer: KSerializer<*> = ProcessResultArtifact.serializer()
|
|
||||||
override val llmEmitted: Boolean = false
|
override val llmEmitted: Boolean = false
|
||||||
|
|
||||||
override fun deriveJsonSchema(): JsonSchema = JsonSchema(
|
override fun deriveJsonSchema(): JsonSchema = JsonSchema(
|
||||||
|
|||||||
+15
@@ -29,6 +29,21 @@ class ArtifactKindRegistryTest {
|
|||||||
assertEquals(false, registry.get("process_result")!!.llmEmitted)
|
assertEquals(false, registry.get("process_result")!!.llmEmitted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `config-declared kind registers and exposes its declared schema`() {
|
||||||
|
val schema = JsonSchema(
|
||||||
|
type = "object",
|
||||||
|
properties = mapOf("x" to JsonSchemaProperty(type = "string")),
|
||||||
|
required = listOf("x"),
|
||||||
|
)
|
||||||
|
registry.register(ConfigArtifactKind(id = "custom", schema = schema, llmEmitted = true))
|
||||||
|
|
||||||
|
val kind = registry.get("custom")
|
||||||
|
assertNotNull(kind)
|
||||||
|
assertEquals(true, kind!!.llmEmitted)
|
||||||
|
assertEquals(schema, kind.deriveJsonSchema())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `FileWrittenKind schema has required fields`() {
|
fun `FileWrittenKind schema has required fields`() {
|
||||||
val schema = FileWrittenKind.deriveJsonSchema()
|
val schema = FileWrittenKind.deriveJsonSchema()
|
||||||
|
|||||||
@@ -36,8 +36,21 @@ object ConfigLoader {
|
|||||||
val sections = mutableMapOf<String, MutableMap<String, Any>>()
|
val sections = mutableMapOf<String, MutableMap<String, Any>>()
|
||||||
val providers = mutableListOf<MutableMap<String, Any>>()
|
val providers = mutableListOf<MutableMap<String, Any>>()
|
||||||
val models = mutableListOf<MutableMap<String, Any>>()
|
val models = mutableListOf<MutableMap<String, Any>>()
|
||||||
|
val artifacts = mutableListOf<MutableMap<String, Any>>()
|
||||||
var currentProvider: MutableMap<String, Any>? = null
|
var currentProvider: MutableMap<String, Any>? = null
|
||||||
var currentModel: MutableMap<String, Any>? = null
|
var currentModel: MutableMap<String, Any>? = null
|
||||||
|
var currentArtifact: MutableMap<String, Any>? = null
|
||||||
|
|
||||||
|
// Flush any open array-of-table entry into its list. Called whenever a new
|
||||||
|
// table header (array or section) starts, so the previous entry is committed.
|
||||||
|
fun flushTables() {
|
||||||
|
currentProvider?.let { providers.add(it) }
|
||||||
|
currentModel?.let { models.add(it) }
|
||||||
|
currentArtifact?.let { artifacts.add(it) }
|
||||||
|
currentProvider = null
|
||||||
|
currentModel = null
|
||||||
|
currentArtifact = null
|
||||||
|
}
|
||||||
|
|
||||||
for ((lineNum, line) in lines.withIndex()) {
|
for ((lineNum, line) in lines.withIndex()) {
|
||||||
val trimmed = line.trim()
|
val trimmed = line.trim()
|
||||||
@@ -47,29 +60,23 @@ object ConfigLoader {
|
|||||||
// Skip empty lines and comments
|
// Skip empty lines and comments
|
||||||
}
|
}
|
||||||
trimmed == "[[providers]]" -> {
|
trimmed == "[[providers]]" -> {
|
||||||
// Start a new provider entry
|
flushTables()
|
||||||
if (currentProvider != null) providers.add(currentProvider)
|
|
||||||
if (currentModel != null) { models.add(currentModel); currentModel = null }
|
|
||||||
currentProvider = mutableMapOf()
|
currentProvider = mutableMapOf()
|
||||||
currentSection = ""
|
currentSection = ""
|
||||||
}
|
}
|
||||||
trimmed == "[[models]]" -> {
|
trimmed == "[[models]]" -> {
|
||||||
// Start a new model entry
|
flushTables()
|
||||||
if (currentModel != null) models.add(currentModel)
|
|
||||||
if (currentProvider != null) { providers.add(currentProvider); currentProvider = null }
|
|
||||||
currentModel = mutableMapOf()
|
currentModel = mutableMapOf()
|
||||||
currentSection = ""
|
currentSection = ""
|
||||||
}
|
}
|
||||||
|
trimmed == "[[artifacts]]" -> {
|
||||||
|
flushTables()
|
||||||
|
currentArtifact = mutableMapOf()
|
||||||
|
currentSection = ""
|
||||||
|
}
|
||||||
trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> {
|
trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> {
|
||||||
// Parse section headers like [server] or [tools.shell]
|
// Parse section headers like [server] or [tools.shell]
|
||||||
if (currentProvider != null) {
|
flushTables()
|
||||||
providers.add(currentProvider)
|
|
||||||
currentProvider = null
|
|
||||||
}
|
|
||||||
if (currentModel != null) {
|
|
||||||
models.add(currentModel)
|
|
||||||
currentModel = null
|
|
||||||
}
|
|
||||||
currentSection = trimmed.substring(1, trimmed.length - 1).trim()
|
currentSection = trimmed.substring(1, trimmed.length - 1).trim()
|
||||||
sections.putIfAbsent(currentSection, mutableMapOf())
|
sections.putIfAbsent(currentSection, mutableMapOf())
|
||||||
}
|
}
|
||||||
@@ -81,9 +88,14 @@ object ConfigLoader {
|
|||||||
val valueStr = trimmed.substring(eqIndex + 1).trim()
|
val valueStr = trimmed.substring(eqIndex + 1).trim()
|
||||||
val parsedValue = parseValue(valueStr, lineNum + 1)
|
val parsedValue = parseValue(valueStr, lineNum + 1)
|
||||||
|
|
||||||
|
// Locals so smart-casting works (the vars are captured by flushTables).
|
||||||
|
val provider = currentProvider
|
||||||
|
val model = currentModel
|
||||||
|
val artifact = currentArtifact
|
||||||
when {
|
when {
|
||||||
currentProvider != null -> currentProvider[key] = parsedValue
|
provider != null -> provider[key] = parsedValue
|
||||||
currentModel != null -> currentModel[key] = parsedValue
|
model != null -> model[key] = parsedValue
|
||||||
|
artifact != null -> artifact[key] = parsedValue
|
||||||
currentSection.isNotEmpty() -> sections[currentSection]?.put(key, parsedValue)
|
currentSection.isNotEmpty() -> sections[currentSection]?.put(key, parsedValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,10 +104,9 @@ object ConfigLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Don't forget the last array-of-table entry if file ends with one
|
// Don't forget the last array-of-table entry if file ends with one
|
||||||
if (currentProvider != null) providers.add(currentProvider)
|
flushTables()
|
||||||
if (currentModel != null) models.add(currentModel)
|
|
||||||
|
|
||||||
return buildConfig(sections, providers, models)
|
return buildConfig(sections, providers, models, artifacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseValue(valueStr: String, lineNum: Int): Any {
|
private fun parseValue(valueStr: String, lineNum: Int): Any {
|
||||||
@@ -212,6 +223,7 @@ object ConfigLoader {
|
|||||||
sections: Map<String, Map<String, Any>>,
|
sections: Map<String, Map<String, Any>>,
|
||||||
providersList: List<Map<String, Any>> = emptyList(),
|
providersList: List<Map<String, Any>> = emptyList(),
|
||||||
modelsList: List<Map<String, Any>> = emptyList(),
|
modelsList: List<Map<String, Any>> = emptyList(),
|
||||||
|
artifactsList: List<Map<String, Any>> = emptyList(),
|
||||||
): CorrexConfig {
|
): CorrexConfig {
|
||||||
val serverSection = sections["server"] ?: emptyMap()
|
val serverSection = sections["server"] ?: emptyMap()
|
||||||
val tuiSection = sections["tui"] ?: emptyMap()
|
val tuiSection = sections["tui"] ?: emptyMap()
|
||||||
@@ -386,6 +398,16 @@ object ConfigLoader {
|
|||||||
port = asInt(modelsSection["port"], 10000),
|
port = asInt(modelsSection["port"], 10000),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val artifacts = artifactsList.mapNotNull { artifactMap ->
|
||||||
|
val id = asStringOrNull(artifactMap["id"]) ?: return@mapNotNull null
|
||||||
|
val schemaPath = asStringOrNull(artifactMap["schema_path"]) ?: return@mapNotNull null
|
||||||
|
ArtifactKindConfig(
|
||||||
|
id = id,
|
||||||
|
schemaPath = schemaPath,
|
||||||
|
llmEmitted = asBoolean(artifactMap["llm_emitted"], false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return CorrexConfig(
|
return CorrexConfig(
|
||||||
server = server,
|
server = server,
|
||||||
tui = tui,
|
tui = tui,
|
||||||
@@ -395,6 +417,7 @@ object ConfigLoader {
|
|||||||
router = router,
|
router = router,
|
||||||
models = models,
|
models = models,
|
||||||
modelsSettings = modelsSettings,
|
modelsSettings = modelsSettings,
|
||||||
|
artifacts = artifacts,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ data class CorrexConfig(
|
|||||||
val router: RouterConfig = RouterConfig(),
|
val router: RouterConfig = RouterConfig(),
|
||||||
val models: List<ModelConfig> = emptyList(),
|
val models: List<ModelConfig> = emptyList(),
|
||||||
val modelsSettings: ModelsSettings = ModelsSettings(),
|
val modelsSettings: ModelsSettings = ModelsSettings(),
|
||||||
|
val artifacts: List<ArtifactKindConfig> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user-declared artifact kind. [schemaPath] points to a JSON file holding the
|
||||||
|
* kind's JSON schema (object type, typed properties, required, additionalProperties).
|
||||||
|
* A path-to-file (not inline TOML) is used so schemas can nest arbitrarily and be
|
||||||
|
* parsed with a real JSON parser. [llmEmitted] kinds are validated against this
|
||||||
|
* schema, never trusted.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ArtifactKindConfig(
|
||||||
|
val id: String,
|
||||||
|
val schemaPath: String,
|
||||||
|
val llmEmitted: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -41,6 +41,40 @@ class ConfigLoaderTest {
|
|||||||
assertEquals("json", result.cli.defaultOutput)
|
assertEquals("json", result.cli.defaultOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseToml parses artifacts array-of-tables and separates them from models and sections`() {
|
||||||
|
val toml = """
|
||||||
|
[[models]]
|
||||||
|
id = "m1"
|
||||||
|
model_path = "/m1.gguf"
|
||||||
|
|
||||||
|
[[artifacts]]
|
||||||
|
id = "review_report"
|
||||||
|
schema_path = "schemas/review.json"
|
||||||
|
llm_emitted = true
|
||||||
|
|
||||||
|
[[artifacts]]
|
||||||
|
id = "plan"
|
||||||
|
schema_path = "schemas/plan.json"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
port = 1234
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java)
|
||||||
|
parseTomlMethod.isAccessible = true
|
||||||
|
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
|
||||||
|
|
||||||
|
assertEquals(1, result.models.size)
|
||||||
|
assertEquals(1234, result.server.port)
|
||||||
|
assertEquals(2, result.artifacts.size)
|
||||||
|
assertEquals("review_report", result.artifacts[0].id)
|
||||||
|
assertEquals("schemas/review.json", result.artifacts[0].schemaPath)
|
||||||
|
assertEquals(true, result.artifacts[0].llmEmitted)
|
||||||
|
assertEquals("plan", result.artifacts[1].id)
|
||||||
|
assertEquals(false, result.artifacts[1].llmEmitted)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `parseToml skips comments and empty lines`() {
|
fun `parseToml skips comments and empty lines`() {
|
||||||
val toml = """
|
val toml = """
|
||||||
|
|||||||
+17
@@ -42,9 +42,26 @@ class ArtifactPayloadValidator(
|
|||||||
validateSlotAsFileWritten(slot.name, slot.kind.id, payloadStr, issues)
|
validateSlotAsFileWritten(slot.name, slot.kind.id, payloadStr, issues)
|
||||||
"process_result" ->
|
"process_result" ->
|
||||||
validateSlotAsProcessResult(slot.name, slot.kind.id, payloadStr, issues)
|
validateSlotAsProcessResult(slot.name, slot.kind.id, payloadStr, issues)
|
||||||
|
// Config-declared kinds: validate generically against the declared schema, so an
|
||||||
|
// LLM-emitted custom kind is checked against its shape rather than trusted.
|
||||||
|
else ->
|
||||||
|
validateSlotAgainstSchema(slot, payloadStr, issues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun validateSlotAgainstSchema(
|
||||||
|
slot: TypedArtifactSlot,
|
||||||
|
payloadStr: String,
|
||||||
|
issues: MutableList<ValidationIssue>,
|
||||||
|
) {
|
||||||
|
val json = runCatching { Json.parseToJsonElement(payloadStr) }.getOrElse {
|
||||||
|
issues += decodeFailedIssue(slot.name, slot.kind.id, it.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
JsonSchemaValidator.validate(slot.kind.deriveJsonSchema(), json)
|
||||||
|
.forEach { issues += semanticIssue(slot.name, it) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun validateSlotAsFileWritten(
|
private fun validateSlotAsFileWritten(
|
||||||
slotName: ArtifactId,
|
slotName: ArtifactId,
|
||||||
kindId: String,
|
kindId: String,
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package com.correx.core.validation.artifact
|
||||||
|
|
||||||
|
import com.correx.core.artifacts.kind.JsonSchema
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal, deterministic validator of a JSON value against a [JsonSchema]
|
||||||
|
* (the subset correx models: object type, typed properties, required keys,
|
||||||
|
* additionalProperties). Returns human-readable error strings; empty = valid.
|
||||||
|
*
|
||||||
|
* Used to validate config-declared artifact kinds against their declared schema,
|
||||||
|
* so an LLM-emitted custom kind is checked against its shape rather than trusted.
|
||||||
|
*/
|
||||||
|
object JsonSchemaValidator {
|
||||||
|
|
||||||
|
fun validate(schema: JsonSchema, value: JsonElement): List<String> =
|
||||||
|
if (schema.type == "object") {
|
||||||
|
validateObject(schema, value)
|
||||||
|
} else {
|
||||||
|
checkType(schema.type, value)?.let { listOf(it) } ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateObject(schema: JsonSchema, value: JsonElement): List<String> {
|
||||||
|
val obj = value as? JsonObject ?: return listOf("expected object, got ${kindOf(value)}")
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
schema.required.filterNot { it in obj }.forEach { errors += "missing required property '$it'" }
|
||||||
|
for ((key, element) in obj) {
|
||||||
|
val property = schema.properties[key]
|
||||||
|
if (property == null) {
|
||||||
|
if (!schema.additionalProperties) errors += "unknown property '$key'"
|
||||||
|
} else {
|
||||||
|
checkType(property.type, element)?.let { errors += "property '$key': $it" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkType(type: String, value: JsonElement): String? {
|
||||||
|
val ok = when (type) {
|
||||||
|
"string" -> value is JsonPrimitive && value.isString
|
||||||
|
"integer" -> value is JsonPrimitive && !value.isString && value.longOrNull != null
|
||||||
|
"number" -> value is JsonPrimitive && !value.isString && value.doubleOrNull != null
|
||||||
|
"boolean" -> value is JsonPrimitive && !value.isString && value.booleanOrNull != null
|
||||||
|
"object" -> value is JsonObject
|
||||||
|
"array" -> value is JsonArray
|
||||||
|
else -> true // unknown declared type: don't enforce
|
||||||
|
}
|
||||||
|
return if (ok) null else "expected $type, got ${kindOf(value)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun kindOf(value: JsonElement): String = when (value) {
|
||||||
|
is JsonArray -> "array"
|
||||||
|
is JsonObject -> "object"
|
||||||
|
is JsonPrimitive -> if (value.isString) "string" else "primitive"
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package com.correx.core.validation.artifact
|
||||||
|
|
||||||
|
import com.correx.core.artifacts.kind.JsonSchema
|
||||||
|
import com.correx.core.artifacts.kind.JsonSchemaProperty
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class JsonSchemaValidatorTest {
|
||||||
|
|
||||||
|
private val schema = JsonSchema(
|
||||||
|
type = "object",
|
||||||
|
properties = mapOf(
|
||||||
|
"summary" to JsonSchemaProperty(type = "string"),
|
||||||
|
"score" to JsonSchemaProperty(type = "integer"),
|
||||||
|
),
|
||||||
|
required = listOf("summary", "score"),
|
||||||
|
additionalProperties = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun errors(json: String) =
|
||||||
|
JsonSchemaValidator.validate(schema, Json.parseToJsonElement(json))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid object passes`() {
|
||||||
|
assertTrue(errors("""{"summary":"ok","score":3}""").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing required property is flagged`() {
|
||||||
|
assertTrue(errors("""{"summary":"ok"}""").any { it.contains("score") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wrong property type is flagged`() {
|
||||||
|
assertTrue(errors("""{"summary":"ok","score":"high"}""").any { it.contains("score") && it.contains("integer") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown property is flagged when additionalProperties is false`() {
|
||||||
|
assertTrue(errors("""{"summary":"ok","score":3,"extra":1}""").any { it.contains("extra") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown property is allowed when additionalProperties is true`() {
|
||||||
|
val open = schema.copy(additionalProperties = true)
|
||||||
|
val result = JsonSchemaValidator.validate(open, Json.parseToJsonElement("""{"summary":"ok","score":3,"extra":1}"""))
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-object payload against object schema is flagged`() {
|
||||||
|
assertEquals(1, errors("""["a","b"]""").size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,3 +87,13 @@ backend = "in_memory" # or "turbovec"
|
|||||||
# script_path = "~/.config/correx/python/turbovec_sidecar.py"
|
# script_path = "~/.config/correx/python/turbovec_sidecar.py"
|
||||||
# dim = 1536
|
# dim = 1536
|
||||||
# bit_width = 4
|
# bit_width = 4
|
||||||
|
|
||||||
|
# Custom artifact kinds. Each entry points to a JSON file holding the kind's JSON
|
||||||
|
# schema (object type, typed properties, required, additionalProperties). Workflows
|
||||||
|
# may then declare `produces` slots of this kind; LLM-emitted kinds are validated
|
||||||
|
# against the declared schema. schema_path is absolute, ~-relative, or relative to
|
||||||
|
# this config file's directory.
|
||||||
|
# [[artifacts]]
|
||||||
|
# id = "review_report"
|
||||||
|
# schema_path = "schemas/review_report.json"
|
||||||
|
# llm_emitted = true
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import com.correx.infrastructure.tools.DispatchingToolExecutor
|
|||||||
import com.correx.infrastructure.tools.SandboxedToolExecutor
|
import com.correx.infrastructure.tools.SandboxedToolExecutor
|
||||||
import com.correx.infrastructure.tools.ToolConfig
|
import com.correx.infrastructure.tools.ToolConfig
|
||||||
import com.correx.infrastructure.tools.buildTools
|
import com.correx.infrastructure.tools.buildTools
|
||||||
|
import com.correx.core.artifacts.kind.ArtifactKind
|
||||||
|
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
|
||||||
import com.correx.infrastructure.workflow.FileSystemPromptLoader
|
import com.correx.infrastructure.workflow.FileSystemPromptLoader
|
||||||
import com.correx.infrastructure.workflow.PromptLoader
|
import com.correx.infrastructure.workflow.PromptLoader
|
||||||
import com.correx.infrastructure.workflow.TomlWorkflowLoader
|
import com.correx.infrastructure.workflow.TomlWorkflowLoader
|
||||||
@@ -175,7 +177,11 @@ object InfrastructureModule {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun createWorkflowLoader(): WorkflowLoader = TomlWorkflowLoader()
|
fun createWorkflowLoader(extraKinds: List<ArtifactKind> = emptyList()): WorkflowLoader {
|
||||||
|
val registry = DefaultArtifactKindRegistry()
|
||||||
|
extraKinds.forEach { registry.register(it) }
|
||||||
|
return TomlWorkflowLoader(registry)
|
||||||
|
}
|
||||||
|
|
||||||
fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()
|
fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user