Common AI framework utilities for the Embabel Agent system including LLM configuration, output converters, prompt contributors, and embedding service abstractions.
Jackson-based converters for transforming LLM text responses into typed objects with automatic JSON Schema generation and lenient parsing.
JacksonOutputConverter: Base converter with schema generation FilteringJacksonOutputConverter: Converter with property filtering Lenient Parsing: Handles malformed JSON common in LLM responses
open class JacksonOutputConverter<T>(
clazz: Class<T>,
objectMapper: ObjectMapper
) : StructuredOutputConverter<T> {
val objectMapper: ObjectMapper
val jsonSchema: String
fun convert(text: String): T?
fun getFormat(): String
protected open fun postProcessSchema(jsonNode: JsonNode)
}import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
data class Person(val name: String, val age: Int, val email: String)
val mapper = ObjectMapper().registerKotlinModule()
val converter = JacksonOutputConverter(Person::class.java, mapper)
// Convert LLM response
val response = """{"name": "Alice", "age": 30, "email": "alice@example.com"}"""
val person = converter.convert(response)
// person = Person(name=Alice, age=30, email=alice@example.com)val person = converter.convert(llmResponse)
if (person == null) {
// Parsing failed
logger.error("Failed to parse LLM response")
}Automatically generate schema from types for LLM prompts.
val schema = converter.jsonSchema
// Returns: {"type":"object","properties":{"name":{"type":"string"},...}}val prompt = """
Extract person information from text.
${converter.getFormat()}
Text: "John Doe is 35 years old, email: john@example.com"
""".trimIndent()Respects Jackson annotations:
data class Person(
@JsonProperty("full_name")
@JsonPropertyDescription("Person's full name")
val name: String,
@JsonIgnore
val internalId: String
)
// Schema will use "full_name" and exclude "internalId"Supports complex types:
data class User(
val name: String,
val addresses: List<Address>,
val metadata: Map<String, Any>
)
// Schema includes nested object definitionsHandles common LLM output issues automatically.
Markdown code blocks:
val response = """
```json
{"name": "Bob", "age": 25}
```
"""
converter.convert(response) // Works!Trailing commas:
val response = """{"name": "Alice", "age": 30,}"""
converter.convert(response) // Works!Unquoted field names:
val response = """{name: "Charlie", age: 28}"""
converter.convert(response) // Works!Single quotes:
val response = """{'name': 'David', 'age': 35}"""
converter.convert(response) // Works!Comments:
val response = """
{
"name": "Eve", // user name
"age": 22 /* years old */
}
"""
converter.convert(response) // Works!Malformed escaped quotes:
val response = """{"name": "\"Frank\""}"""
converter.convert(response) // Works!The converter attempts multiple parsing strategies:
Exclude properties from JSON schema while keeping them in the target type.
open class FilteringJacksonOutputConverter<T>(
clazz: Class<T>,
objectMapper: ObjectMapper,
propertyFilter: Predicate<String>
) : JacksonOutputConverter<T>data class User(
val id: String,
val name: String,
val email: String,
val internalId: String,
val systemMetadata: String
)
// Filter out internal fields
val filter = Predicate<String> { propertyName ->
!propertyName.startsWith("internal") &&
!propertyName.startsWith("system")
}
val converter = FilteringJacksonOutputConverter(
User::class.java,
mapper,
filter
)
// Schema only includes: id, name, email
val schema = converter.jsonSchemaExclude by prefix:
val filter = Predicate<String> { !it.startsWith("_") }Whitelist only:
val whitelist = setOf("name", "email", "age")
val filter = Predicate<String> { it in whitelist }Exclude by suffix:
val filter = Predicate<String> { !it.endsWith("Internal") }Complex logic:
val filter = Predicate<String> { property ->
!property.startsWith("_") &&
!property.endsWith("Internal") &&
property != "systemData"
}Hide sensitive fields from LLM but keep in code:
data class User(
val name: String,
val email: String,
val password: String // Not in schema
)
val filter = Predicate<String> { it != "password" }Separate input/output fields:
data class Task(
val input: String,
val expectedOutput: String, // Not in schema for LLM
val actualOutput: String?
)
val filter = Predicate<String> { it != "expectedOutput" }Use ParameterizedTypeReference for generic types.
open class JacksonOutputConverter<T>(
typeReference: ParameterizedTypeReference<T>,
objectMapper: ObjectMapper
) : StructuredOutputConverter<T>import org.springframework.core.ParameterizedTypeReference
data class Result<T>(val data: T, val status: String)
val typeRef = object : ParameterizedTypeReference<Result<Person>>() {}
val converter = JacksonOutputConverter(typeRef, mapper)
val response = """{"data": {"name": "Alice", "age": 30}, "status": "ok"}"""
val result = converter.convert(response)
// result = Result(data=Person(name=Alice, age=30), status=ok)List types:
val typeRef = object : ParameterizedTypeReference<List<Person>>() {}
val converter = JacksonOutputConverter(typeRef, mapper)Map types:
val typeRef = object : ParameterizedTypeReference<Map<String, Person>>() {}
val converter = JacksonOutputConverter(typeRef, mapper)Override postProcessSchema() to modify generated schema.
class CustomConverter<T>(
clazz: Class<T>,
objectMapper: ObjectMapper
) : JacksonOutputConverter<T>(clazz, objectMapper) {
override fun postProcessSchema(jsonNode: JsonNode) {
if (jsonNode is ObjectNode) {
// Disallow additional properties
jsonNode.put("additionalProperties", false)
// Add custom fields
jsonNode.put("$schema", "http://json-schema.org/draft-07/schema#")
// Modify existing properties
val properties = jsonNode.get("properties") as? ObjectNode
properties?.fields()?.forEach { (name, prop) ->
if (prop is ObjectNode) {
prop.put("description", "Field: $name")
}
}
}
}
}Add schema version:
jsonNode.put("$schema", "http://json-schema.org/draft-07/schema#")Disallow additional properties:
jsonNode.put("additionalProperties", false)Add required fields:
val required = jsonNode.putArray("required")
required.add("name")
required.add("email")Add examples:
jsonNode.putArray("examples").add(
mapper.createObjectNode().apply {
put("name", "Alice")
put("age", 30)
}
)fun extractPerson(text: String, llmClient: LLMClient): Person? {
val converter = JacksonOutputConverter(Person::class.java, mapper)
val prompt = """
Extract person information as JSON: ${converter.jsonSchema}
Text: $text
""".trimIndent()
val response = llmClient.call(prompt)
return converter.convert(response)
}fun convertWithValidation(text: String): Person? {
val person = converter.convert(text)
return person?.takeIf { it.age in 0..150 && it.email.contains("@") }
}fun convertBatch(responses: List<String>): List<Person> {
return responses.mapNotNull { converter.convert(it) }
}Cause: Malformed JSON that couldn't be parsed Solution: Check LLM response format, adjust temperature, improve prompt
Cause: LLM didn't include all required fields Solution: Make fields nullable or provide defaults
data class Person(
val name: String = "Unknown",
val age: Int? = null
)Cause: LLM used wrong type (e.g., string instead of number) Solution: Use lenient parsing or add type coercion
// Accept both string and int for age
data class Person(
val name: String,
val age: Any // Convert later
)→ See Error Handling for detailed troubleshooting → See Performance for optimization tips
tessl i tessl/maven-com-embabel-agent--embabel-agent-common@0.3.1