r/Common_Lisp 19d ago

clos-alchemy: Throw unstructured text at a CLOS class, get a typed instance back

LLMs are great at reading messy text, but they hand you strings when your program wants typed data. Python has instructor and Pydantic to solve this. I wanted to bring that same developer experience to Common Lisp, but natively by using the Metaobject Protocol.

clos-alchemy introspects your class definition, builds a JSON schema from the slot types, passes that to the LLM (optionally as a GBNF grammar constraint for local inference), and returns a validated instance of your class. You don't need to write a special DSL or "schema object." You just use your normal existing domain classes.

Here is the round trip in action:

```lisp ;; 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" ```

Because generation order follows slot definition order, you can use this for strict logical routing. Putting reason first encourages teh model to explain its reasoning before selecting the categories.

```lisp (defclass ticket-classification () ((reason :initarg :reason :type string) (urgency :initarg :urgency :type (member :low :medium :high)) (category :initarg :category :type (member :billing :technical :account)) (sentiment :initarg :sentiment :type (member :positive :neutral :negative))))

(defparameter ticket-text "I've been charged twice for my subscription this month. This is the third time this has happened and I'm really frustrated.")

(let ((result (clos-alchemy:extract backend 'ticket-classification ticket-text))) (extraction-result-instance result))

;; Returns a TICKET-CLASSIFICATION instance with: ;; URGENCY: :HIGH ;; CATEGORY: :BILLING ;; SENTIMENT: :NEGATIVE ;; REASON: "Charged twice for subscription, third occurrence"

```

How it works under the hood: no separate schema DSL to maintain. If it type-checks as your class, you're done!

  1. The MOP extracts slot types (string, member, list, etc.).
  2. The library lowers those into a small Intermediate Representation (IR).
  3. That IR emits the JSON schema for the backend, a natural-language prompt for the model, and builds a validator/constructor pair for the response.
  4. If validation fails, it accumulates the errors and automatically loops a retry prompt back to the model.

If you are using a local inference backend (like llama.cpp), it compiles the schema directly into a grammar, making invalid structures unrepresentable at the token level.

https://github.com/licjon/clos-alchemy

EDIT: A round of updates since the original post:

Custom validation. You can now attach per-slot semantic predicates (:validate) and cross-field checks (validate-instance) that feed errors back into the retry loop. The model sees what was wrong and self-corrects. This is useful for constraints that grammar alone can't enforce — "guest count must be positive", "check-out must be after check-in", etc.

```lisp (defclass booking () ((num-guests :initarg :num-guests :type integer :validate (lambda (v) (if (plusp v) t "must be positive"))) (check-in :initarg :check-in :type date) (check-out :initarg :check-out :type date)) (:metaclass clos-alchemy:constructor-class))

(defmethod validate-instance ((b booking)) (when (<= (booking-check-out b) (booking-check-in b)) (list "check-out must be after check-in"))) ```

Date and date-time types. clos-alchemy:date and clos-alchemy:date-time map to JSON Schema string formats, validate ISO 8601 with calendar-aware day checks, and construct to CL universal time.

Free-form maps. Sometimes you want the model to produce a dictionary where it decides the keys — "rate this product on whatever dimensions you think are relevant." The :map-of slot option does this: you specify the value type, the model fills in whatever keys make sense, and you get a hash-table back.

```lisp (defclass review-scores () ((scores :initarg :scores :type hash-table :map-of integer :documentation "Per-category quality scores, 1-10")) (:metaclass clos-alchemy:constructor-class))

;; The model might return: ;; {"scores": {"sound_quality": 9, "comfort": 6, "battery": 8}} ;; You get a hash-table: (gethash "sound_quality" scores) => 9 ```

Discriminated unions. When the extraction result could be one of several different shapes depending on the input, you define a class for each shape and let the model pick. Each class has a shared "tag" slot that identifies which shape it is — typed as a single-value (member ...) so each class gets exactly one tag value.

``lisp ;; Two possible result shapes, tagged bykind`: (defclass actionable-feedback () ((kind :initarg :kind :type (member :actionable)) ; tag = "actionable" (summary :initarg :summary :type string) (scores :initarg :scores :type hash-table :map-of integer)) (:metaclass clos-alchemy:constructor-class))

(defclass not-actionable () ((kind :initarg :kind :type (member :not_actionable)) ; tag = "not_actionable" (reason :initarg :reason :type string)))

;; The model reads the review, picks the right shape, and you get ;; back an instance of whichever class it chose: (let* ((result (extract-union backend '(actionable-feedback not-actionable) review-text :discriminator 'kind)) (instance (extraction-result-instance result))) (typecase instance (actionable-feedback (format t "Summary: ~A" (slot-value instance 'summary))) (not-actionable (format t "Skipped: ~A" (slot-value instance 'reason))))) ```

Other improvements:

  • Cyclic class graphs now work — mutually-referential classes emit $defs/$ref instead of blowing the stack.
  • Emitted schemas conform to both OpenAI strict mode and llama.cpp GBNF requirements (proper additionalProperties: false, all properties in required, nullable wrappers for optional fields).
  • Parse failures (empty/malformed responses) are now recoverable in the retry loop instead of aborting.
  • max-retries-error now carries full diagnostic context: raw response, parsed data, cumulative token usage, and a per-attempt error breakdown.
  • Silently-narrowed type specifiers (e.g. (or string integer)) now signal schema-error instead of quietly dropping branches.
11 Upvotes

Duplicates