跳到正文
原文
Google AI:DEV 作者专属(RSS)· Lakhan Malviya·· 4 小时前AI 评分56

OKF 系列教程 Part 3:把数据库文档编译成 Agent 可用的知识包

Turn Database Docs into Agent-Ready Knowledge (OKF Series, Part 3)

AI 导读

作者构建 okf_compiler Python 包,把 LiveSQLBench 数据库的原始文档(schema、列含义、业务规则)编译成 OKF 知识包,每张表和每条业务规则各一个概念文件,互相链接并带索引。

正文

None

An AI agent that writes SQL for your database needs more than the schema. It also needs to know what your business terms mean: which columns make up a "Resource Utilization Ratio", or which three conditions turn an operation "high-risk". Most teams already have that knowledge written down somewhere, in a schema dump, a column dictionary or a page of metric definitions. The trouble is that it's not in a form an agent can find its way through.

Over this part and the next, we build the pieces that close that gap for one real database, and run them end to end:

  1. A compiler. A small Python package that reads a database's raw documentation and writes it as an OKF bundle: one concept file per table and per business rule, linked to each other, with an index file in each folder.
  2. An agent. A simple SQL agent on an open model. It reads the bundle the way you'd browse a wiki: the index first, then only the files the question needs. Then it writes the SQL and runs it.
  3. A side-by-side run. We ask the same questions twice: once with the bundle, and once with all the raw documentation pasted into the prompt. Then we compare what the agent read, how many tokens it used, and the SQL it wrote.

This part builds the first piece, the compiler, and runs it on all 18 databases in the benchmark. The next part builds the agent and runs the comparison. By the end of both, you'll have a working system on your laptop: one database, its bundle, and an agent you can ask your own questions.

Our database is disaster, from LiveSQLBench, a public text-to-SQL benchmark: 10 tables and 54 business rules about disaster-response operations. If you followed the previous part, you wrote three of its concept files by hand. Now we generate all of them. If you didn't, everything you need is in this post.

What we build in this part, and the two ways we feed the agent.

Set up the project and the database

We need two things before writing any code: a place for the compiler to live, and a real database for the agent to run its SQL against.

The project layout

We add a Python package, okf_compiler, next to the data/ and bundles/ folders. (If you're starting here, set up the project and download the data as described in the tutorial guide.)

Here's where we're heading. Each module is written, in full, in the step that needs it:

okf-sql-knowledge/
├── data/
│   └── livesqlbench-base-lite/
├── bundles/
├── okf_compiler/
│   ├── __init__.py
│   ├── readers.py      read the three raw files into Python
│   ├── concept.py      write one concept file: frontmatter + body
│   ├── tables.py       build one concept file per table
│   ├── rules.py        build one concept file per business rule, with links
│   ├── indexes.py      write the index files and the log
│   └── cli.py          one command to compile a database
├── docker-compose.yml
├── requirements.txt
└── README.md

Create the okf_compiler/ folder with an empty __init__.py inside it.

The database

The agent we build later runs real SQL, so it needs the real disaster database. The LiveSQLBench team publishes a PostgreSQL image with all 18 Base-Lite databases already loaded. You need Docker Desktop to run it; see the tutorial guide.

Create docker-compose.yml in the project folder:

services:
  postgres:
    image: docker.io/shawnxxh/bird-interact-postgresql:latest
    container_name: livesqlbench_postgresql
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: "123123"
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Start it, and follow the log:

docker compose up -d
docker logs -f livesqlbench_postgresql

Wait until the log stops printing import messages, then press Ctrl+C. The container keeps running. You don't need the database until we build the agent, so you can move on to the next step while it loads.

Each benchmark database has _template added to its name, so ours is called disaster_template. Check that its tables are there:

docker exec -it livesqlbench_postgresql psql -U root -d disaster_template -c "\dt"

You should see the ten tables:

               List of relations
 Schema |            Name             | Type  | Owner
--------+-----------------------------+-------+-------
 public | beneficiariesandassessments | table | root
 public | coordinationandevaluation   | table | root
 public | disasterevents              | table | root
 public | distributionhubs            | table | root
 public | environmentandhealth        | table | root
 public | financials                  | table | root
 public | humanresources              | table | root
 public | operations                  | table | root
 public | supplies                    | table | root
 public | transportation              | table | root

The ten tables match the ten CREATE TABLE statements in disaster_schema.txt. The database holds the data, but nothing in it says how to turn hubutilpct into a Resource Utilization Ratio. That's what the bundle is for.

Port already in use? If PostgreSQL already runs on your laptop, change the port line to "5433:5432" and use port 5433 wherever this post connects to the database.

Read the raw files into Python

The compiler's first job is to turn three different file formats (a SQL text file, a JSON object and a JSON Lines file) into one set of Python objects. Every later step works with those objects and never touches the raw files again.

Three formats in, one set of Python objects out.

Create okf_compiler/readers.py:

"""Read the three raw LiveSQLBench files for one database into Python objects."""

import json
import re
from dataclasses import dataclass, field
from pathlib import Path


@dataclass
class Column:
    name: str
    sql_type: str
    nullable: bool
    meaning: str = ""
    json_fields: dict = field(default_factory=dict)  # only for JSONB columns


@dataclass
class ForeignKey:
    column: str
    ref_table: str
    ref_column: str


@dataclass
class Table:
    name: str
    columns: list[Column]
    primary_key: list[str]
    foreign_keys: list[ForeignKey]


@dataclass
class Rule:
    id: int
    name: str
    description: str
    definition: str
    kind: str
    depends_on: list[int]


COLUMN_LINE = re.compile(r"^(\w+)\s+(.+?)\s+(NOT NULL|NULL),?$")
PRIMARY_KEY = re.compile(r"PRIMARY KEY \(([^)]+)\)")
FOREIGN_KEY = re.compile(r"FOREIGN KEY \((\w+)\) REFERENCES (\w+)\((\w+)\)")


def read_schema(path: Path) -> list[Table]:
    tables = []
    for block in path.read_text(encoding="utf-8").split("CREATE TABLE ")[1:]:
        name = block.split('"')[1]
        body = block.split(");")[0]
        columns, primary_key, foreign_keys = [], [], []
        for line in body.splitlines()[1:]:
            line = line.strip()
            if m := COLUMN_LINE.match(line):
                columns.append(Column(m[1], m[2], m[3] == "NULL"))
            elif m := PRIMARY_KEY.search(line):
                primary_key = [c.strip() for c in m[1].split(",")]
            elif m := FOREIGN_KEY.search(line):
                foreign_keys.append(ForeignKey(m[1], m[2], m[3]))
        tables.append(Table(name, columns, primary_key, foreign_keys))
    return tables


def add_column_meanings(tables: list[Table], path: Path) -> list[str]:
    """Attach each column's meaning. Returns the columns that have none."""
    meanings = {}
    for key, value in json.loads(path.read_text(encoding="utf-8")).items():
        _, table, column = key.lower().split("|")
        meanings[(table, column)] = value

    missing = []
    for table in tables:
        for column in table.columns:
            value = meanings.get((table.name.lower(), column.name.lower()))
            if value is None:
                missing.append(f"{table.name}.{column.name}")
            elif isinstance(value, dict):  # JSONB column
                column.meaning = value.get("column_meaning", "")
                column.json_fields = value.get("fields_meaning", {})
            else:
                column.meaning = value
    return missing


def read_rules(path: Path) -> list[Rule]:
    rules = []
    for line in path.read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        raw = json.loads(line)
        children = raw["children_knowledge"]
        rules.append(Rule(
            id=raw["id"],
            name=raw["knowledge"],
            description=raw["description"],
            definition=raw["definition"],
            kind=raw["type"],
            depends_on=children if isinstance(children, list) else [],
        ))
    return rules

The four dataclasses are the shape every later module relies on: a Table with its Columns and ForeignKeys, and a Rule.

Notice four things the raw data forced on us:

  • The schema is split on CREATE TABLE, not on blank lines. Each table is followed by sample rows, and a table with no sample rows would otherwise run into the next one.
  • Names are matched in lowercase. The files mix cases: the column impactmetrics in the schema is impactMetrics in the descriptions, and some schemas have capitalized column names.
  • JSONB columns have a description for every nested field, not just one sentence. We keep those field descriptions in json_fields, because an agent can't query a JSON field it doesn't know exists.
  • children_knowledge is either a list or -1. We turn it into depends_on, which is always a list, so later code never has to check for -1.

add_column_meanings also returns the columns it couldn't find a description for, instead of failing quietly. It's our first report of what the source documentation is missing.

Check what was read

Create inspect_db.py in the project root, next to docker-compose.yml (not inside okf_compiler/). It reads one database and prints a summary:

import sys
from pathlib import Path

from okf_compiler.readers import add_column_meanings, read_rules, read_schema

name = sys.argv[1]
folder = Path("data/livesqlbench-base-lite") / name

tables = read_schema(folder / f"{name}_schema.txt")
missing = add_column_meanings(tables, folder / f"{name}_column_meaning_base.json")
rules = read_rules(folder / f"{name}_kb.jsonl")

print(f"tables:  {len(tables)}")
print(f"columns: {sum(len(t.columns) for t in tables)}")
print(f"columns without a meaning: {', '.join(missing) or 'none'}")
print(f"rules:   {len(rules)}")
print()
for table in tables:
    joins = ", ".join(fk.ref_table for fk in table.foreign_keys) or "-"
    print(f"{table.name:<28} {len(table.columns):>3} columns   joins: {joins}")

Run it for disaster:

python inspect_db.py disaster

The output:

tables:  10
columns: 123
columns without a meaning: none
rules:   54

beneficiariesandassessments   10 columns   joins: disasterevents, operations
disasterevents                 9 columns   joins: -
distributionhubs              11 columns   joins: disasterevents
environmentandhealth          13 columns   joins: disasterevents
operations                    12 columns   joins: disasterevents, distributionhubs
coordinationandevaluation     29 columns   joins: disasterevents, operations
humanresources                 4 columns   joins: disasterevents, operations
supplies                       4 columns   joins: disasterevents, distributionhubs
transportation                18 columns   joins: disasterevents, distributionhubs, supplies
financials                    13 columns   joins: disasterevents, operations

Ten tables, the same as the ten CREATE TABLE statements, and 54 rules, one per line of disaster_kb.jsonl. Every column has a description.

That isn't true for every database. Run it for mental:

python inspect_db.py mental

The output (the table list is cut):

tables:  9
columns: 105
columns without a meaning: patients.clinleadref
rules:   63
...

One column, patients.clinleadref, has no description in the source. The compiler can't invent one, so that column will reach the bundle with its name and type only. It's the first gap we've found in the source documentation, and the compiler should report gaps like this rather than hide them.

Your project now looks like this:

okf-sql-knowledge/
├── data/
│   └── livesqlbench-base-lite/
├── bundles/
├── okf_compiler/
│   ├── __init__.py
│   └── readers.py
├── docker-compose.yml
├── inspect_db.py
├── requirements.txt
└── README.md

Generate the table concepts

Each table becomes one concept file: the file an agent opens when it needs a table's columns, their meanings and its joins.

A helper for every concept file

Every concept file the compiler writes has the same shape: YAML frontmatter, then a markdown body. One small module writes that shape, so the table and rule modules don't repeat it.

Create okf_compiler/concept.py:

"""Write one concept file: YAML frontmatter, then a markdown body."""

from datetime import datetime
from pathlib import Path

import yaml

COMPILER = "okf_compiler/0.1"
SOURCE_BASE = "https://huggingface.co/datasets/birdsql/livesqlbench-base-lite/blob/main"


def generated() -> dict:
    now = datetime.now().astimezone().isoformat(timespec="seconds")
    return {"by": COMPILER, "at": now}


def source(database: str, filename: str, title: str) -> dict:
    return {
        "id": filename.split(".")[0].removeprefix(f"{database}_"),
        "resource": f"{SOURCE_BASE}/{database}/{filename}",
        "title": title,
    }


def write_concept(path: Path, frontmatter: dict, body: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    header = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True, width=1000)
    path.write_text(f"---\n{header}---\n\n{body.strip()}\n", encoding="utf-8")

Notice two things about the frontmatter it produces. (What each field means is covered in the introduction to OKF.)

  • generated.by is the compiler and its version, not a person. A reader of the bundle can tell these files were written by okf_compiler/0.1, and when.
  • There is no verified field. Nobody has reviewed these files, and the bundle says so. If someone checks a concept later, they add verified to that file.

The table module

Create okf_compiler/tables.py:

"""Build one concept file per table."""

from pathlib import Path

from okf_compiler.concept import generated, source, write_concept
from okf_compiler.readers import Table


def describe(table: Table) -> str:
    keys = set(table.primary_key) | {fk.column for fk in table.foreign_keys}
    names = [c.name for c in table.columns if c.name not in keys]
    text = f"{len(table.columns)} columns: {', '.join(names)}."
    joins = sorted({fk.ref_table for fk in table.foreign_keys})
    if joins:
        text += f" Joins to {', '.join(joins)}."
    return text


def cell(text: str) -> str:
    return text.replace("|", "\\|").replace("\n", " ")


def json_field_lines(prefix: str, fields: dict) -> list[str]:
    lines = []
    for name, value in fields.items():
        path = f"{prefix}.{name}"
        if isinstance(value, dict):
            lines += json_field_lines(path, value)
        else:
            lines.append(f"* `{path}`: {value}")
    return lines


def table_body(table: Table, related: list[tuple[str, str]]) -> str:
    parts = ["# Schema", "", "| Column | Type | Meaning |", "|---|---|---|"]
    for c in table.columns:
        sql_type = c.sql_type + (", primary key" if c.name in table.primary_key else "")
        parts.append(f"| `{c.name}` | {sql_type} | {cell(c.meaning)} |")

    json_lines = []
    for c in table.columns:
        json_lines += json_field_lines(c.name, c.json_fields)
    if json_lines:
        parts += ["", "# JSON fields", "", *json_lines]

    if table.foreign_keys:
        parts += ["", "# Joins", ""]
        for fk in table.foreign_keys:
            parts.append(
                f"* `{fk.column}` references `{fk.ref_column}` in "
                f"[{fk.ref_table}](/tables/{fk.ref_table}.md)."
            )

    if related:
        parts += ["", "# Related knowledge", ""]
        for title, slug in related:
            parts.append(f"* [{title}](/knowledge/{slug}.md)")
    return "\n".join(parts)


def write_table(table: Table, database: str, bundle: Path, related: list[tuple[str, str]]) -> None:
    frontmatter = {
        "type": "PostgreSQL Table",
        "title": table.name,
        "description": describe(table),
        "tags": [database],
        "generated": generated(),
        "sources": [
            source(database, f"{database}_schema.txt", f"{database} schema (LiveSQLBench)"),
            source(database, f"{database}_column_meaning_base.json",
                   f"{database} column descriptions (LiveSQLBench)"),
        ],
    }
    write_concept(bundle / "tables" / f"{table.name}.md", frontmatter, table_body(table, related))

The body keeps every column's meaning word for word, because that's where details like enum values live (warehousestate is Fair, Excellent, Good or Poor). JSONB columns get an extra section that lists every nested field as a path, such as impactmetrics.population.affected, ready to use in a query.

Design choice: table descriptions come from column names, not an LLM.
description is the one line an agent sees in the index before deciding whether to open the file. A person would write "one row per distribution hub, with its capacity and storage". A compiler can't write that sentence without an LLM, and an LLM would add wording that isn't in the source docs, which would give the bundle knowledge the other setups don't get. So describe lists the table's column names and the tables it joins to: plain, but taken straight from the source. If the agent struggles to pick the right tables, this is the first thing to improve.

write_table also takes a list of related business rules. It's empty for now; the next step fills it, so each table links to the rules that use its columns.

The command

Create okf_compiler/cli.py. For now it only writes the table concepts; we extend it in the next steps:

"""Compile one LiveSQLBench database into an OKF bundle."""

import shutil
import sys
from pathlib import Path

from okf_compiler.readers import add_column_meanings, read_rules, read_schema
from okf_compiler.tables import write_table

DATA = Path("data/livesqlbench-base-lite")
OUT = Path("bundles/compiled")


def compile_database(name: str) -> None:
    folder = DATA / name
    tables = read_schema(folder / f"{name}_schema.txt")
    missing = add_column_meanings(tables, folder / f"{name}_column_meaning_base.json")
    rules = read_rules(folder / f"{name}_kb.jsonl")

    bundle = OUT / name
    shutil.rmtree(bundle, ignore_errors=True)
    for table in tables:
        write_table(table, name, bundle, related=[])

    print(f"{name}: {len(tables)} table concepts written to {bundle}")
    if missing:
        print(f"  columns without a description: {', '.join(missing)}")


if __name__ == "__main__":
    compile_database(sys.argv[1])

The compiler writes to bundles/compiled/<database>, so it never overwrites a bundle you wrote by hand, and it clears that folder first, so the bundle always matches the current source files.

Run it:

python -m okf_compiler.cli disaster

The output:

disaster: 10 table concepts written to bundles\compiled\disaster

Open bundles/compiled/disaster/tables/distributionhubs.md. It should look like this (most columns cut):

---
type: PostgreSQL Table
title: distributionhubs
description: '11 columns: hubcaptons, hubutilpct, storecapm3, storeavailm3, coldstorecapm3, coldstoretempc, warehousestate, invaccpct, stockturnrate. Joins to disasterevents.'
tags:
- disaster
generated:
  by: okf_compiler/0.1
  at: '2026-10-03T00:03:25+05:30'
sources:
- id: schema
  resource: https://huggingface.co/datasets/birdsql/livesqlbench-base-lite/blob/main/disaster/disaster_schema.txt
  title: disaster schema (LiveSQLBench)
- id: column_meaning_base
  resource: https://huggingface.co/datasets/birdsql/livesqlbench-base-lite/blob/main/disaster/disaster_column_meaning_base.json
  title: disaster column descriptions (LiveSQLBench)
---

# Schema

| Column | Type | Meaning |
|---|---|---|
| `hubregistry` | character varying, primary key | A VARCHAR(20) primary key identifying each distribution hub record (e.g., 'HUB0001'). |
| `disteventref` | character varying | A VARCHAR(20) referencing DisasterEvents(DistRegistry), linking this hub to a specific disaster event (e.g., 'DIST0001'). |
... 9 more columns

# Joins

* `disteventref` references `distregistry` in [disasterevents](/tables/disasterevents.md).

A generated table concept, as an agent or a person would read it.

Your project now looks like this:

okf-sql-knowledge/
├── data/
│   └── livesqlbench-base-lite/
├── bundles/
│   └── compiled/
│       └── disaster/
│           └── tables/          10 concept files
├── okf_compiler/
│   ├── __init__.py
│   ├── cli.py
│   ├── concept.py
│   ├── readers.py
│   └── tables.py
├── docker-compose.yml
├── inspect_db.py
├── requirements.txt
└── README.md

Generate the rule concepts and their links

The business rules are what the schema can't tell an agent: what "Resource Utilization Ratio" means, or which conditions make an operation "high-risk". Each rule becomes one concept file, linked to the rules it depends on and to the tables whose columns it uses.

Create okf_compiler/rules.py:

"""Build one concept file per business rule, and link rules to tables."""

import re
from pathlib import Path

from okf_compiler.concept import generated, source, write_concept
from okf_compiler.readers import Rule, Table

TYPE_NAMES = {
    "calculation_knowledge": "Calculation",
    "domain_knowledge": "Business Rule",
    "value_illustration": "Value Illustration",
}
WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")


def make_slugs(rules: list[Rule]) -> dict[int, str]:
    """A file name for every rule, e.g. 'resource-utilization-ratio'."""
    slugs, used = {}, set()
    for rule in rules:
        name = re.sub(r"\([^)]*\)", "", rule.name)
        slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
        if slug in used:
            slug = f"{slug}-{rule.id}"
        used.add(slug)
        slugs[rule.id] = slug
    return slugs


def find_tables(rule: Rule, tables: list[Table]) -> dict[str, list[str]]:
    """Tables whose column names appear in the rule's name or definition."""
    words = {w.lower() for w in WORD.findall(f"{rule.name} {rule.definition}")}
    found = {}
    for table in tables:
        columns = [c.name for c in table.columns if c.name.lower() in words]
        if columns:
            found[table.name] = columns
    return found


def rule_body(rule: Rule, rules_by_id: dict[int, Rule], used_by: list[int],
              slugs: dict[int, str], tables: dict[str, list[str]]) -> str:
    parts = ["# Definition", "", rule.definition]

    if tables:
        parts += ["", "# Columns used", ""]
        for table, columns in tables.items():
            names = ", ".join(f"`{c}`" for c in columns)
            parts.append(f"* [{table}](/tables/{table}.md): {names}")

    for heading, ids in (("Depends on", rule.depends_on), ("Used by", used_by)):
        links = [f"* [{rules_by_id[i].name}](/knowledge/{slugs[i]}.md)"
                 for i in ids if i in rules_by_id]
        if links:
            parts += ["", f"# {heading}", "", *links]
    return "\n".join(parts)


def write_rules(rules: list[Rule], tables: list[Table], database: str,
                bundle: Path) -> dict[str, list[tuple[str, str]]]:
    """Write every rule concept. Returns, per table, the rules that use it."""
    slugs = make_slugs(rules)
    rules_by_id = {r.id: r for r in rules}
    used_by = {r.id: [] for r in rules}
    for rule in rules:
        for parent in rule.depends_on:
            if parent in used_by:
                used_by[parent].append(rule.id)

    related = {t.name: [] for t in tables}
    for rule in rules:
        found = find_tables(rule, tables)
        for table in found:
            related[table].append((rule.name, slugs[rule.id]))

        frontmatter = {
            "type": TYPE_NAMES.get(rule.kind, rule.kind),
            "title": rule.name,
            "description": rule.description,
            "tags": [database],
            "generated": generated(),
            "sources": [source(database, f"{database}_kb.jsonl",
                               f"{database} business rules (LiveSQLBench), rule {rule.id}")],
        }
        body = rule_body(rule, rules_by_id, used_by[rule.id], slugs, found)
        write_concept(bundle / "knowledge" / f"{slugs[rule.id]}.md", frontmatter, body)
    return related

Notice three things:

  • Each rule type gets a readable OKF type. calculation_knowledge becomes Calculation, domain_knowledge becomes Business Rule, and value_illustration becomes Value Illustration.
  • Dependencies become links in both directions. depends_on comes straight from the source. Used by is the reverse, built by the compiler, so an agent that lands on either rule can reach the other.
  • The definition is copied as it is, LaTeX included. Converting formulas to plain text would risk changing a formula, and LLMs read LaTeX well.

find_tables is the only guess the compiler makes. The source never says which table a rule belongs to. So we look for column names inside the rule's name and definition: rule 10 mentions hubutilpct, storecapm3 and storeavailm3, and all three are columns of distributionhubs. write_rules returns those matches per table, so each table concept can list the rules that use it.

One more detail: two rules can have the same name once the brackets are removed. make_slugs adds the rule's id to the second one, so no file overwrites another.

Update the command

Replace okf_compiler/cli.py:

"""Compile one LiveSQLBench database into an OKF bundle."""

import shutil
import sys
from pathlib import Path

from okf_compiler.readers import add_column_meanings, read_rules, read_schema
from okf_compiler.rules import find_tables, write_rules
from okf_compiler.tables import write_table

DATA = Path("data/livesqlbench-base-lite")
OUT = Path("bundles/compiled")


def compile_database(name: str) -> None:
    folder = DATA / name
    tables = read_schema(folder / f"{name}_schema.txt")
    missing = add_column_meanings(tables, folder / f"{name}_column_meaning_base.json")
    rules = read_rules(folder / f"{name}_kb.jsonl")

    bundle = OUT / name
    shutil.rmtree(bundle, ignore_errors=True)
    related = write_rules(rules, tables, name, bundle)
    for table in tables:
        write_table(table, name, bundle, related[table.name])

    unlinked = [r for r in rules if not find_tables(r, tables)]
    print(f"{name}: {len(tables)} tables, {len(rules)} rules written to {bundle}")
    if missing:
        print(f"  columns without a description: {', '.join(missing)}")
    if unlinked:
        print(f"  rules not linked to any table ({len(unlinked)}):")
        for rule in unlinked:
            print(f"    {rule.id}: {rule.name}")


if __name__ == "__main__":
    compile_database(sys.argv[1])

It now writes the rules first, because the table concepts need to know which rules link to them. It also prints a report of every rule it couldn't link to a table.

Run it:

python -m okf_compiler.cli disaster

The output:

disaster: 10 tables, 54 rules written to bundles\compiled\disaster
  rules not linked to any table (4):
    42: Sustainable Operation Excellence
    50: Resource Utilization Classification
    51: Environmental Impact Classification
    52: Community Resilience Classification

Four of the 54 rules are not linked to a table. Look at their names: three are classifications, such as rule 50, Resource Utilization Classification. Rule 50 never mentions a column. It says "RUR > 5", and RUR is rule 10. So it isn't lost: an agent reaches its table in two hops, through the Depends on link to rule 10, then rule 10's link to distributionhubs.

Rule 50 names no column, but an agent still reaches its table in two hops.

That's why the compiler reports these rules instead of guessing. A missing link costs the agent an extra hop. A wrong link sends it to the wrong table.

How many rules end up unlinked depends on how the source docs are written. Run the compiler on news:

python -m okf_compiler.cli news

The output (most of the list is cut):

news: 8 tables, 61 rules written to bundles\compiled\news
  rules not linked to any table (48):
    6: statusWeight
    7: genderFactor
    8: occupationFactor
    10: Premium Content Rule (PCR)
    11: Personalization Priority (PP)
    12: AB Testing Cohort Analysis (ABTCA)
    ... 42 more

In news, most rules are written in business language and never name a column. Rule 6, statusWeight, says 'Premium' = 2.0, 'Enterprise' = 3.0, but not which column holds the subscription tier. Others, such as rule 31, are built only from other rules' abbreviations. Name matching can't link these, so for those rules the agent has to find the right column itself, exactly as it would with the raw docs. The report tells you where your own documentation would need a human to add the missing link.

Open bundles/compiled/disaster/knowledge/resource-utilization-ratio.md. It should look like this:

---
type: Calculation
title: Resource Utilization Ratio (RUR)
description: Measures how effectively hub capacity is being used relative to available resources
tags:
- disaster
generated:
  by: okf_compiler/0.1
  at: '2026-10-03T00:11:39+05:30'
sources:
- id: kb
  resource: https://huggingface.co/datasets/birdsql/livesqlbench-base-lite/blob/main/disaster/disaster_kb.jsonl
  title: disaster business rules (LiveSQLBench), rule 10
---

# Definition

RUR = \frac{hubutilpct}{100} \times \frac{storecapm3}{storeavailm3 + 1}

# Columns used

* [distributionhubs](/tables/distributionhubs.md): `hubutilpct`, `storecapm3`, `storeavailm3`

# Used by

* [Financial Vulnerability Zone](/knowledge/financial-vulnerability-zone.md)
* [Resource Utilization Classification](/knowledge/resource-utilization-classification.md)

Generate the index files and validate

The bundle now has 64 concept files. An agent can't open all of them for every question, so it needs a menu first: an index.md in each folder that lists every concept with its one-line description. The agent reads the menu, then opens only what it needs.

Read the menu first, then open only the files the question needs.

Every line in an index comes from a concept file's frontmatter: its title, its file name and its description. So the index module doesn't need the raw data at all. It reads the concept files the compiler just wrote. That also means it works on any bundle folder, including one you edit by hand.

Create okf_compiler/indexes.py:

"""Write the index files and the log for a bundle."""

from datetime import date
from pathlib import Path

import yaml

from okf_compiler.concept import COMPILER

FOLDERS = {
    "tables": "PostgreSQL tables, with column meanings, JSON fields and joins.",
    "knowledge": "Business rules: calculations, definitions and value illustrations.",
}


def read_frontmatter(path: Path) -> dict:
    text = path.read_text(encoding="utf-8")
    return yaml.safe_load(text.split("---", 2)[1])


def write_folder_index(folder: Path) -> int:
    groups: dict[str, list[str]] = {}
    for path in sorted(folder.glob("*.md")):
        if path.name == "index.md":
            continue
        meta = read_frontmatter(path)
        line = f"* [{meta['title']}]({path.name}) - {meta['description']}"
        groups.setdefault(meta["type"], []).append(line)

    parts = []
    for concept_type, lines in sorted(groups.items()):
        parts += [f"# {concept_type}", "", *lines, ""]
    (folder / "index.md").write_text("\n".join(parts), encoding="utf-8")
    return sum(len(lines) for lines in groups.values())


def write_indexes(bundle: Path, database: str) -> None:
    counts = {name: write_folder_index(bundle / name) for name in FOLDERS}

    root = ["---", 'okf_version: "0.2"', "---", "", f"# {database} database", ""]
    for name, text in FOLDERS.items():
        root.append(f"* [{name.capitalize()}]({name}/index.md) - {text}")
    (bundle / "index.md").write_text("\n".join(root) + "\n", encoding="utf-8")

    log = [
        "# Update Log",
        "",
        f"## {date.today().isoformat()}",
        f"* **Creation**: Compiled {counts['tables']} tables and {counts['knowledge']} "
        f"business rules from LiveSQLBench `{database}` with `{COMPILER}`.",
    ]
    (bundle / "log.md").write_text("\n".join(log) + "\n", encoding="utf-8")

Notice three things:

  • Each folder index is grouped by type. In knowledge/index.md, calculations, business rules and value illustrations each get their own heading, so an agent looking for a formula can skip the definitions.
  • Only the root index has frontmatter, and only okf_version. OKF allows no other frontmatter in index files.
  • The log has a single entry. The compiler rebuilds the bundle from scratch on every run, so the log records one thing: which compiler version built it from which source, and when.

The final command

Replace okf_compiler/cli.py one last time. It now writes the indexes, and it accepts all to compile every database:

"""Compile one LiveSQLBench database into an OKF bundle."""

import shutil
import sys
from pathlib import Path

from okf_compiler.indexes import write_indexes
from okf_compiler.readers import add_column_meanings, read_rules, read_schema
from okf_compiler.rules import find_tables, write_rules
from okf_compiler.tables import write_table

DATA = Path("data/livesqlbench-base-lite")
OUT = Path("bundles/compiled")


def compile_database(name: str) -> None:
    folder = DATA / name
    tables = read_schema(folder / f"{name}_schema.txt")
    missing = add_column_meanings(tables, folder / f"{name}_column_meaning_base.json")
    rules = read_rules(folder / f"{name}_kb.jsonl")

    bundle = OUT / name
    shutil.rmtree(bundle, ignore_errors=True)
    related = write_rules(rules, tables, name, bundle)
    for table in tables:
        write_table(table, name, bundle, related[table.name])
    write_indexes(bundle, name)

    unlinked = [r for r in rules if not find_tables(r, tables)]
    print(f"{name}: {len(tables)} tables, {len(rules)} rules written to {bundle}")
    if missing:
        print(f"  columns without a description: {', '.join(missing)}")
    if unlinked:
        print(f"  rules not linked to any table ({len(unlinked)}):")
        for rule in unlinked:
            print(f"    {rule.id}: {rule.name}")


if __name__ == "__main__":
    if sys.argv[1] == "all":
        for folder in sorted(DATA.iterdir()):
            if (folder / f"{folder.name}_schema.txt").exists():
                compile_database(folder.name)
    else:
        compile_database(sys.argv[1])

Compile disaster again:

python -m okf_compiler.cli disaster

The output is the same report as before. The difference is in the bundle: it now has an index in each folder. Open bundles/compiled/disaster/knowledge/index.md. It should look like this (each group cut short):

# Business Rule

* [Community Resilience Builder](community-resilience-builder.md) - Identifies operations that strengthen local community capacity
...
* [Vulnerable Population Hotspot](vulnerable-population-hotspot.md) - Identifies areas with highly vulnerable populations requiring priority attention

# Calculation

* [available storage percentage](available-storage-percentage.md) - Calculates what proportion of total storage capacity is currently available
...
* [Supply Chain Sustainability Index (SCSI)](supply-chain-sustainability-index.md) - Assesses the environmental sustainability of the disaster supply chain

# Value Illustration

* [coordeffectlvl](coordeffectlvl.md) - Illustrates the quality of coordination between responding agencies
...
* [staffingProfile.readiness.ppe_status](staffingprofile-readiness-ppe-status.md) - Illustrates the availability of Personal Protective Equipment

Validate

Now check the bundle against the OKF rules with the validator from okf-skills, pinned to commit 8e31878 (download steps in the tutorial guide):

python tools/okf_validate.py bundles/compiled/disaster

The output:

OKF v0.2 conformance — bundles\compiled\disaster
  concepts: 64   index.md: 3   log.md: 1
  ✓ conformant — no issues

All 64 concepts, the 3 index files and the log are conformant, with no warnings. Every link points to a file that exists, because the compiler only writes links to tables and rules it has just generated.

All 18 databases

The same command works for every database in Base-Lite:

python -m okf_compiler.cli all

It prints one report per database (most of it cut):

alien: 11 tables, 56 rules written to bundles\compiled\alien
  rules not linked to any table (10):
    40: High-Confidence Technosignature
    41: Habitable Zone Transmission
    ... 8 more
... 16 more databases
virtual: 14 tables, 55 rules written to bundles\compiled\virtual
  rules not linked to any table (15):
    12: Content Creation Impact Score (CCIS)
    ... 14 more

Then validate them all. On macOS or Linux:

for db in bundles/compiled/*/; do python tools/okf_validate.py "$db" | tail -1; done

On Windows (PowerShell):

Get-ChildItem bundles/compiled -Directory | ForEach-Object { python tools/okf_validate.py $_.FullName | Select-Object -Last 1 }

Each bundle prints its last line, so you should see 18 of these:

  ✓ conformant — no issues
  ✓ conformant — no issues
  ✓ conformant — no issues
  ...

Seeing Γ£ô instead of ✓ in PowerShell? That's only the console's text encoding, not a problem with the bundles. Run [Console]::OutputEncoding = [Text.Encoding]::UTF8 once, then run the loop again.

Eighteen databases, eighteen conformant bundles.

What we built, and what we learned

We now have a compiler that turns a database's raw documentation into an OKF bundle with one command:

  • One concept file per table, with every column's meaning, its JSON fields and its joins.
  • One concept file per business rule, linked to the rules it depends on, the rules that use it, and the tables whose columns it names.
  • An index in every folder, so an agent can read a short menu before opening anything.
  • A report of what's missing: columns without a description, and rules the compiler couldn't link to a table.

Three lessons from running it on real documentation:

  1. A compiler should report gaps, not fill them. It can't invent a description for patients.clinleadref, and it shouldn't guess which table a rule belongs to. A missing link costs the agent one extra hop; a wrong link sends it to the wrong table.
  2. How well the links come out depends on how the docs are written. In disaster, 50 of 54 rules name their columns, so they link on their own. In news, 48 of 61 don't. The compiler can only link what the source spells out, and its report shows exactly where a human would need to add the missing link.
  3. Keep generated knowledge honest. Every file says which compiler wrote it, from which source file and when, and none of them claims to be verified. Anyone reading the bundle can tell it was generated, not reviewed.

What's next

The bundle is ready, but nothing reads it yet. In the next part, we build the SQL agent on an open model, point it at the disaster bundle, and ask it questions twice: once browsing the bundle, and once with all the raw documentation pasted into its prompt. Then we compare what it read, how many tokens it used, and whether its SQL was right.

The full code for this part is in the okf-sql-knowledge repository.

Sources

来源:Google AI:DEV 作者专属(RSS) · dev.to