Extract structured data from unstructured text via LLMs into typed CLOS instances.
Define ordinary CLOS classes — or pass a bare CL type specifier for single
values. clos-alchemy introspects your types via the MOP, generates a JSON
Schema with constraints, and returns typed CLOS instances or unwrapped values.
LLMs are good at reading messy text but hand you strings; programs want typed data. Python has instructor (Pydantic models as extraction targets); Common Lisp already has a richer modelling layer built in — CLOS with the MOP. This library uses your class definition itself as the extraction contract: slot types become the JSON Schema, the schema becomes a grammar constraint on generation, and the response comes back as a real instance of your class. There’s no separate schema DSL to maintain, and no hash tables or plists to destructure — if it type-checks as your class, you’re done.
;; 1. Define a class
(defclass person ()
((name :initarg :name :accessor person-name :type string)
(age :initarg :age :accessor person-age :type integer)
(email :initarg :email :accessor person-email :type (or null string))
(hobbies :initarg :hobbies :accessor person-hobbies :type list)))
;; 2. Extract from text
(let* ((backend (cl-llm-backend/llama:make-llama-backend
:model model :context ctx))
(result (extract backend 'person
"Alice Chen is 32. Reach her at alice@example.com.
She enjoys rock climbing, painting, and cello.")))
(person-name (extraction-result-instance result)))
;; => "Alice Chen"The returned instance is a real CLOS object — typep, slot-value, and all
accessors work as expected.
When the extraction target is a single value, skip the wrapper class and pass a CL type specifier directly:
;; Extract a bare integer
(extract-value backend 'integer
"The tower stands 330 metres tall."
:user-prompt "Extract the height in metres.")
;; => 330
;; Sentiment as an enum keyword
(extract-value backend '(member :positive :negative :neutral)
"I've been waiting three weeks and I'm furious."
:user-prompt "What is the sentiment?")
;; => :NEGATIVE
;; Bounded integer via type specifier
(extract-value backend '(integer 1 5)
"The critic gave it 4 out of 5 stars."
:user-prompt "Extract the star rating.")
;; => 4extract-value supports all types cl-type-to-ir-type handles: primitives,
enums, lists ((vector string)), constrained integers, and so on. The
returned extraction-result-instance is the unwrapped value, not a CLOS
object.
Not yet in Quicklisp — clone this repo and cl-llm-backend into a directory
ASDF searches, e.g. ~/quicklisp/local-projects/ (or
~/.roswell/local-projects/ for Roswell):
cd ~/quicklisp/local-projects
git clone https://github.com/licjon/clos-alchemy
git clone https://github.com/licjon/cl-llm-backendThen load the core system — the remaining dependencies (closer-mop, yason,
cl-ppcre) come from Quicklisp automatically:
(ql:quickload :clos-alchemy)If Quicklisp doesn’t see the fresh clones, run (ql:register-local-projects)
first.
The llama.cpp backend additionally requires cl-llama-cpp — which bundles llama.cpp as a git submodule and needs it built as a shared library — and cl-llama-cpp-extras:
cd ~/quicklisp/local-projects
git clone --recursive https://github.com/licjon/cl-llama-cpp
git clone https://github.com/licjon/cl-llama-cpp-extras
cd cl-llama-cpp/llama.cpp
cmake -B build -DBUILD_SHARED_LIBS=ON
cmake --build build -j$(nproc)See the cl-llama-cpp README for GPU builds, full requirements, and troubleshooting. Then:
(ql:quickload :cl-llm-backend/llama)You’ll also need a GGUF model file (e.g. from Hugging Face) to run the examples.
| System | Description | Dependencies |
|---|---|---|
clos-alchemy | Core library — introspection, IR, schema | closer-mop, yason, cl-ppcre, cl-llm-backend |
clos-alchemy/tests | Core test suite | rove |
clos-alchemy/tests/gbnf | GBNF conformance tests (asserts schemas compile to GBNF grammars) | rove, cl-llama-cpp-extras/json-schema |
clos-alchemy/telemetry | Reference consumers for extraction lifecycle conditions (logging) | clos-alchemy |
clos-alchemy/examples | Example programs | clos-alchemy, clos-alchemy/telemetry, cl-llm-backend/llama |
CLOS class
│
▼
MOP introspection (closer-mop)
│
▼
IR schema (ir-schema, ir-field, ir-type-*)
│
├──▶ JSON Schema (hash table, with $defs/$ref for cycles)
│ │
│ ▼
│ GBNF grammar (via cl-llama-cpp-extras/json-schema)
│ │
│ ▼
│ Constrained generation (cl-llama-cpp :grammar)
│ │
│ ▼
│ JSON response
│
├──▶ Structural validation (validate-data)
│
▼
CLOS instance (construct-from-data)
│
▼
Semantic validation (:validate, validate-instance)
│
▼
Result (or retry with error feedback)
The IR (intermediate representation) normalises CLOS MOP metadata into a
backend-agnostic schema. ir-schema describes the extraction target,
ir-field its slots, and ir-type-* structs form a tagged union of supported
types. Backends only see the IR — they never touch MOP internals.
Grammar-constrained generation eliminates JSON parsing failures. Validation
then checks structural constraints (field types, required fields) and semantic
constraints (per-slot :validate predicates, validate-instance methods);
failed attempts retry automatically (default 3 retries) with error messages
fed back to the model as corrective context. Parse failures are also
recoverable — they accumulate errors and trigger corrective retries rather
than aborting.
Mutually-referential classes are handled via $defs / $ref in the emitted
JSON Schema, so cyclic class graphs work without stack overflow.
Type specifiers on CLOS slots map to JSON Schema types:
| CL type specifier | JSON Schema | IR type |
|---|---|---|
string, (string N) | {"type":"string"} | ir-type-primitive |
integer, fixnum, (integer lo hi) | {"type":"integer"} | ir-type-primitive |
(unsigned-byte N), (signed-byte N), (mod N) | {"type":"integer"} | ir-type-primitive |
float, number, (float lo hi), real | {"type":"number"} | ir-type-primitive |
boolean | {"type":"boolean"} | ir-type-primitive |
(member :a :b :c) | {"enum":["a","b","c"]} | ir-type-enum |
list | Array of strings | ir-type-list |
:list-of <type> | {"type":"array","items":{...}} | ir-type-list |
(vector <type>), (array <type>) | {"type":"array","items":{...}} | ir-type-list |
(or null <type>) | {"anyOf":[{...},{"type":"null"}]} | ir-type-nullable |
date | {"type":"string","format":"date"} | ir-type-date |
date-time | {"type":"string","format":"date-time"} | ir-type-date |
:map-of <type> | {"additionalProperties":{...}} | ir-type-map |
| A CLOS class name | Nested object | ir-type-object |
| Discriminated union | {"anyOf":[...]} with enum disc. | ir-type-union |
A plain list slot defaults to string elements, and (vector <type>) gives
typed elements on any class — neither needs a metaclass. Standard CL has no
parameterised list type, so for the one remaining case — a list of
non-string elements — :list-of is available as a custom slot option on
classes with (:metaclass constructor-class):
(defclass survey ()
((scores :initarg :scores :type list :list-of integer))
(:metaclass clos-alchemy:constructor-class))Slots typed (vector <type>) or (array <type>) receive a CL vector; list
and :list-of slots receive a list. All elements must be the same type;
mixed-type arrays (e.g. ["alice", 32, true]) are not supported.
The exported symbols clos-alchemy:date and clos-alchemy:date-time map to
JSON Schema format: "date" and format: "date-time" respectively. Values
are validated as ISO 8601 with calendar-aware day checks and constructed to
CL universal time:
(defclass event ()
((title :initarg :title :type string)
(when :initarg :when :type date)))When the set of keys is unknown at schema-definition time, use the :map-of
slot option (requires constructor-class). The JSON Schema emits
additionalProperties with a value-type schema; the CL representation is a
hash-table with equal test:
(defclass scores ()
((per-category :initarg :per-category :type hash-table
:map-of integer))
(:metaclass clos-alchemy:constructor-class))When the model must choose between structurally different result shapes, use
compile-union-extractor / extract-union. Each class in the union has a
shared discriminator slot typed as a single-value (member :tag) enum:
(defclass success ()
((kind :initarg :kind :type (member :success))
(value :initarg :value :type integer)))
(defclass failure ()
((kind :initarg :kind :type (member :failure))
(reason :initarg :reason :type string)))
(let ((c (compile-union-extractor '(success failure)
:discriminator 'kind)))
(extract-union backend c document))The returned instance is whichever CLOS class the model selected. Unions compose with list, nullable, map, and nested-object types.
CL type specifiers with bounds automatically emit JSON Schema constraints:
| CL type specifier | Emitted constraints |
|---|---|
(integer lo hi) | minimum, maximum |
(float lo hi) | minimum, maximum |
(unsigned-byte N) | minimum: 0, maximum: 2^N-1 |
(signed-byte N) | minimum: -2^(N-1), maximum: 2^(N-1)-1 |
(mod N) | minimum: 0, maximum: N-1 |
(string N) | maxLength: N |
For constraints that CL type specifiers can’t express, use metaclass slot
options (requires constructor-class):
| Slot option | JSON Schema keyword | Applies to |
|---|---|---|
:minimum | minimum | numbers |
:maximum | maximum | numbers |
:exclusive-minimum | exclusiveMinimum | numbers |
:exclusive-maximum | exclusiveMaximum | numbers |
:min-length | minLength | strings |
:max-length | maxLength | strings |
:pattern | pattern | strings |
:min-items | minItems | arrays |
:max-items | maxItems | arrays |
Slot options override type-inferred bounds when both are present:
(defclass user-profile ()
((username :initarg :username :type string
:min-length 3 :max-length 20
:pattern "^[a-zA-Z][a-zA-Z0-9_]*$")
(rating :initarg :rating :type (integer 1 10)
:minimum 1 :maximum 5
:documentation "Slot options narrow the type's 1-10 range to 1-5")
(interests :initarg :interests :type list :list-of string
:min-items 1 :max-items 5))
(:metaclass clos-alchemy:constructor-class))Constraints are emitted into the JSON Schema so the model respects them during generation. The validator enforces the same bounds — violations caught on a backend that doesn’t enforce them trigger the corrective retry loop.
Any other type specifier signals a schema-error — named types with no JSON
analogue (hash-table, symbol, keyword, character, pathname) and
compound specifiers that can’t be mapped ((satisfies ...), (complex ...),
(cons ...), (values ...)). Nothing falls back to string silently; use
:slot-types overrides to map exotic types explicitly.
Slot :documentation flows into the JSON Schema (as description) and the
generated system prompt, so the model sees what each field means:
(defclass person ()
((name :initarg :name :type string
:documentation "The person's full legal name")))Descriptions materially improve extraction quality on ambiguous fields — prefer them over relying on slot names alone. Enum fields keep their allowed values visible in the prompt alongside the description.
Beyond the structural checks that schema validation provides, you can add semantic constraints that feed errors back into the retry loop.
Attach a predicate to any slot via the :validate option (requires
constructor-class). The function receives the slot’s value and returns t
on success or an error-message string on failure:
(defclass booking ()
((num-guests :initarg :num-guests :type integer
:validate (lambda (v) (if (plusp v) t "must be positive"))))
(:metaclass clos-alchemy:constructor-class))For constraints that span multiple slots, specialize the validate-instance
generic function on your class. Return nil if valid, or a list of
error-message strings:
(defmethod validate-instance ((b booking))
(when (<= (booking-check-out b) (booking-check-in b))
(list "check-out must be after check-in")))Both per-slot and instance validation errors are fed back to the model as corrective context on the next retry, so the LLM sees what was wrong and can self-correct.
compile-extractor (class &key mode slot-list slot-types user-prompt)— pre-compile the extraction pipeline. Returns a reusableextraction-compilationwith a cached IR schema and system prompt.extract (backend compilation-or-class document &key max-retries temperature max-tokens user-prompt)— extract a single instance.extract-list (backend compilation-or-class document &key ...)— extract multiple instances; the result’s instance slot is a list (possibly empty).extract-value (backend type-spec document &key max-retries temperature max-tokens user-prompt)— extract a single value of a CL type specifier; the result’s instance slot is the unwrapped value.compile-union-extractor (classes &key discriminator user-prompt)— pre-compile a discriminated union extraction pipeline.extract-union (backend compilation-or-classes document &key discriminator max-retries temperature max-tokens user-prompt)— extract one of N union shapes.validate-instance (instance)— generic function for cross-field validation. Default returnsnil; specialize on your class.class-to-schema (class &key mode slot-list slot-types)— introspect a class into an IR schema.schema-to-json-schema (schema)— convert an IR schema to a JSON Schema hash table.
Backends implement the cl-llm-backend protocol; extract works with any
backend that supports structured output (:output-schema). To add one,
specialize llm:backend-generate on your class and declare
llm:backend-supports-structured-output-p true — see the cl-llm-backend
README. The core builds the messages, passes the JSON Schema via
:output-schema, and parses the returned text itself
(parse-json-response).
(cl-llm-backend/llama:make-llama-backend :model model :context ctx)Uses grammar-constrained generation — the model is forced to produce JSON matching the schema, eliminating post-hoc parsing failures.
class-to-schema and compile-extractor accept a :mode keyword:
| Mode | Behavior |
|---|---|
:full | All slots (default) |
:required | Only slots with :initarg and no :initform |
:custom | Only slots listed in :slot-list |
- Typed output. The result is always a real CLOS instance —
typep, accessors, andslot-valuework. No hash tables or plists to destructure. - Grammar before validation. Constrained decoding prevents malformed JSON at generation time. Validation is a safety net for semantic errors, not the primary correctness mechanism.
- IR separation. Backends receive a backend-agnostic intermediate representation, never MOP internals. Adding a new LLM backend requires two generic functions, not knowledge of the introspection layer.
- Ordinary classes. Define a CLOS class with types on its slots and the
library introspects it. No metaclass needed for the common cases — plain
listslots default to string elements and(vector <type>)works anywhere.constructor-classis opt-in, only for custom slot options (:list-of,:map-of,:validate, constraint options).
Set the model path once, then run either example:
(ql:quickload :clos-alchemy/examples)Or set the environment variable LLAMA_MODEL before starting the Lisp image.
Single extraction, list extraction, and nested classes.
(setf clos-alchemy/examples/extract-llama::*model-path*
"/path/to/model.gguf")
(clos-alchemy/examples/extract-llama:run)Classifies support tickets by urgency, category, and sentiment using enum-typed slots. The grammar constraint forces the LLM to pick from the allowed values — no training data, no string matching, no post-processing.
(defclass ticket-classification ()
((reason :type string ...)
(urgency :type (member :low :medium :high :critical) ...)
(category :type (member :billing :technical :account :general) ...)
(sentiment :type (member :positive :negative :neutral) ...)))Fields are generated in slot-definition order, so putting the free-text
reason slot first makes the model write its analysis before the grammar
forces it to commit to the enum values — chain-of-thought inside the schema.
(setf clos-alchemy/examples/classify-llama::*model-path*
"/path/to/model.gguf")
(clos-alchemy/examples/classify-llama:run)Example output:
── Ticket 1 ── I've been charged twice for my subscription this month. This is the third time this has happened and I'm really frustrated. Urgency: HIGH Category: BILLING Sentiment: NEGATIVE Reason: "Charged twice for subscription, third occurrence" Retries: 0
Per-slot :validate predicates and a validate-instance method enforce
semantic constraints (positive guest counts, date ordering, nights consistency)
on hotel bookings. Adversarial inputs provoke extraction errors that the
validator catches and feeds back to the model for self-correction. Uses date
typed slots.
(setf clos-alchemy/examples/validate-llama::*model-path*
"/path/to/model.gguf")
(clos-alchemy/examples/validate-llama:run)Type-inferred numeric bounds ((integer 0 100), (float 0.0 4.0)), slot
option constraints (:min-length, :pattern, :min-items), and combined
constraints where slot options narrow type-inferred ranges.
(setf clos-alchemy/examples/constraints-llama::*model-path*
"/path/to/model.gguf")
(clos-alchemy/examples/constraints-llama:run)Extracts bare values without wrapper classes: integer, boolean, enum keyword,
string vector, and constrained integer via extract-value.
(setf clos-alchemy/examples/value-llama::*model-path*
"/path/to/model.gguf")
(clos-alchemy/examples/value-llama:run)Product review analyzer: the model decides whether a review is actionable
(with per-category scores in a :map-of slot) or not actionable (with a
reason). A discriminated union constrains the model to pick exactly one shape.
(setf clos-alchemy/examples/union-llama::*model-path*
"/path/to/model.gguf")
(clos-alchemy/examples/union-llama:run)Demonstrates the extraction-attempt=/=extraction-retry=/=extraction-exhausted
conditions and the abort-extraction=/=retry-with-backend restarts, plus
with-extraction-logging. Unlike the other examples, this one needs no GGUF
model — every backend call is scripted with cl-llm-backend’s
mock-backend, so it’s deterministic and runs instantly:
(clos-alchemy/examples/telemetry:run)When all retries are exhausted, max-retries-error is signalled with
diagnostic context for debugging or salvage:
max-retries-error-raw-response— the last raw LLM output stringmax-retries-error-raw-data— the last parsed data (ornilon parse failure)max-retries-error-usage— cumulative token usage across all attempts (:prompt-tokens,:completion-tokens)max-retries-error-attempts— list of per-attempt plists (:raw-response,:raw-data,:errors)max-retries-error-retriesandmax-retries-error-errors— unchanged from before
For real-time observability into the retry loop — logging, metrics,
circuit-breaking, debugging — %extract-with-retry signals CL conditions as
it runs, rather than requiring you to poll or wrap every call:
extraction-attempt— signaled once per attempt, success or failureextraction-retry— signaled when an attempt failed and another attempt will run; establishes theabort-extractionandretry-with-backendrestartsextraction-exhausted— signaled immediately beforemax-retries-erroris raised
Each condition carries extraction-event-attempt-number,
-raw-response, -raw-data, -parse-error, -validation-errors, and
-usage (cumulative token usage so far). These are plain CL conditions, not
errors — an unhandled signal is a no-op, so extraction behaves identically
whether or not a handler is bound:
(handler-bind ((extraction-attempt
(lambda (e)
(format t "attempt ~D: ~D validation errors~%"
(extraction-event-attempt-number e)
(length (extraction-event-validation-errors e))))))
(extract backend 'person document))extraction-retry establishes two restarts a handler can invoke to
intervene mid-loop:
abort-extraction— stop retrying immediately; raisesmax-retries-errorwith diagnostics accumulated so far (:retriesreflects the actual attempt count, not the configuredmax-retries)retry-with-backend <new-backend>— swap the backend used for all remaining attempts, e.g. failing over to a healthier backend
(handler-bind ((extraction-retry
(lambda (e)
(when (circuit-breaker-tripped-p)
(invoke-restart 'abort-extraction)))))
(extract backend 'person document :max-retries 5))clos-alchemy/telemetry ships with-extraction-logging, a minimal
handler-bind-based consumer that logs one line per signaled event:
(ql:quickload :clos-alchemy/telemetry)
(clos-alchemy/telemetry:with-extraction-logging (*standard-output*)
(extract backend 'person document))It’s a starting point for logging, metrics, or circuit-breaking integrations, not a full observability platform — OpenTelemetry-style tracing export is left as future work on the same condition protocol.
(asdf:test-system :clos-alchemy) ; no model neededMIT