chartcoach

Python

Load catalog bundles, inspect Polars tables, query with DuckDB, and create LanceDB search indexes.

Use chartcoach.open() to inspect Default Catalog tables and run indexed search from Python.

Run one check without adding the package to a project:

uv run --with chartcoach python - <<'PY'
import chartcoach

cc = chartcoach.open()
print(cc.catalog.guidelines().select("id", "title").head(3))
PY

Add the package from an application root:

uv add chartcoach

uv add 'chartcoach[index]'

uv add 'chartcoach[mcp,index]'
import chartcoach

cc = chartcoach.open()
cc.catalog.guidelines().select("id", "title").head(5)

chartcoach.open() uses the package-pinned Default Catalog when no path is provided. Accessing .catalog loads the catalog and returns derived Polars tables from methods such as guidelines() and sections(). Indexed search resolves a LanceDB table through .index.

The first .catalog access, cc.search(), or CLI catalog read downloads package-pinned artifacts. Indexed search downloads an index archive. Set CHARTCOACH_CACHE_DIR to isolate writes.

Loading

Use the facade when code needs catalog tables and indexed search:

import chartcoach

cc = chartcoach.open("dist/catalog")
catalog = cc.catalog

chartcoach.open(source) accepts authored folders, bundle directories, standalone parquet files, release root URLs, and release metadata URLs. Bundle reads load MANIFEST.md and entries.parquet.

Use Catalog.open() when code only needs the catalog object:

from chartcoach import Catalog

catalog = Catalog.open("dist/catalog")
rows = catalog.guidelines().select("id", "title")

Facade sources and search indexes

CallCatalog behaviorIndexed search behavior
chartcoach.open()Uses the package-pinned Default Catalogcc.search() can resolve the package-pinned index
chartcoach.open("dist/catalog")Loads a custom catalog sourcePass index= or table= before indexed search
chartcoach.open(catalog=catalog)Uses a caller-owned catalogPass index= or table= before indexed search
chartcoach.open(catalog=catalog, table=table)Uses caller-owned catalog and LanceDB tablecc.search() uses the injected table

Pass either source or catalog, not both.

Tables

Catalog returns derived Polars tables from guidelines(), sections(), labels(), guideline_labels(), references(), guideline_references(), and guideline_sources().

Use catalog.table(name) when a caller receives a table name from the CLI, MCP, or docs. The Cataloging scheme lists table names and columns.

Entries

entry = catalog.entry("compare-percentages-with-bars-not-pies")
print(entry["id"])
print(entry["title"])
print(entry["sections"][0]["role"])
print(entry["references"])

entry(id) returns the catalog record to inspect before using a candidate in feedback. Use its source references to explain where the recommendation came from. Unknown ids raise KeyError.

Use citation_records(...) when an agent needs source references:

from chartcoach.catalog.references import citation_records

citations = citation_records(
    catalog,
    ids=["compare-percentages-with-bars-not-pies"],
)
print(citations[0]["id"])
print(citations[0]["url"])
print(citations[0]["sources"][0]["reference_id"])

Each returned citation object contains id, title, url, guideline_citation, and sources. Each source includes reference_id, source_title, doi, url, and a formatted citation.

Catalog API

Use Catalog directly when code owns guideline entries, tables, or local catalog files.

APIBehavior
Catalog.open(path=None)Loads the Default Catalog, remote metadata, local authored folder, bundle, or parquet file
Catalog.from_entries(entries, manifest=None)Builds an in-memory catalog from CatalogEntry, Guideline, or mapping objects
Catalog.from_frame(df, manifest=None)Builds a catalog from a serialized Polars dataframe
Catalog.from_folder(path)Reads an authored catalog folder
Catalog.from_parquet(path)Reads a standalone entries.parquet file
catalog.select(ids)Returns a catalog with the requested guideline ids in order and raises KeyError for missing ids
catalog.merge(other)Returns a catalog that combines both catalog tables and raises when manifests differ
catalog.manifestReturns the loaded CatalogManifest, or None
catalog.require_manifest()Returns the manifest or raises when the catalog has none
catalog.digest()Returns a stable digest of the serialized catalog table
CatalogManifest.from_text(markdown)Parses manifest Markdown
CatalogManifest.from_path(path)Reads and parses MANIFEST.md
parse_label(value)Splits a catalog label into family, category, and modifier fields
from chartcoach import Catalog, CatalogManifest, Guideline, Section, parse_label

catalog = Catalog.from_entries(
    [
        Guideline(
            id="direct-labels",
            title="Use direct labels",
            description="Place labels near the marks they identify.",
            sections=[
                Section(
                    role="advice",
                    title="Advice",
                    content="Label marks directly when space permits.",
                )
            ],
        )
    ],
    manifest=CatalogManifest.from_text("""
## Section Roles

### advice

Actionable guidance for applying the guideline.

## Label Families

### chart

Chart-family labels such as `chart:bar`.
"""),
)

subset = catalog.select(["direct-labels"])
label = parse_label("chart:bar:use")
print(subset.digest())
print(label.family, label.category, label.modifier)

Guideline requires either body or sections and derives one from the other when needed. parse_label() accepts <family>:<category> and <family>:<category>:<modifier> labels. Pass manifest=CatalogManifest.from_path("MANIFEST.md") when the catalog should validate section roles and label families.

DuckDB

Use DuckDB for joins across guideline tables.

conn = catalog.duckdb()
try:
    rows = conn.sql("""
        select g.id, g.title, s.role, s.content
        from guidelines g
        join sections s on s.guideline_id = g.id
        where s.role = 'advice'
        limit 5
    """).pl()
finally:
    conn.close()

The result has id, title, role, and content columns. DuckDB is installed with the base Python package.

Write a durable database file for another tool:

catalog.write_duckdb("./chartcoach-catalog.duckdb", overwrite=True)

Add the index extra before using search in application code.

import chartcoach

cc = chartcoach.open()
hits = cc.search("overplotted scatter plots").to_dict()["rows"]
print(hits[0]["id"])
print(hits[0]["matched_role"])
print(hits[0]["matched_text"])

cc.search() downloads and extracts the package-pinned index on first use. Later calls reuse the local copy. This default index auto-resolution applies only to the Default Catalog.

cc.search(text, *, limit=10, candidate_limit=None, where=None, mode="auto") returns a Result. Result.to_dict() contains query, rows, row_count, limit, candidate_limit, and where.

Each search hit contains rank, id, title, description, labels, matched_document_id, matched_role, score, and matched_text. Use hits as candidate guidelines, then call cc.catalog.entry(hit["id"]) and citation_records(...) before producing a sourced answer.

Pass index= when application code owns an index path:

cc = chartcoach.open("dist/catalog", index="./chartcoach-index")
hits = cc.search("direct labels", mode="fts")

Use cc.index.query() for document-level matches before guideline deduplication:

documents = cc.index.query(
    "direct labels",
    limit=5,
    where="role = 'overview'",
    mode="fts",
)
print(documents[0]["parent_id"])
print(documents[0]["text"])

Document rows contain id, parent_id, role, labels, content_hash, and text, plus LanceDB score fields when returned. Use cc.index.table when application code needs the native LanceDB table.

Use chartcoach.open(catalog=catalog, table=table) when application code already owns a catalog and native LanceDB table.

Import index when the application needs to build a caller-owned table:

from chartcoach.search import index

table = index(catalog, "./chartcoach-index")

Without an embedding function, index() creates a full-text table over the text column.

Write formats

catalog.write_folder("dist/authored")
catalog.write_parquet("dist/entries.parquet")
catalog.write_bundle("dist/catalog", overwrite=True)

write_bundle() writes MANIFEST.md, entries.parquet, and metadata.json. It requires a catalog manifest.

On this page