Skip to content

Commit 88fb935

Browse files
rustyconoverclaude
andcommitted
feat: bring docgen to vgi-lint-check 0.26.0 strict profile (100/100)
Add per-object, schema, and catalog discovery/description metadata for the 0.26.0 strict-by-default profile and rename the deprecated tag keys: - vgi.description_llm -> vgi.doc_llm, vgi.description_md -> vgi.doc_md (meta.object_tags helper + catalog/schema tags in worker.py) - vgi.columns_md -> vgi.result_columns_md (docgen_merge table function) Per-object tags (title/doc_llm/doc_md/keywords/source_url), bundled-template executable examples (VGI509), schema classifying bare keys (domain/category/ topic), example_queries (VGI506), and the merge result_columns_md table were already present from the prior round; this completes the 0.26.0 key rename. The linter executes every example by default and they run cleanly against the attached worker (render/merge use the bundled sample template by absolute path). Lint: 100/100, 0 findings. ruff/mypy/pydoclint/pytest all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 773fde2 commit 88fb935

5 files changed

Lines changed: 322 additions & 16 deletions

File tree

35.8 KB
Binary file not shown.

vgi_docgen/meta.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Shared helpers for the per-object discovery/description metadata.
2+
3+
The ``vgi-lint`` strict profile expects these on **every** function and table.
4+
Each function/table surfaces them in its ``Meta.tags``:
5+
6+
- ``vgi.title`` (VGI124) -- human-friendly display name (must NOT
7+
normalize-equal the machine name, or VGI125 fires).
8+
- ``vgi.doc_llm`` (VGI112) -- a Markdown narrative aimed at LLM/agents.
9+
- ``vgi.doc_md`` (VGI113) -- a Markdown narrative for human docs.
10+
- ``vgi.keywords`` (VGI126) -- comma-separated search terms/synonyms.
11+
- ``vgi.source_url`` (VGI128) -- link to the implementing source file.
12+
13+
:func:`source_url` builds the canonical GitHub blob URL for a source file so
14+
every object points at exactly where it is implemented; :func:`object_tags`
15+
assembles the five standard per-object tags.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
# Base GitHub blob URL for source files in this repo (pinned to ``main``).
21+
_SOURCE_BASE = "https://github.com/Query-farm/vgi-docgen/blob/main"
22+
23+
24+
def source_url(relative_path: str) -> str:
25+
"""Build the implementation ``vgi.source_url`` for a repo-relative file.
26+
27+
Args:
28+
relative_path: Path relative to the repository root, e.g.
29+
``vgi_docgen/scalars.py``.
30+
31+
Returns:
32+
The canonical GitHub blob URL for that file on ``main``.
33+
"""
34+
return f"{_SOURCE_BASE}/{relative_path}"
35+
36+
37+
def object_tags(
38+
title: str,
39+
description_llm: str,
40+
description_md: str,
41+
keywords: str,
42+
relative_path: str,
43+
) -> dict[str, str]:
44+
"""Build the five standard per-object discovery/description tags.
45+
46+
Args:
47+
title: Human-friendly display name (VGI124).
48+
description_llm: Markdown narrative for LLM/agent audiences (VGI112).
49+
description_md: Markdown narrative for human docs (VGI113).
50+
keywords: Comma-separated search terms/synonyms (VGI126).
51+
relative_path: Implementing source file, relative to the repo root,
52+
turned into the ``vgi.source_url`` (VGI128).
53+
54+
Returns:
55+
A ``dict`` of the five ``vgi.*`` tags, ready to spread into a
56+
function's ``Meta.tags``.
57+
"""
58+
return {
59+
"vgi.title": title,
60+
"vgi.doc_llm": description_llm,
61+
"vgi.doc_md": description_md,
62+
"vgi.keywords": keywords,
63+
"vgi.source_url": source_url(relative_path),
64+
}

vgi_docgen/scalars.py

Lines changed: 121 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
from __future__ import annotations
3737

38+
import json
3839
from typing import Annotated, Any
3940

4041
import pyarrow as pa
@@ -44,8 +45,13 @@
4445

4546
from . import core
4647
from .core import DocgenError, TemplateRef
48+
from .meta import object_tags
4749
from .schema_utils import to_context
4850

51+
# Absolute path to the bundled sample DOCX template (shared with tables.py), so
52+
# examples render a real document regardless of the worker's working directory.
53+
from .tables import SAMPLE_TEMPLATE_PATH
54+
4955

5056
def _render_one(ref: TemplateRef | None, ctx_value: Any, want_pdf: bool) -> bytes | None:
5157
"""Render a single row to DOCX (or PDF) bytes, or ``None`` on NULL/failure."""
@@ -88,6 +94,105 @@ def _render_array(
8894

8995
_RENDER_CATEGORIES = ["docgen", "template", "blob"]
9096

97+
# Shared per-object discovery/description tags for the docgen_render overloads.
98+
# All four overloads name themselves ``docgen_render`` and describe the SAME
99+
# logical function, so they carry identical title/description/keywords/source.
100+
_RENDER_TITLE = "Render Document from Template"
101+
102+
_RENDER_DESCRIPTION_LLM = (
103+
"Mail-merge **one DOCX (Microsoft Word) document per input row** from a "
104+
"template, returning each rendered file as a `BLOB`.\n\n"
105+
"## What it does\n\n"
106+
"The first argument is the template, accepted as **either** a `VARCHAR` "
107+
"path (resolved directly, then under `$VGI_DOCGEN_TEMPLATES`) **or** a "
108+
"`BLOB` of raw `.docx` bytes. The second argument is an arbitrary `STRUCT` "
109+
"of row data: every field becomes a Jinja2 variable in the template "
110+
"(`{{ field }}`), and nested lists/structs drive `{% for %}` loops and "
111+
"tables. An optional third boolean (`pdf`) converts the result to PDF via a "
112+
"headless LibreOffice when one is on PATH.\n\n"
113+
"## When to use it\n\n"
114+
"Use this scalar to produce a separate filled document for each row -- one "
115+
"invoice per customer, one letter per recipient, one statement per account. "
116+
"When you instead want a *single* document concatenating every row, use the "
117+
"`docgen_merge` table function.\n\n"
118+
"## Inputs and output\n\n"
119+
"- `template` -- `VARCHAR` path or `BLOB` `.docx` bytes.\n"
120+
"- `data` -- `STRUCT`; fields become template variables.\n"
121+
"- `pdf` (optional) -- `BOOLEAN`; `true` requests PDF output.\n"
122+
"- Returns a `BLOB`: the rendered `.docx` (or PDF).\n\n"
123+
"## Edge cases\n\n"
124+
"Hostile or missing input degrades to `NULL`, never a crash: a `NULL` "
125+
"template or `NULL` data, a non-DOCX blob, a missing template file, a "
126+
"malformed template, a Jinja render error, and a requested-but-unavailable "
127+
"PDF conversion all yield `NULL`."
128+
)
129+
130+
_RENDER_DESCRIPTION_MD = (
131+
"# Render Document from Template\n\n"
132+
"Mail-merge **one DOCX document per row** from a template, returning each "
133+
"rendered file as a `BLOB`.\n\n"
134+
"## Usage\n\n"
135+
"```sql\n"
136+
"-- One rendered document per row, from a template file\n"
137+
"SELECT docgen.docgen_render('invoice.docx', {customer: name, total: amt})\n"
138+
"FROM invoices;\n\n"
139+
"-- From inline template BLOB bytes\n"
140+
"SELECT docgen.docgen_render(tpl_bytes, {customer: name}) FROM letters;\n\n"
141+
"-- PDF output (requires headless LibreOffice on PATH)\n"
142+
"SELECT docgen.docgen_render('invoice.docx', {total: amt}, true) FROM invoices;\n"
143+
"```\n\n"
144+
"## Notes\n\n"
145+
"- The template is a `VARCHAR` path (resolved under "
146+
"`$VGI_DOCGEN_TEMPLATES`) or inline `.docx` `BLOB` bytes.\n"
147+
"- Every field of the `STRUCT` data argument becomes a Jinja2 variable "
148+
"(`{{ field }}`); nested lists drive `{% for %}` loops.\n"
149+
"- Missing/hostile input (NULL, non-DOCX blob, bad placeholder, missing "
150+
"template, unavailable LibreOffice) degrades to `NULL` rather than crashing."
151+
)
152+
153+
_RENDER_KEYWORDS = (
154+
"docgen, render, mail merge, document generation, docx, word, template, "
155+
"jinja2, docxtpl, fill template, invoice, letter, statement, contract, "
156+
"blob, pdf, libreoffice, placeholder"
157+
)
158+
159+
_RENDER_TAGS = object_tags(
160+
_RENDER_TITLE,
161+
_RENDER_DESCRIPTION_LLM,
162+
_RENDER_DESCRIPTION_MD,
163+
_RENDER_KEYWORDS,
164+
"vgi_docgen/scalars.py",
165+
)
166+
167+
# VGI509 guaranteed-runnable, catalog-qualified examples. Each is self-contained
168+
# and re-runnable against an attached ``docgen`` worker WITHOUT any external
169+
# table: the first renders a real document from the worker's BUNDLED sample
170+
# template (absolute path), and the rest exercise the documented NULL-vs-crash
171+
# discipline. ``expected_result`` is omitted deliberately.
172+
_RENDER_EXECUTABLE_EXAMPLES = json.dumps(
173+
[
174+
{
175+
"description": (
176+
"Render a document from the bundled sample template filled with a "
177+
"STRUCT of fields and confirm a non-empty DOCX BLOB is produced."
178+
),
179+
"sql": (
180+
"SELECT octet_length(docgen.docgen_render("
181+
f"'{SAMPLE_TEMPLATE_PATH}', "
182+
"{customer: 'Ada Lovelace', total: '99.50'})) > 0 AS ok"
183+
),
184+
},
185+
{
186+
"description": "A NULL template passes straight through to a NULL document (NULL-in, NULL-out).",
187+
"sql": "SELECT docgen.docgen_render(NULL::VARCHAR, {customer: 'Ada'}) AS doc",
188+
},
189+
{
190+
"description": "Non-DOCX inline bytes degrade to a clean NULL rather than crashing the worker.",
191+
"sql": "SELECT docgen.docgen_render('not a docx'::BLOB, {x: 1}) AS doc",
192+
},
193+
]
194+
)
195+
91196

92197
class DocgenRenderPath(ScalarFunction):
93198
"""``docgen_render(path, data)`` -- render a template file to a DOCX BLOB."""
@@ -102,10 +207,15 @@ class Meta:
102207
"resolved under $VGI_DOCGEN_TEMPLATES) filled with a STRUCT of row "
103208
"data; returns the rendered .docx as a BLOB, or NULL on failure."
104209
)
210+
tags = {**_RENDER_TAGS, "vgi.executable_examples": _RENDER_EXECUTABLE_EXAMPLES}
105211
examples = [
106212
FunctionExample(
107-
sql="SELECT docgen.docgen_render('invoice.docx', {customer: name, total: amt}) FROM inv",
108-
description="Render an invoice per row from a template file",
213+
sql=(
214+
"SELECT octet_length(docgen.docgen_render("
215+
f"'{SAMPLE_TEMPLATE_PATH}', "
216+
"{customer: 'Ada', total: '99.50'})) > 0 AS ok"
217+
),
218+
description="Render an invoice from the bundled sample template",
109219
),
110220
]
111221

@@ -136,10 +246,11 @@ class Meta:
136246
"with a STRUCT; with pdf=true convert to PDF via headless "
137247
"LibreOffice (NULL if LibreOffice is unavailable), else return DOCX."
138248
)
249+
tags = dict(_RENDER_TAGS)
139250
examples = [
140251
FunctionExample(
141-
sql="SELECT docgen.docgen_render('invoice.docx', {total: amt}, true) FROM inv",
142-
description="Render to PDF (requires LibreOffice on PATH)",
252+
sql="SELECT docgen.docgen_render('invoice.docx', {total: '99.50'}, true) AS doc",
253+
description="Render to PDF (requires LibreOffice on PATH; NULL otherwise)",
143254
),
144255
]
145256

@@ -171,10 +282,11 @@ class Meta:
171282
"filled with a STRUCT of row data; returns the rendered .docx as a "
172283
"BLOB, or NULL on failure."
173284
)
285+
tags = dict(_RENDER_TAGS)
174286
examples = [
175287
FunctionExample(
176-
sql="SELECT docgen.docgen_render(tpl, {customer: name}) FROM letters, templates",
177-
description="Render from a template held as bytes",
288+
sql="SELECT docgen.docgen_render('not a docx'::BLOB, {customer: 'Ada'}) AS doc",
289+
description="Render from inline template BLOB bytes (non-DOCX bytes yield a clean NULL)",
178290
),
179291
]
180292

@@ -205,10 +317,11 @@ class Meta:
205317
"filled with a STRUCT; with pdf=true convert to PDF via headless "
206318
"LibreOffice (NULL if unavailable), else return DOCX."
207319
)
320+
tags = dict(_RENDER_TAGS)
208321
examples = [
209322
FunctionExample(
210-
sql="SELECT docgen.docgen_render(tpl, {total: amt}, true) FROM inv, templates",
211-
description="Render template bytes to PDF (requires LibreOffice)",
323+
sql="SELECT docgen.docgen_render('not a docx'::BLOB, {total: '99.50'}, true) AS doc",
324+
description="Render template bytes to PDF (requires LibreOffice; NULL otherwise)",
212325
),
213326
]
214327

vgi_docgen/tables.py

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525

2626
from __future__ import annotations
2727

28+
import json
2829
from dataclasses import dataclass
30+
from pathlib import Path
2931
from typing import Annotated, ClassVar
3032

3133
import pyarrow as pa
@@ -39,8 +41,96 @@
3941
from . import core
4042
from .buffering import DrainState, SinkBuffer
4143
from .core import DocgenError, TemplateRef
44+
from .meta import object_tags
4245
from .schema_utils import field as sfield
4346

47+
_MERGE_TITLE = "Merge Rows into One Document"
48+
49+
_MERGE_DESCRIPTION_LLM = (
50+
"Mail-merge a **whole input relation into a SINGLE merged DOCX (or PDF) "
51+
"document**, returned as a one-row, one-column `BLOB`.\n\n"
52+
"## What it does\n\n"
53+
'`docgen_merge` is the "many rows -> one document" path. It consumes an '
54+
"entire input relation (passed positionally as a `(SELECT ...)` subquery), "
55+
"renders the named `template` once per row, and concatenates the results "
56+
"into one `.docx`, with a page break between rows. Every column of the "
57+
"relation is exposed to the template as a Jinja2 variable (`{{ column }}`). "
58+
"Because it is a buffering (Sink+Source) function, it sinks every input "
59+
"batch first and renders+merges once at finalize.\n\n"
60+
"## When to use it\n\n"
61+
"Use it to assemble one combined document from many rows -- a single PDF of "
62+
"all monthly statements, one Word file containing every contract. When you "
63+
"instead want a *separate* document per row, use the `docgen_render` scalar.\n\n"
64+
"## Inputs and output\n\n"
65+
"- `data` -- the input relation, a positional `(SELECT ...)` subquery; "
66+
"every column becomes a template variable.\n"
67+
"- `template` -- named `VARCHAR` arg: path to a `.docx` template, resolved "
68+
"under `$VGI_DOCGEN_TEMPLATES`.\n"
69+
"- `pdf` -- named `BOOLEAN` arg; `true` converts the merged document to PDF "
70+
"via headless LibreOffice.\n"
71+
"- Returns a single `doc` `BLOB` row.\n\n"
72+
"## Edge cases\n\n"
73+
"An **empty input relation yields zero output rows** (no document). A "
74+
"missing/typo'd template name or an unavailable LibreOffice surfaces as a "
75+
"clean DuckDB error (the merge path fails loudly rather than emitting a "
76+
"silently empty file); the worker stays alive and serving afterwards."
77+
)
78+
79+
_MERGE_DESCRIPTION_MD = (
80+
"# Merge Rows into One Document\n\n"
81+
"Mail-merge a whole relation into a **single merged DOCX/PDF** `BLOB` -- "
82+
"one template render per input row, concatenated with a page break.\n\n"
83+
"## Usage\n\n"
84+
"```sql\n"
85+
"-- Merge an invoice per row into ONE document\n"
86+
"SELECT doc FROM docgen.docgen_merge(\n"
87+
" (SELECT customer, total FROM invoices),\n"
88+
" template := 'invoice.docx');\n\n"
89+
"-- PDF output (requires headless LibreOffice on PATH)\n"
90+
"SELECT doc FROM docgen.docgen_merge(\n"
91+
" (SELECT customer FROM letters),\n"
92+
" template := 'letter.docx', pdf := true);\n"
93+
"```\n\n"
94+
"## Notes\n\n"
95+
"- The relation is the positional `(SELECT ...)` argument; every column is "
96+
"a Jinja2 template variable.\n"
97+
"- `template` and `pdf` are named args (`name := value`), supported by "
98+
"table functions.\n"
99+
"- An empty input relation produces zero output rows; a missing template "
100+
"raises a clean DuckDB error."
101+
)
102+
103+
_MERGE_KEYWORDS = (
104+
"docgen, merge, mail merge, document generation, concatenate, combine, "
105+
"docx, word, template, jinja2, docxtpl, docxcompose, single document, "
106+
"batch, blob, pdf, libreoffice, statements, contracts"
107+
)
108+
109+
# Absolute path to the bundled sample DOCX template, resolved at import time so
110+
# examples resolve regardless of the worker's working directory.
111+
SAMPLE_TEMPLATE_PATH = str(Path(__file__).resolve().parent / "data" / "sample_invoice.docx")
112+
113+
# VGI509 guaranteed-runnable, catalog-qualified examples. They merge real rows
114+
# against the worker's BUNDLED sample template (resolved by absolute path), so
115+
# the example produces an actual non-empty merged document -- self-contained and
116+
# re-runnable with no external table or user-supplied file. ``expected_result``
117+
# is omitted deliberately.
118+
_MERGE_EXECUTABLE_EXAMPLES = json.dumps(
119+
[
120+
{
121+
"description": (
122+
"Merge two rows against the worker's bundled sample template into "
123+
"ONE document and confirm a non-empty DOCX BLOB is produced."
124+
),
125+
"sql": (
126+
"SELECT octet_length(doc) > 0 AS ok FROM docgen.docgen_merge("
127+
"(SELECT * FROM (VALUES ('Ada', '99.50'), ('Grace', '42.00')) "
128+
f"AS t(customer, total)), template := '{SAMPLE_TEMPLATE_PATH}')"
129+
),
130+
}
131+
]
132+
)
133+
44134
_MERGE_SCHEMA = pa.schema(
45135
[
46136
sfield(
@@ -91,7 +181,15 @@ class Meta:
91181
)
92182
categories = ["docgen", "template", "merge", "blob"]
93183
tags = {
94-
"vgi.columns_md": (
184+
**object_tags(
185+
_MERGE_TITLE,
186+
_MERGE_DESCRIPTION_LLM,
187+
_MERGE_DESCRIPTION_MD,
188+
_MERGE_KEYWORDS,
189+
"vgi_docgen/tables.py",
190+
),
191+
"vgi.executable_examples": _MERGE_EXECUTABLE_EXAMPLES,
192+
"vgi.result_columns_md": (
95193
"| column | type | description |\n"
96194
"|---|---|---|\n"
97195
"| `doc` | BLOB | The single merged document -- DOCX by default, or PDF when "
@@ -102,10 +200,11 @@ class Meta:
102200
examples = [
103201
FunctionExample(
104202
sql=(
105-
"SELECT doc FROM docgen.docgen_merge("
106-
"(SELECT customer, total FROM invoices), template := 'invoice.docx')"
203+
"SELECT octet_length(doc) > 0 AS ok FROM docgen.docgen_merge("
204+
"(SELECT * FROM (VALUES ('Ada', '99.50'), ('Grace', '42.00')) "
205+
f"AS t(customer, total)), template := '{SAMPLE_TEMPLATE_PATH}')"
107206
),
108-
description="Merge an invoice per row into one document",
207+
description="Merge two rows into one document using the bundled sample template",
109208
)
110209
]
111210

0 commit comments

Comments
 (0)