
Text-to-Diagram for AI Systems: Designing a DSL an LLM Gets Right
I have worked on sensor design, e-commerce, airlines, medicine and CAD software, in several countries. The domains could not be more different, yet one problem kept coming back: people do not understand each other. Not …
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
I have worked on sensor design, e-commerce, airlines, medicine and CAD software, in several countries. The domains could not be more different, yet one problem kept coming back: people do not understand each other. Not because they are careless. Everybody simply looks at the same system from a different side. Legal sees data, the CEO sees cost, DevOps sees uptime.
Take one sentence from a kick-off meeting:
“We are building an agent to fix invoices.”
What does fix mean? Who uses it? Does it see customer data? Where does it run, and who owns it? Nobody asks, because everybody thinks they know. A year later the project has five different answers to each of those questions.
Large language models make this more urgent, not less. Anybody can now describe a system in a paragraph, and a model will happily draw it. The open question is whether that drawing is the architecture, or just an architecture.
Gnomon is my attempt at an answer. It is a small language, gnomocode, and a compiler that checks every statement against an ontology. A language model translates what people say into gnomocode; the compiler decides what is allowed; and the result can be drawn, queried and read back as prose. The compiler is written in Python, on top of Lark, rdflib and pySHACL.
I presented it as a keynote at DevAI by Data Science Summit in Warsaw on 20 October 2026, under the title of this article. This is the written version of that talk: why I built Gnomon, how it works, and what the measurements say about it.
Work in progress, October 2026. A public repository is being prepared. Until it exists, this article is the best description of what is built and why it looks the way it does.
Draw the invoice agent and you are done in five seconds: invoices go in, an agent does something, fixed invoices come out. The box is easy. The questions around it are the architecture: who owns the data, where the model runs, whether personal data is involved, who reviews the edge cases. That gap between words and a diagram somebody can be held to is what I wanted to close.
It has to work in both directions. Forward, from a conversation to a model that a developer, or an agent, can build from. Backward, from existing code and diagrams to text that people can read. The forward direction is the harder one, and it is what this article is mostly about.
Before writing any code, I wrote down what a language sitting in the middle has to offer:
The last one turned out to matter more than I expected.
I did not start with a language. I started with the cheapest option and let it fail.
Just ask the model. “Draw an architecture for an agent that fixes invoices.” The answer is fluent, plausible and different every time. It invents words, gives no errors and no line numbers, and there is nothing to diff. When it is wrong, nothing tells you where.
Objects. Pydantic [12] classes, filled in by the model through structured output. Validation is real and every provider supports it. But an architecture is a graph, not a tree. A missing fact is either an error or a null, and a conversation with a client is mostly missing facts. Two statements cannot be merged, nor can they contradict each other, and every new term means a new class and a new release. The vocabulary ends up cemented in code.
Existing notations. UML, BPMN, C4 [10], ArchiMate and SysML v2 [11] are standards, and people understand them. They come with fixed symbols and a fixed depth, none of them has a “maybe”, and only some of them check whether a diagram makes sense, as opposed to whether it is drawn correctly.
A language of my own, with a compiler. This is the one that stayed. The idea is old and boring, which is how I like my ideas: let the model talk, let the compiler decide.
The translator, text2gnc, turns natural language into gnomocode. The compiler, which is plain rules with no model inside, checks each statement and builds a graph. When it finds errors, it sends them back to the translator with line numbers and hints. Everything else, diagrams for different audiences, recommendations, even prose, is drawn from the graph.
Two consequences matter to me. The script is the source of truth, not a single call to a model: the same script always compiles to the same graph. And errors are located and readable, so a person and a model can both act on them.
Before designing a single keyword, I went looking for who had already described this world. Plenty of people had. A taxonomy says what is a kind of what: a language model is a machine learning model. An ontology goes further and adds relations and rules: an agent uses a model, a model runs on an environment, and a dataset cannot run on anything at all. Such vocabularies have been written in RDF [1] for years, and some are widely used: SKOS [3] for taxonomies, PROV-O [2] for provenance, DCAT [4] for data catalogues, DPV [7] for privacy, QUDT [8] for units and schema.org [9] for the web. OWL [6] defines the classes and SHACL [5] checks the graphs.
So the rule became: take terms, relations, types and hierarchy from what exists, and add the minimum. The result is a kernel of 57 terms and twelve modules (systems, deployment, integration, data, ML, agents, processes, operations, security, legal, cost and service levels) that a script imports only when it needs them. From day one there was also a licence rule: nothing copyleft goes in, so that the whole thing can later be distributed freely and commercially.
Why call the ontology the key? Because every other part of Gnomon is generated from it or checked against it. Take a single word that a script writes, runs_on. In the ontology it has three layers: a lexicon entry that gives the word and its inverse reading, a property with a definition, and a shape that says what may stand on either side of it.
gnlex:runs-on a skos:Concept ; skos:prefLabel "runs on"@en ; gn:inverseReading "hosts"@en ; gn:denotes gn:runsOn .
gn:runsOn a owl:ObjectProperty ; rdfs:label "runs on"@en ; skos:definition "The subject executes in or on the object."@en ; gn:acyclic true .
gnsh:runsOn-subject a sh:NodeShape ; sh:targetSubjectsOf gn:runsOn ; sh:or ( [ sh:class gn:System ] [ sh:class gn:Model ] [ sh:class gn:Activity ] ) ; sh:message "only a system, a model or an activity can run on something" ; gn:diagnosticCode "type/signature" .
That one definition feeds the whole tool chain. The lexicon decides which words a script may use, and therefore which words a model may generate. The shape becomes the compiler’s error message, word for word. The labels become the English sentences in which a script is read back. The concerns attached to each term decide which audience sees it on a diagram. And because Gnomon’s classes are declared as subclasses of the borrowed ones (a gn:Dataset is also a dcat:Dataset and a prov:Entity), the compiled graph is readable by tools that have never heard of Gnomon.
The grammar of gnomocode is generated from the kernel alone. Words come from the lexicon, so adding a module adds vocabulary without changing the grammar. Extending the language is a matter of editing data, not releasing code.
Why not let the model write RDF directly and validate it with SHACL? RDF is precise, but it is not something I want to read in a review. Here is the invoice agent as gnomocode, 23 lines:
gnomon: 0.2.2 as_of: 2026-10 state: TARGET import: systems, deployment, ml, agent, process
finance_team: Team erp: Application owned_by = finance_team runs_on = CloudEnvironment() ~0.5 # "in the cloud, I think" invoices: Dataset stored_in = erp contains_personal_data = true
invoice_agent: AIAgent uses = LanguageModel() classifies(invoices, frequency = 200/DAY)
fixing: Process = check >> ("amount > 10k" => review | post) review.performer = finance_team invoices -> check -> checked_invoices checked_invoices: Dataset
It compiles to 76 lines of RDF. Three forms carry almost everything: name: Type declares an element, property = value states a fact about it, and action(object) says what it does. Flows connect steps (>>) and data (->). The notation borrows from places people already know: front matter from Markdown, indented blocks and assignments from Python, arrows from graph query languages. Even the case of a word tells you its kind: Type, property, CONSTANT.
The front matter pins the release of the ontology the script was written against, says whether it describes the current state or a target, and imports only the modules the text needs. An old script therefore compiles against the same release, or gets migrated to a new one on purpose.
Look at the ~0.5 on the runs_on line. It means “I think so, but I am only half sure”, and it is part of the language. Confidence has a few reference points: 1 is confirmed in a system or a document, 0.8 is stated by someone who does the work, 0.5 is second-hand, 0.2 is a guess. Below 0.7 a fact is not an error, but it is reported as a finding. In the graph it becomes an annotated statement, so you can query for everything that still needs confirming:
rdf:subject :erp ; rdf:predicate gn:runsOn ; rdf:object minted:cloud-environment-0 ; gn:confidence 0.5 .
The syntax at a glance
The invoice script uses only part of the language. Here is most of the rest in one script that compiles, with a comment on what each line does:
gnomon: 0.2.2 as_of: 2026-10 state: CURRENT import: systems, deployment, ml, agent, process
sales_team: Team # declares an element support_team: Team cloud_account: CloudEnvironment tickets: Dataset product_catalogue: Dataset
crm: Application runs_on = cloud_account ~0.8 # a fact and its confidence owned_by = sales_team # inverse reading of owns search_tool: Tool # a part of crm
support_agent: AIAgent uses = search_tool, LanguageModel() # two values, one unnamed reads(product_catalogue) # an action: a new activity summarises(tickets, frequency = 50/DAY) # with its own properties
LanguageModel[0] is gpt_4o # two names, one element gpt_4o: LanguageModel name = "GPT-4o"
triage: Process = sort >> ("urgent" => call_back | answer) sort: Classification performer = support_agent duration = 3 MIN # a quantity with a unit call_back.performer = support_team tickets -> sort -> sorted_tickets # data flows through a step sorted_tickets: Dataset
A few things are worth noticing. A declaration inside a block makes the element a part of its parent, so nesting is how you say “consists of”. An action such as reads(product_catalogue) does not need a name: the compiler creates an unnamed activity of the right kind, with the agent as its performer and the catalogue as its input, and numbers it so that later lines can refer to it. And | in a flow means exactly one branch is taken, while & means all of them are.
The compiler is plain Python 3.14, managed with uv. Parsing is done by Lark [20], a parsing toolkit for Python under the MIT licence. The grammar is not written by hand: gnomon grammar generates it from the kernel of the ontology, with one slot per class of words. A new module adds words, never grammar rules. An excerpt:
declaration: NAME ":" type_ref confidence? assigned_flow? _NEWLINE block? block: _INDENT member+ _DEDENT own_property: NAME "=" values _NEWLINE own_action: NAME "(" [arguments] ")" confidence? _NEWLINE step_flow: step_term (">>" step_term)+ data_flow: reference ("->" reference)+ group: "(" branch ("|" branch)* ")" -> one_of | "(" branch ("&" branch)+ ")" -> all_of confidence: "~" NUMBER
TYPE: /[A-Z][A-Z0-9][a-z][A-Za-z0-9]/ CONST: /[A-Z][A-Z0-9_](?![A-Za-z0-9_])/ NAME: /[^\W\dA-Z]\w/
Indentation works exactly as in Python, because Lark ships a post-lexer for it. Subclass Indenter, name the tokens, and the LALR parser receives _INDENT and _DEDENT like any other token:
from lark import Lark from lark.indenter import Indenter
class GnomocodeIndenter(Indenter): NL_type = "_NEWLINE" OPEN_PAREN_types = ["LPAR", "LSQB"] CLOSE_PAREN_types = ["RPAR", "RSQB"] INDENT_type = "_IN
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。