Skip to content

About

Given a CLOS class and unstructured text, extract a typed instance with LLM-constrained generation.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Latest commit

 

History

52 Commits

Folders and files

Repository files navigation

clos-alchemy

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.

Why

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.

Quick start

;; 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.

Primitive extraction

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.")
;; => 4

extract-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.

Installation

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-backend

Then 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.

llama.cpp backend

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.

Systems

SystemDescriptionDependencies
clos-alchemyCore library — introspection, IR, schemacloser-mop, yason, cl-ppcre, cl-llm-backend
clos-alchemy/testsCore test suiterove
clos-alchemy/tests/gbnfGBNF conformance tests (asserts schemas compile to GBNF grammars)rove, cl-llama-cpp-extras/json-schema
clos-alchemy/telemetryReference consumers for extraction lifecycle conditions (logging)clos-alchemy
clos-alchemy/examplesExample programsclos-alchemy, clos-alchemy/telemetry, cl-llm-backend/llama

How it works

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.

Supported types

Type specifiers on CLOS slots map to JSON Schema types:

CL type specifierJSON SchemaIR 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
listArray of stringsir-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 nameNested objectir-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.

Dates

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)))

Free-form maps (:map-of)

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))

Discriminated unions

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.

Schema constraints

CL type specifiers with bounds automatically emit JSON Schema constraints:

CL type specifierEmitted 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 optionJSON Schema keywordApplies to
:minimumminimumnumbers
:maximummaximumnumbers
:exclusive-minimumexclusiveMinimumnumbers
:exclusive-maximumexclusiveMaximumnumbers
:min-lengthminLengthstrings
:max-lengthmaxLengthstrings
:patternpatternstrings
:min-itemsminItemsarrays
:max-itemsmaxItemsarrays

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.

Unsupported types

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.

Field descriptions

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.

Custom validation

Beyond the structural checks that schema validation provides, you can add semantic constraints that feed errors back into the retry loop.

Per-slot predicates (:validate)

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))

Cross-field validation (validate-instance)

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.

API

Core

  • compile-extractor (class &key mode slot-list slot-types user-prompt) — pre-compile the extraction pipeline. Returns a reusable extraction-compilation with 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 returns nil; 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

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).

llama.cpp backend

(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.

Extraction modes

class-to-schema and compile-extractor accept a :mode keyword:

ModeBehavior
:fullAll slots (default)
:requiredOnly slots with :initarg and no :initform
:customOnly slots listed in :slot-list

Design principles

  • Typed output. The result is always a real CLOS instance — typep, accessors, and slot-value work. 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 list slots default to string elements and (vector <type>) works anywhere. constructor-class is opt-in, only for custom slot options (:list-of, :map-of, :validate, constraint options).

Examples

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.

Extraction (extract-llama)

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)

Zero-shot structured classifier (classify-llama)

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

Semantic validation (validate-llama)

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)

Schema constraints (constraints-llama)

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)

Primitive extraction (value-llama)

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)

Discriminated union (union-llama)

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)

Lifecycle hooks (telemetry)

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)

Error handling

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 string
  • max-retries-error-raw-data — the last parsed data (or nil on 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-retries and max-retries-error-errors — unchanged from before

Lifecycle hooks

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 failure
  • extraction-retry — signaled when an attempt failed and another attempt will run; establishes the abort-extraction and retry-with-backend restarts
  • extraction-exhausted — signaled immediately before max-retries-error is 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))

Restarts

extraction-retry establishes two restarts a handler can invoke to intervene mid-loop:

  • abort-extraction — stop retrying immediately; raises max-retries-error with diagnostics accumulated so far (:retries reflects the actual attempt count, not the configured max-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))

Reference logging consumer (clos-alchemy/telemetry)

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.

Running tests

(asdf:test-system :clos-alchemy)  ; no model needed

License

MIT

About

Given a CLOS class and unstructured text, extract a typed instance with LLM-constrained generation.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages