Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,3 @@
# mkdocs build output
site/
.ralph/
.tablassert-agent/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project are documented in this file.
## Unreleased

### Breaking Changes
- **Slimmed the `tablassert agent` CLI surface.** Removed the `--qc-threshold`/`-qt` flag (it was echoed into the run metrics but never gated any decision — the accept/`MAPPED` outcome is driven solely by `--map-threshold` coverage), the `--executor`/`-e` flag and its Docker sandbox option (model-written code now always runs on the in-process `local` executor), and the `--no-fetch`/`-nf` flag (the supervisor always fetches the PMC payload). The `--map-threshold` default was lowered from `0.8` to `0.25`, and the agent workspace default moved from the standalone `.tablassert-agent/` directory to `.tablassert/agent/` (under the shared, already-git-ignored `.tablassert/` parent).
- Renamed CLI commands to a consistent verb-noun scheme and retired the legacy `datassert` naming throughout: `build` → `build-kg` (`validate` and `build-fullmap` are unchanged — they already fit the scheme). The `Graph` config field `datassert` is renamed to `fullmap` (also renaming the `resolve_many()` keyword argument from `datassert` to `fullmap`); existing graph YAML configs must rename their `datassert:` key to `fullmap:`. This is a pure naming migration. `build-fullmap`'s default `--output`/`--cache` directories moved from `./datassert/` to `./fullmap/`. (Note: the embedded fullmap database format was independently changed — see the redb schema item below — so existing `fullmap.redb` files **do** need to be rebuilt with `tablassert build-fullmap`.)
- Removed the `syntax` key entirely from both configuration formats. Graph configs no longer accept `syntax: GC2`/`GC3` and table configs no longer accept `syntax: TC4`; any `syntax:` field is now rejected (`extra = "forbid"`).
- Removed the `status` template-metadata field from table configurations.
Expand Down
27 changes: 11 additions & 16 deletions docs/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,14 @@ With network access and a configured endpoint:
```bash
tablassert agent PMC11708054 PMC12345678 \
--fullmap .fullmap \
--map-threshold 0.8 \
--qc-threshold 0.9 \
--map-threshold 0.25 \
--max-improve-iters 3 \
--max-steps 20 \
--state-dir .tablassert-agent \
--executor local
--state-dir .tablassert/agent
```

Flags: `--max-steps`/`-ms`, `--map-threshold`/`-mt`, `--qc-threshold`/`-qt`, `--max-improve-iters`/`-mi`,
`--state-dir`/`-sd`, `--executor {local,docker}`/`-e`, `--backend {openai,litellm}`/`-b`, `--no-fetch`/`-nf`.
Flags: `--max-steps`/`-ms`, `--map-threshold`/`-mt`, `--max-improve-iters`/`-mi`,
`--state-dir`/`-sd`, `--backend {openai,litellm}`/`-b`.

### What the supervisor does

Expand All @@ -125,12 +123,12 @@ advances — one difficult article never aborts the batch.

### Workspace layout & checkpoint / resume

`tablassert agent` uses a **single stable workspace root** — `state_dir` (default `.tablassert-agent`,
`tablassert agent` uses a **single stable workspace root** — `state_dir` (default `.tablassert/agent`,
override with `--state-dir`). The CLI never sets a separate artifact root, so the checkpoint, the configs,
the fetched downloads, and the build outputs **all co-locate** under it:

```
.tablassert-agent/ # = state_dir (the workspace root)
```text
.tablassert/agent/ # = state_dir (the workspace root)
state.json # supervisor checkpoint (atomic; unchanged location)
configs/<pmc_id>.yaml # best / accepted config (ALL configs in ONE folder)
configs/<pmc_id>.derived.yaml # initial agent-derived config
Expand All @@ -143,25 +141,24 @@ the fetched downloads, and the build outputs **all co-locate** under it:
| `state.json` | supervisor checkpoint: `{pmc_id, status, config_path, coverage_history[], qc_pass_rate, attempts, last_edits, best_coverage, best_config_path}` per record | written **atomically** (tmp write + `os.replace`) after each config and each improve iteration; git-ignored |
| `configs/<pmc_id>.yaml` | the best / accepted config for the article | the reuse entry point (below) |
| `configs/<pmc_id>.derived.yaml` | the agent's initial derived config | kept for provenance |
| `downloads/<pmc_id>/<prefix>/` | fetched PMC payload (main text + metadata + tables) | **stable** — persists across runs; `--no-fetch` replays this snapshot instead of re-downloading |
| `downloads/<pmc_id>/<prefix>/` | fetched PMC payload (main text + metadata + tables) | **stable** — persists across runs |
| `builds/<pmc_id>/` | KGX artifacts: `agent_0.0.1.{nodes,edges}.ndjson`, `table.yaml`, `graph.yaml`, `.tablassert/store` | **stable** — the built graph for the article |

Re-running the same command **resumes** from the checkpoint: records already `MAPPED`/`SKIPPED` are
skipped. Because `downloads/` persists, a resumed or `--no-fetch` run reuses the already-fetched payload
with no re-download.
skipped. The `downloads/` payload persists on disk across runs.

### Reusing agent outputs with the full pipeline

The best config's `source.local` points at the downloaded table under `downloads/<pmc_id>/`, so the full
(non-agent) pipeline can reuse the agent's output **without re-fetching**:

```bash
tablassert build-kg .tablassert-agent/configs/PMC11708054.yaml --table-config --fullmap ./fullmap
tablassert build-kg .tablassert/agent/configs/PMC11708054.yaml --table-config --fullmap ./fullmap
```

!!! warning "Not relocatable"
`source.local` in the best config is an **absolute** path into `downloads/<pmc_id>/`. The workspace is
therefore **not relocatable** — moving or renaming the `.tablassert-agent` folder breaks that reference
therefore **not relocatable** — moving or renaming the `.tablassert/agent` folder breaks that reference
(re-run the agent, or fix `source.local`, after any move).

## The tools
Expand Down Expand Up @@ -203,8 +200,6 @@ PMC article text and tables are **untrusted data**. Defenses:
instructions, and any embedded commands are ignored.
- **Minimal authorized imports** — the executor allowlist is just `["yaml"]`, so a hijacked agent cannot
`import os`/`subprocess`.
- **Sandboxed executor option** — `--executor docker` runs model-written code in a sandbox; the default
`local` executor is **not** a security boundary (use `docker` for untrusted inputs).

## Evaluation & optimization loop

Expand Down
32 changes: 7 additions & 25 deletions src/tablassert/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,7 @@ def candidate_tables(files: list[Path]) -> list[Path]:
"""Return EVERY downloaded data-table file, raising ``FileNotFoundError`` when there is none.

The supervisor presents all candidates to the agent (which chooses among them and among Excel
worksheets); the fail-fast guard matters for the ``--no-fetch`` snapshot path, where no fetch gate
has already run.
worksheets); the fail-fast guard raises when a fetch yields no data tables.
"""
tables: list[Path] = [path for path in files if is_table_file(path.name)]
if not tables:
Expand Down Expand Up @@ -1598,7 +1597,6 @@ def build_agent(
instructions: str = INSTRUCTIONS,
max_steps: int = 20,
planning_interval: int = 3,
executor_type: str = "local",
additional_authorized_imports: list[str] | None = None,
step_callbacks: list[Callable[[object, object], None]] | None = None,
final_answer_checks: list[Callable[..., bool]] | None = None,
Expand All @@ -1613,10 +1611,7 @@ def build_agent(
yields an empty tool list: the supervisor builds the fullmap-bound tools (US-009) and passes
them in, since they need a fullmap this factory does not have.

SECURITY: ``executor_type="local"`` runs model-written code in-process and is NOT a security
boundary; ``executor_type="docker"`` is the HARDENED option (sandboxed executor). Pass
``executor_type`` straight through (``local``/``docker``/``e2b``). ``verbosity_level`` (a
smolagents ``LogLevel``) is forwarded only when not None.
``verbosity_level`` (a smolagents ``LogLevel``) is forwarded only when not None.
"""
_require("smolagents")
from smolagents import CodeAgent # local import keeps module import lazy # pyright: ignore[reportMissingImports]
Expand All @@ -1634,7 +1629,7 @@ def build_agent(
"additional_authorized_imports": imports,
"step_callbacks": callbacks,
"final_answer_checks": checks,
"executor_type": executor_type,
"executor_type": "local",
}
if verbosity_level is not None:
agent_kwargs["verbosity_level"] = verbosity_level
Expand Down Expand Up @@ -1975,23 +1970,19 @@ def run_supervisor(
*,
fullmap: Path,
build_model_factory: Callable[[], object],
map_threshold: float = 0.8,
qc_threshold: float = 0.9,
map_threshold: float = 0.25,
max_improve_iters: int = 3,
max_steps: int = 20,
state_dir: Path = Path(".tablassert-agent"),
executor: str = "local",
state_dir: Path = Path(".tablassert") / "agent",
workdir: Path | None = None,
fetch: bool = True,
name: str = "agent",
version: str = "0.0.1",
) -> dict[str, object]:
"""Run the deterministic supervisor over a batch of PMC ids with checkpoint/resume.

For each pmc id (resume-aware: terminal DONE/MAPPED/SKIPPED records are skipped):
1. mark RUNNING + checkpoint; fetch the latest-version article payload (``fetch_pmc_article``, the
single seam tests monkeypatch; or a pre-fetched snapshot when ``fetch`` is False) and present ALL
candidate tables + the main-text path to the agent;
single seam tests monkeypatch) and present ALL candidate tables + the main-text path to the agent;
2. run the INNER agent (``build_agent`` + ``build_model_factory()``) whose schema-gated
final answer is the initial Section config;
3. ``build_and_audit`` it for coverage, then run the deterministic IMPROVE loop
Expand Down Expand Up @@ -2036,14 +2027,7 @@ def run_supervisor(
rec.attempts += 1
save_state(state_dir, state)

files: list[Path]
if fetch:
files = fetch_pmc_article(pmc_id, pmc_download_dir(art_root, pmc_id))
else: # --no-fetch: resolve an already-fetched snapshot (the `fetch` param was previously dead)
snapshot: Path = pmc_download_dir(art_root, pmc_id)
files = sorted(path for path in snapshot.rglob("*") if path.is_file())
if not files:
raise FileNotFoundError(f"--no-fetch but no snapshot files under {snapshot}.")
files: list[Path] = fetch_pmc_article(pmc_id, pmc_download_dir(art_root, pmc_id))
tables: list[Path] = candidate_tables(files)
table_list: str = "\n".join(f" - {path}" for path in tables)
article_xml: Path | None = next((path for path in files if path.suffix.lower() in {".xml", ".nxml"}), None)
Expand All @@ -2053,7 +2037,6 @@ def run_supervisor(
model=build_model_factory(),
tools=make_tools(fullmap=fullmap, table_path=tables[0], name=name, version=version),
max_steps=max_steps,
executor_type=executor,
step_callbacks=[make_step_callback(metrics)],
verbosity_level=verbosity,
)
Expand Down Expand Up @@ -2159,7 +2142,6 @@ def total(key: str) -> int:

state.metrics = {
"map_threshold": map_threshold,
"qc_threshold": qc_threshold,
"mapped": mapped,
"skipped": skipped,
"mean_best_coverage": mean_best,
Expand Down
16 changes: 3 additions & 13 deletions src/tablassert/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,13 +536,10 @@ def agent(
api_base: Annotated[str | None, cyclopts.Parameter(name=["--api-base", "-ab"])] = None,
api_key: Annotated[str | None, cyclopts.Parameter(name=["--api-key", "-ak"])] = None,
max_steps: Annotated[int, cyclopts.Parameter(name=["--max-steps", "-ms"])] = 20,
map_threshold: Annotated[float, cyclopts.Parameter(name=["--map-threshold", "-mt"])] = 0.8,
qc_threshold: Annotated[float, cyclopts.Parameter(name=["--qc-threshold", "-qt"])] = 0.9,
map_threshold: Annotated[float, cyclopts.Parameter(name=["--map-threshold", "-mt"])] = 0.25,
max_improve_iters: Annotated[int, cyclopts.Parameter(name=["--max-improve-iters", "-mi"])] = 3,
state_dir: Annotated[Path, cyclopts.Parameter(name=["--state-dir", "-sd"])] = Path(".tablassert-agent"),
executor: Annotated[Literal["local", "docker"], cyclopts.Parameter(name=["--executor", "-e"])] = "local",
state_dir: Annotated[Path, cyclopts.Parameter(name=["--state-dir", "-sd"])] = Path(".tablassert") / "agent",
backend: Annotated[Literal["openai", "litellm"], cyclopts.Parameter(name=["--backend", "-b"])] = "openai",
no_fetch: Annotated[bool, cyclopts.Parameter(name=["--no-fetch", "-nf"], negative="")] = False,
) -> None:
"""Autonomously derive, build, audit, and improve KG configs from PMC articles.

Expand All @@ -556,8 +553,7 @@ def agent(
Model config comes from ``--model-id``/``--api-base``/``--api-key`` OR the ``TABLASSERT_AGENT_MODEL_ID``
/ ``TABLASSERT_AGENT_API_BASE`` / ``TABLASSERT_AGENT_API_KEY`` environment variables (explicit flags win).
Secrets are NEVER hardcoded or defaulted: a missing value fails loud (exit 2) BEFORE any model is built.
``--executor docker`` is the hardened, sandboxed code-execution option (``local`` runs in-process and is
not a security boundary). Requires the ``[agent]`` extra (``pip install tablassert[agent]``).
Requires the ``[agent]`` extra (``pip install tablassert[agent]``).

Args:
pmc_ids: One or more PMC article ids (positional).
Expand All @@ -567,12 +563,9 @@ def agent(
api_key: API key (falls back to ``TABLASSERT_AGENT_API_KEY``).
max_steps: Max inner-agent steps per article.
map_threshold: Coverage an article must reach to be MAPPED.
qc_threshold: Target QC pass rate.
max_improve_iters: Max deterministic improve iterations per article.
state_dir: Checkpoint/resume directory.
executor: Code-execution backend; ``docker`` is the hardened sandbox.
backend: Model backend (``openai`` or ``litellm``).
no_fetch: Skip PMC download (use already-fetched snapshot tables).
"""
from tablassert import agent as agent_mod

Expand All @@ -596,12 +589,9 @@ def build_model_factory() -> object:
fullmap=fullmap,
build_model_factory=build_model_factory,
map_threshold=map_threshold,
qc_threshold=qc_threshold,
max_improve_iters=max_improve_iters,
max_steps=max_steps,
state_dir=state_dir,
executor=executor,
fetch=not no_fetch,
)

metrics_raw: object = result.get("metrics")
Expand Down
13 changes: 5 additions & 8 deletions tests/test_agent_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def test_agent_env_fallback_and_forwarding(monkeypatch: pytest.MonkeyPatch, caps

Why: the CLI is thin glue over ``run_supervisor``. With the three env vars set (and no flags),
``resolve_model_config`` must fill the model config from the environment, and the thresholds /
executor / fetch flag / fullmap must reach the supervisor unchanged. ``run_supervisor`` and
fullmap must reach the supervisor unchanged. ``run_supervisor`` and
``build_model`` are monkeypatched (module attributes the command looks up at call time) so no real
agent runs; invoking the forwarded ``build_model_factory`` then proves the factory resolved the env
config and handed it to ``build_model``.
Expand All @@ -75,14 +75,12 @@ def fake_build_model(*args: object, **kwargs: object) -> object:
monkeypatch.setattr("tablassert.agent.run_supervisor", fake_run_supervisor)
monkeypatch.setattr("tablassert.agent.build_model", fake_build_model)

agent(["PMC1", "PMC2"], fullmap=Path("/tmp/fm"), map_threshold=0.7, max_improve_iters=5, executor="docker", no_fetch=True)
agent(["PMC1", "PMC2"], fullmap=Path("/tmp/fm"), map_threshold=0.7, max_improve_iters=5)

assert captured["pmc_ids"] == ["PMC1", "PMC2"]
assert captured["fullmap"] == Path("/tmp/fm")
assert captured["map_threshold"] == 0.7
assert captured["max_improve_iters"] == 5
assert captured["executor"] == "docker"
assert captured["fetch"] is False # no_fetch=True -> fetch=False

# The forwarded factory resolves config from the environment and builds via the patched build_model.
factory = captured["build_model_factory"]
Expand All @@ -100,12 +98,11 @@ def test_agent_cli_flag_parsing() -> None:
"""A full argv parses into the command's bound args WITHOUT executing the body.

Why: the documented UX is positional PMC ids plus flags. cyclopts' ``parse_args`` binds tokens to
the signature without running the function, so this proves ``agent PMC9 --fullmap ... --executor
docker`` parses (positional list + required ``--fullmap`` + typed flags) with no model/network run.
the signature without running the function, so this proves ``agent PMC9 --fullmap ... --map-threshold``
parses (positional list + required ``--fullmap`` + typed flags) with no model/network run.
"""
fn, bound, _ = APP.parse_args(["agent", "PMC9", "--fullmap", "/tmp/fm", "--map-threshold", "0.5", "--executor", "docker"], exit_on_error=False)
fn, bound, _ = APP.parse_args(["agent", "PMC9", "--fullmap", "/tmp/fm", "--map-threshold", "0.5"], exit_on_error=False)
assert fn is agent
assert bound.args == (["PMC9"],)
assert bound.kwargs["fullmap"] == Path("/tmp/fm")
assert bound.kwargs["map_threshold"] == 0.5
assert bound.kwargs["executor"] == "docker"
2 changes: 1 addition & 1 deletion tests/test_agent_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def test_agent_doc_references_cli_surface() -> None:
"""The documented command + flags match the real CLI (tablassert agent + key flags + env vars)."""
text: str = DOC.read_text()
assert "tablassert agent" in text
for flag in ("--fullmap", "--map-threshold", "--executor", "--max-improve-iters", "--state-dir"):
for flag in ("--fullmap", "--map-threshold", "--max-improve-iters", "--state-dir"):
assert flag in text, f"docs missing CLI flag {flag}"
for env in ("TABLASSERT_AGENT_MODEL_ID", "TABLASSERT_AGENT_API_BASE", "TABLASSERT_AGENT_API_KEY"):
assert env in text, f"docs missing env var {env}"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_agent_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ def test_candidate_tables_returns_all_tables(tmp_path: Path) -> None:


def test_candidate_tables_none_raises(tmp_path: Path) -> None:
"""No table among the files -> ``FileNotFoundError`` (the --no-fetch guard)."""
"""No table among the files -> ``FileNotFoundError`` (fail-fast when a fetch yields no tables)."""
with pytest.raises(FileNotFoundError, match="No supplementary table"):
candidate_tables([tmp_path / "a.xml", tmp_path / "b.jpg"])

Expand Down
Loading
Loading