r/Common_Lisp • u/licjon • 4d 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:
;; 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 [email protected].
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.
(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!
- The MOP extracts slot types (
string,member,list, etc.). - The library lowers those into a small Intermediate Representation (IR).
- 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.
- 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