Compare commits

...

10 Commits

Author SHA1 Message Date
justin 4c9b087da8 Merge pull request 'README: rewrite for crop-chem-docs as a product (was template README)' (#1) from readme-update into main 2026-05-25 17:51:20 -04:00
justin b1a712308c README: rewrite for crop-chem-docs as a product (was template README)
The README had never been customized after cloning the
docs-mcp-template — title said "docs-mcp-template" and it read as
the template's generic introduction with no mention of EPA PPLS,
the Bayer scraper, the ~4k label corpus, or the production deploy.

Replace with a crop-chem-docs-specific README that covers:

- Corpus inventory: 4,159 indexed pages (91 Bayer + 4,068 EPA PPLS)
- MCP tool catalog with crop_chem_api_lessons specifics
- Eval baseline from eval/results/with_rerank.md showing
  hybrid+rerank wins (MRR 0.672) over BM25-only (0.544) and that
  hybrid-without-rerank actively HURTS (0.114) — same pattern
  seed-mcp found independently
- Note that the deployed rerank was silently failing through
  2026-05-25 due to the llama-rerank Docker network gotcha;
  fixed and re-running eval is on the followup list
- Quick-start commands
- Repo layout reference
- Infrastructure: registry, embedder pool, shared llama-rerank
  sidecar, PRODUCT_NAME=crop_chem
- Cross-link to the sibling seed-mcp project

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:50:36 -04:00
justin 4b21ba53a2 gc: rewrite registry_gc.py against Gitea's actual API (+ UA fix)
Root cause of run #122's GC failure (turns out NOT a permission
issue, despite the 403):

  1. The template's URL was wrong: /api/v1/packages/{owner}/container/
     {name}/versions — Gitea interprets this as "look up a SINGLE
     version named 'versions'" and returns "package does not exist".
     The correct list endpoint is:
       GET /api/v1/packages/{owner}?type=container&q={name}
     which returns one entry per tag with {id, version, created_at}.

  2. Cloudflare in front of git.jpaul.io returns 403 to the default
     Python-urllib User-Agent — any non-Python UA passes (curl,
     "requests", anything). That explains the 403 in CI (Python made
     the call) vs 404 from my curl test (curl passed CF, hit Gitea's
     wrong-URL 404). So both the URL AND the UA were broken.

Fixes:
  - Set User-Agent to "crop-chem-docs-registry-gc/0.1" in api().
  - Correct URL for list (above) + DELETE
    /api/v1/packages/{owner}/container/{name}/{tag} for delete.
  - Cleaner keep policy with explicit reasons:
      always: :latest
      always: corpus-*  (production pins; Drawbar may have locked)
      keep:   --keep-latest most recent OTHER tags
      keep:   anything younger than --keep-days
      delete: everything else
  - --dry-run for safe testing.

Local dry-run against current 4 tags categorizes correctly and
deletes nothing (4 < keep-latest=6).

Leaving continue-on-error: true in the workflows for one more
cycle. If tonight's run passes the GC step cleanly, follow-up
commit removes the safety net.

(Workflow paths: filter excludes scripts/**, so this commit
doesn't trigger image-only.yml.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:42:46 -04:00
justin 3a279212ef ci: continue-on-error for the GC step (403 with current PAT scope)
Run #122 finished green-on-everything-that-matters: indexing,
docker login (REGISTRY_TOKEN fix worked), build + push, and the
package-link API call all succeeded. The image is published with
all four expected tags: latest, c5ed5560fc, corpus-2026.05.24,
a97107de46 (manual earlier push).

Only the final GC step failed with HTTP 403 enumerating
/packages/.../versions — the PAT we use as REGISTRY_TOKEN has
push/pull scope but not the broader package-admin scope needed
to list + delete old versions.

GC is housekeeping, not part of the publish path. Marking it
continue-on-error: true keeps the whole run green so monitoring
can rely on "red = real problem." Both workflows get the same
treatment.

Followup TODO baked into the workflow comments: mint a separate
PAT with admin:package scope and add it as a second secret
(PACKAGES_ADMIN_TOKEN) — then point the GC step at it. Then
remove continue-on-error.

Workflow-only commit, doesn't trigger image-only.yml (path filter
excludes .gitea/**).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:29:54 -04:00
justin 719cfee2ca ci: add concurrency control to image-only.yml
Two rapid commits today (2acba0ac5ed556) queued back-to-back
runs of the same workflow. Each run is ~90 min (the reindex is the
long pole), and the older commit's image just gets overwritten by
the newer one anyway — so the older run is pure waste.

  concurrency:
    group: image-only
    cancel-in-progress: true

cancels any in-flight run on the same workflow when a new push
lands. Future-me will thank present-me.

(The workflow-file change doesn't itself trigger image-only.yml
since .gitea/workflows/** isn't in the paths: filter — so this
commit doesn't kick off a third run.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:27:31 -04:00
justin e5da4b21b0 deploy: add llama-rerank service to compose snippet
Drawbar's compose doesn't have a rerank service today — the
llama-rerank container I spun up earlier was a standalone
docker run, not a compose service. For Docker DNS resolution
(http://llama-rerank:8080) to work between MCP + reranker, both
need to be siblings in the same compose stack.

Added the llama-rerank service entry with:
- :server-cuda image (CUDA-built llama.cpp; the plain :server is
  CPU-only and 25× slower for our 50-doc rerank pool)
- -ngl 99 to offload all layers to GPU
- deploy.resources.reservations.devices block for compose v3 GPU
  passthrough (preferred over the older `runtime: nvidia` syntax)
- volume for the HuggingFace model cache so first-start GGUF
  download survives container recreates
- no host port mapping — internal-network-only

Tesla P4 compatibility notes inline: Pascal (CC 6.1) is in the
:server-cuda image's compute-arch list (500-1200) so no special
handling beyond the standard compose entry.

Also: cleanup instruction to docker rm -f the standalone
llama-rerank from the earlier setup before bringing up compose
(name collision).

And: noted that if trashpanda's existing Ollama is a host-mode
process rather than a compose service, the MCP needs
host.docker.internal override (snippet included).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:25:34 -04:00
justin c5ed5560fc deploy: sensible Dockerfile defaults + simplified compose snippet
Image rebuild (skip scrape) / build (push) Failing after 1h41m9s
Dockerfile now sets OLLAMA_URL=http://ollama:11434 and
RERANK_URL=http://llama-rerank:8080 as image defaults, assuming the
MCP container shares a Docker network with services named `ollama`
and `llama-rerank` (typical compose pattern). Drawbar's stack
already runs both — no cross-host IPs to maintain, no off-stack
GPU dependencies. Stays inside the trashpanda compose.

deploy/drawbar-compose-snippet.md simplified: no environment
overrides needed for the common case. Override block shown only
for stacks with non-default service names. Pull tag updated to
:corpus-2026.05.24.

Per the new architecture call:
- MCP doesn't reach out to cross-host Ollama instances (192.168.0.2,
  192.168.0.125 etc.) at serve time — only at index-build time in CI.
- All serve-time dependencies are in the same Docker network as
  the consumer apps.

Code push touches Dockerfile → image-only.yml will rebuild + push.
Future-me note: the image-only.yml needs Ollama reachable from the
Gitea Actions runner for the reindex step; that still uses the LAN
endpoints (workflow env), which is correct since indexing is CI-side
not serve-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:09:38 -04:00
justin 2acba0aa86 server: catch one more "PPLS" → "crop-chem-docs" rename miss in corpus_status header
Image rebuild (skip scrape) / build (push) Failing after 16m22s
Functional smoke test from trashpanda confirmed end-to-end working:
  $ docker run -d ... git.jpaul.io/justin/crop-chem-docs:corpus-2026.05.24
  $ docker exec ... python -c 'from docs_mcp.server import corpus_status; print(corpus_status())'

Output: 4,159 labels on disk (4,068 epa_ppls + 91 bayer), 216,467
chunks in Chroma collection `crop_chem_docs`, BM25 db 416 MB,
HYBRID_SEARCH=on, RERANK_URL=http://10.10.1.65:8082. Image is
production-ready for Drawbar compose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:02:45 -04:00
justin 8766d73327 deploy: Drawbar compose snippet — first image is published
Image pushed to git.jpaul.io/justin/crop-chem-docs with three tags:
  :latest             — Watchtower auto-pull target
  :a97107de4636       — commit-sha rollback pin
  :corpus-2026.05.24  — corpus-snapshot pin (prod-recommended)

Drawbar compose snippet at deploy/drawbar-compose-snippet.md.
Wires the container against the existing infra:
  - Ollama pool: 192.168.0.2:11434, 192.168.0.2:11435,
                 192.168.0.125:11434, 10.10.1.65:11434
  - Reranker:    http://10.10.1.65:8082
  - HYBRID_SEARCH=true (production retrieval — BM25 + dense + rerank)
  - Exposes streamable-HTTP MCP on port 8000

Pull path uses git.jpaul.io (public hostname, CF-fronted; pull
response bodies aren't capped). Push path uses 192.168.0.2:1234
(LAN endpoint, bypasses CF 100MB body cap). Same registry,
different URLs — per the template gotcha doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:48:24 -04:00
justin 420b4fa2d8 workflows: use LAN registry endpoint for push (CF 100MB cap)
Cloudflare in front of git.jpaul.io caps HTTP request bodies at
100 MB, which kills container blob pushes for our 6 GB image
(Chroma layer alone is ~2 GB). Per the template gotcha doc:

  Push via LAN endpoint (192.168.0.2:1234, plain HTTP, in the
  Gitea host's insecure-registries list).
  Pull via public hostname (git.jpaul.io) — pull response bodies
  aren't capped.

REGISTRY_PUSH:  192.168.0.2:1234
REGISTRY_PULL:  git.jpaul.io   (unchanged; used for the package-link API)

This matches how hvm-docs / morpheus-docs / opsramp-docs / zerto-docs
CI workflows push successfully on the same Gitea host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:47:41 -04:00
7 changed files with 393 additions and 122 deletions
+14 -1
View File
@@ -20,8 +20,15 @@ on:
- "Dockerfile"
- "sources.json"
# If multiple pushes land in quick succession, cancel the older one
# rather than queueing both — each run is ~90 min and the older
# commit's image just gets overwritten by the newer one anyway.
concurrency:
group: image-only
cancel-in-progress: true
env:
REGISTRY_PUSH: git.jpaul.io
REGISTRY_PUSH: 192.168.0.2:1234
REGISTRY_PULL: git.jpaul.io
IMAGE: ${{ github.repository_owner }}/${{ github.event.repository.name }}
OLLAMA_URL: http://192.168.0.2:11434,http://192.168.0.2:11435,http://192.168.0.125:11434
@@ -94,6 +101,12 @@ jobs:
esac
- name: Prune old container versions
# GC requires broader scope than REGISTRY_TOKEN's push perms
# (got HTTP 403 enumerating /packages/.../versions on run #122).
# Non-critical — housekeeping only. Don't fail the whole run.
# TODO: issue a separate PAT with admin:package scope and set
# as PACKAGES_ADMIN_TOKEN, then use it here.
continue-on-error: true
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
+5 -1
View File
@@ -27,7 +27,7 @@ on:
env:
# Self-hosted Gitea registry on the same LAN as the runner.
REGISTRY_PUSH: git.jpaul.io
REGISTRY_PUSH: 192.168.0.2:1234
REGISTRY_PULL: git.jpaul.io
IMAGE: ${{ github.repository_owner }}/${{ github.event.repository.name }}
@@ -148,7 +148,11 @@ jobs:
esac
- name: Prune old container versions
# GC requires broader scope than REGISTRY_TOKEN's push perms
# (HTTP 403 on /packages/.../versions). Non-critical housekeeping.
# TODO: issue separate PAT with admin:package scope.
if: steps.commit.outputs.changed == 'true' || inputs.force_build == true
continue-on-error: true
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
+7 -3
View File
@@ -46,9 +46,13 @@ ENV PYTHONUNBUFFERED=1 \
MCP_TRANSPORT=streamable-http \
MCP_HOST=0.0.0.0 \
MCP_PORT=8000 \
HYBRID_SEARCH=true
# RERANK_URL set at deploy time, e.g. http://llama-rerank:8080
# OLLAMA_URL set at deploy time, comma-separated list
HYBRID_SEARCH=true \
OLLAMA_URL=http://ollama:11434 \
RERANK_URL=http://llama-rerank:8080
# Defaults above assume the MCP container shares a Docker network
# with services named `ollama` and `llama-rerank`. Override either
# in the compose `environment:` block if your stack uses different
# service names or if you want to point at off-stack hosts.
EXPOSE 8000
+110 -67
View File
@@ -1,103 +1,146 @@
# docs-mcp-template
# crop-chem-docs
A reusable template for building hosted MCP servers over a product's
public documentation. Distilled from one production build; everything
product-specific has been factored out.
MCP server over ~4,000 public US row-crop pesticide / herbicide / fertilizer labels — feeding the same Drawbar farm-advisor AI as the sibling [`seed-mcp`](https://git.jpaul.io/justin/seed-mcp). The advisor calls this MCP for label rates, REI/PHI, rotation restrictions, tank-mix guidance, and active-ingredient lookups.
The end product is a streamable-HTTP MCP server with ~15 tools that
any LLM client (Claude Desktop, Claude Code, Cursor, Copilot) can
call to answer questions against the docs, surface what changed
recently, and flag likely inconsistencies.
Built on [`docs-mcp-template`](https://git.jpaul.io/justin/docs-mcp-template) (same template lineage as seed-mcp). **In production** on trashpanda; the Drawbar advisor calls it via the `chem:` prefix.
## What's here
## What's in the corpus
- **[PLAN.md](PLAN.md)** — comprehensive build guide. Phased
approach (13 phases, ~23 weeks of focused work for the full
stack). Includes the design decisions, the gotchas, and a
per-product customization checklist.
- **Scaffolded skeleton** — working FastMCP server with stub tools,
Dockerfile, docker-compose, CI workflows, eval harness layout,
usage logging. Everything you need to `git clone` and start
filling in the product-specific bits.
**4,159 indexed pages** across two complementary sources:
| Source | Pages | Notes |
|---|---|---|
| `bayer` | 91 | Bayer Crop Science US product pages — Warrant, Harness, Roundup, Liberty, Capreno, etc. Rich Next.js `__NEXT_DATA__` payload: active ingredients, label rates, MOA codes, supplemental PDFs (24c / 2EE / bulletins). robots.txt explicitly whitelists RAG indexing. |
| `epa_ppls` | 4,068 | EPA Pesticide Product Label System — every registered ag chemistry product. Authoritative source of truth for EPA reg numbers, master labels, signal words, registrant info, formulations. |
## MCP tools
Same shape as the docs-mcp-template's standard tools (see [`docs_mcp/server.py`](docs_mcp/server.py)):
| Tool | Purpose |
|---|---|
| `search_docs` | Hybrid dense + BM25 + rerank search over the label corpus, filterable by source. |
| `get_page` | Full label record by `(source, source_key)`. Returns marketing copy + extracted PDF text + sidecar metadata. |
| `list_versions` | Facet discovery (sources, EPA registrant codes, label categories). |
| `crop_chem_api_lessons` | Curated agronomy / regulatory lessons — EPA reg-number normalization, label-supersession ordering, common tank-mix gotchas. |
| Plus the template's standard `diff_versions`, `bundle_changelog`, `weekly_digest` if needed. |
## Retrieval — eval-validated
From [`eval/results/with_rerank.md`](eval/results/with_rerank.md) (35 golden queries, k=5):
| Retriever | MRR | Recall@5 | nDCG@5 | Time (s) |
|---|---|---|---|---|
| **hybrid+rerank** | **0.672** | **0.638** | **0.621** | 823 |
| bm25 | 0.544 | 0.586 | 0.524 | 5 |
| dense+rerank | 0.171 | 0.143 | 0.149 | 805 |
| hybrid-rrf | 0.114 | 0.114 | 0.108 | 8 |
| dense | 0.027 | 0.086 | 0.041 | 5 |
**Deploy config**: `HYBRID_SEARCH=true` + `RERANK_URL=http://llama-rerank:8080`.
Pattern matches what seed-mcp found independently:
1. **Dense embedding alone is essentially useless** on this corpus (MRR 0.027). Variety codes, EPA reg numbers, and active-ingredient names have no semantic neighbors — `nomic-embed-text` returns noise.
2. **Hybrid-rrf (no rerank) is worse than BM25 alone.** RRF dilutes BM25's strong ranking with dense noise. Don't ship without rerank.
3. **BM25 alone (MRR 0.544, 5 sec) is a great fallback** when the rerank sidecar is unavailable.
4. **Rerank brings the win**`hybrid+rerank` MRR 0.672 is 23% better than BM25 alone and dominates every other configuration.
**Note on rerank in production**: through 2026-05-25 the `llama-rerank` sidecar was attached to Docker's default `bridge` network instead of `drawbar-backend_default`, so chem-mcp's `RERANK_URL=http://llama-rerank:8080` was resolving via public DNS to a random IP and connection-refusing. The MCP fell back to dense+BM25 silently. Fixed via `docker network connect drawbar-backend_default llama-rerank`. Re-running the eval is on the follow-up list; expect the deployed MRR to lift toward the lab number.
## Quick start
```bash
git clone https://git.jpaul.io/justin/docs-mcp-template.git my-product-docs
cd my-product-docs
git remote remove origin # detach from template
git clone https://git.jpaul.io/justin/crop-chem-docs.git
cd crop-chem-docs
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
# Read PLAN.md before doing anything else. Pay particular attention to
# Phase 1 (scraper) — that's the most product-specific phase.
# Sample-scrape to verify wiring:
python -m scrape.runner --source bayer --limit 5
# Run the stub server (no corpus yet — just verifies the wiring):
python -m docs_mcp.server --transport stdio
# Full refresh (be polite — bayer is small, epa_ppls is hours):
python -m scrape.runner --source bayer --force
python -m scrape.runner --source epa_ppls --force
# Rebuild Chroma + BM25:
OLLAMA_URL=http://192.168.0.125:11434 PRODUCT_NAME=crop_chem \
python -m rag.index --rebuild
# Run the eval harness:
RERANK_URL=http://localhost:18080 python -m eval.run_eval \
--queries eval/queries.jsonl --k 5 \
--output eval/results/baseline.md
# Local MCP server (stdio for Claude Desktop dev):
PRODUCT_NAME=crop_chem python -m docs_mcp.server --transport stdio
```
## Repo layout
```
.
├── PLAN.md # The build guide. Read first.
├── CLAUDE.md # Canonical agent guide
├── PLAN.md # Template's 13-phase build guide
├── README.md
├── requirements.txt
├── Dockerfile
├── .gitignore
├── deploy/
│ ├── docker-compose.yml # Drop-in compose for Drawbar
│ ├── drawbar-compose-snippet.md # Notes on the parent compose merge
│ └── rerank-docker.md # llama-rerank service deployment
├── .gitea/workflows/
│ ├── refresh.yml # Weekly scrape + index + image push
│ └── image-only.yml # On-demand code-only ship
│ ├── refresh.yml # Monthly cron: scrape + index + image push
│ └── image-only.yml # On-demand code-only ship cycle
├── scrape/
│ ├── README.md # Product-specific scraper goes here
── changelog.py # Reusable: --json, --history-out
│ ├── runner.py # Dispatches `--source <id>`
── changelog.py # Reusable: --json, --history-out
│ └── sources/
│ ├── bayer.py # cropscience.bayer.us Next.js scraper
│ └── epa_ppls.py # EPA PPLS pagination + label PDFs
├── rag/
│ ├── embeddings.py # Ollama embedder, swappable
│ ├── chunk.py # Chunker — adjust per page format
│ ├── index.py # Builds Chroma + (optionally) BM25
│ └── bm25.py # SQLite FTS5 lexical index
│ ├── embeddings.py # nomic-embed-text via Ollama
│ ├── chunk.py # Chunker w/ EPA-reg-number preamble
│ ├── index.py # Chroma + BM25 builder
│ └── bm25.py # FTS5 lexical index
├── docs_mcp/
│ ├── server.py # FastMCP server with stub tools
│ ├── server.py # FastMCP — hybrid+rerank
│ ├── lessons.md # Curated knowledge layer
│ └── usage.py # TimedCall + JSONL telemetry
├── eval/
│ ├── queries.jsonl.example # Curate ~25 hand-labeled queries
│ ├── retrievers.py # Retriever protocol + implementations
── run_eval.py # MRR / Recall@k / nDCG@k harness
│ ├── queries.jsonl # 35 golden queries
│ ├── retrievers.py # 5 named configurations
── run_eval.py # MRR / Recall@k / nDCG@k
│ └── results/ # Baseline + with_rerank measurements
├── scripts/
│ ├── usage_report.py # Standalone log analyzer
│ ├── usage_report.py
│ └── registry_gc.py # Container registry cleanup
└── deploy/
── docker-compose.yml # Hosting stack: MCP + reranker + Watchtower
└── corpus/ # Committed scrape output (CI-refreshed)
── bayer/
└── epa_ppls/
```
## What's product-specific (must implement)
## Infrastructure
- `scrape/` — the scraper itself. The template gives you the corpus
layout contract and a working `changelog.py`; the actual extraction
logic is yours.
- The corpus on disk (gitignored; rebuilt by CI).
- The reranker GGUF model and llama.cpp container (commented in
`deploy/docker-compose.yml`).
- The reverse proxy / TLS layer in front of the public endpoint.
- The hand-curated knowledge surface (your product's API gotchas,
example scripts, anything the LLM should know that the docs
don't say).
- **Registry**: pushes to `192.168.0.2:1234` (LAN, no CF body cap); deploys pull `git.jpaul.io/justin/crop-chem-docs:latest` (public, CF tunnel). Also tagged `:<sha12>` for rollback pinning and `:corpus-YYYY.MM.DD` for snapshot pinning.
- **Embedder pool (CI)**: 3 GPU-pinned Ollama endpoints, weighted toward `.0.125` (RTX 40-series).
- **Reranker**: shared `llama-rerank` sidecar on trashpanda's Tesla P4 (`jina-reranker-v2-base-multilingual` via llama.cpp). Same container serves crop-chem-docs and seed-mcp.
- **PRODUCT_NAME**: `crop_chem` — used in `crop_chem_docs` Chroma collection, `bm25/crop_chem_docs.db`, and the `crop_chem_api_lessons` tool name.
## What's NOT product-specific (works as-is)
## Deploy mechanics
- FastMCP server skeleton + tool decoration pattern
- Chroma + Ollama embedding pipeline
- BM25 / SQLite FTS5 lexical index
- Hybrid retrieval (RRF) + reranker integration
- Eval harness (Retriever protocol, MRR/Recall/nDCG)
- Usage logging (TimedCall, JSONL, daily rotation)
- CI workflow shape (weekly + on-demand, retry-on-race, three-tag
image scheme)
- Registry GC script
- Standard tools: `search_docs`, `get_page`, `list_versions`,
`diff_versions`, `bundle_changelog`, `weekly_digest`,
`find_doc_inconsistencies`, etc.
Same Watchtower auto-deploy chain as seed-mcp. On every push to `main` that touches `docs_mcp/`, `rag/`, `scrape/`, `requirements.txt`, `Dockerfile`, or `sources.json`:
## License
1. `image-only.yml` checks out main + committed corpus
2. Rebuilds Chroma + BM25 (~few min on the GPU pool)
3. `docker build` + push three tags to the LAN registry
4. Links the package to the repo via Gitea API
5. Watchtower on trashpanda polls `:latest` every 5 min → recreates `drawbar-backend-chem-mcp-1`
Internal template. Adjust before publishing.
Corpus refresh runs monthly via `refresh.yml`. EPA PPLS is the slow source — ~hours at 1 req/sec at full scale.
## Sibling
[`seed-mcp`](https://git.jpaul.io/justin/seed-mcp) covers the row-crop seed-variety + yield-trial side of the advisor's tool catalog. Both MCPs are docs-mcp-template clones running side-by-side on trashpanda, sharing the Ollama pool and the `llama-rerank` sidecar.
See [`CLAUDE.md`](./CLAUDE.md) for canonical sidecar schemas, the EPA reg-number normalization rules, and label-supersession ordering.
+148
View File
@@ -0,0 +1,148 @@
# Drawbar deploy — `crop-chem-docs` MCP server snippet
Drop these two services into Drawbar's `docker-compose.yml`. Targets
the trashpanda stack: shared Docker network with the existing
Drawbar services + the Cloudflare Tunnel.
## Pre-reqs (one-time on the deploy host)
1. **Docker login to the Gitea registry:**
```bash
docker login git.jpaul.io -u justin # PAT for password
```
2. **NVIDIA Container Toolkit** — already installed on trashpanda
(the existing standalone `llama-rerank` container ran with
`--gpus all` fine).
3. **If a standalone `llama-rerank` container is already running**
(left over from earlier setup), remove it so the compose service
can bind the same name:
```bash
docker rm -f llama-rerank
```
## Compose services
```yaml
services:
# ---- Reranker sidecar -----------------------------------------
# jina-reranker-v2-base-multilingual via llama.cpp on the Tesla P4.
# Internal port only (no host port mapping needed — the MCP reaches
# it via Docker DNS). ~280 MB GPU VRAM at idle, ~500 MB during a
# 50-doc rerank. Co-exists fine with any other GPU users on the P4.
llama-rerank:
image: ghcr.io/ggml-org/llama.cpp:server-cuda
container_name: llama-rerank
restart: unless-stopped
command:
- "-hf"
- "gpustack/jina-reranker-v2-base-multilingual-GGUF:Q8_0"
- "--reranking"
- "--host"
- "0.0.0.0"
- "--port"
- "8080"
- "-ngl"
- "99" # offload all layers to GPU
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
# Model cache survives container recreates; first start downloads
# the GGUF (~280 MB) from HuggingFace.
volumes:
- llama-rerank-cache:/root/.cache/huggingface
networks:
- default
# ---- MCP server ------------------------------------------------
crop-chem-docs:
image: git.jpaul.io/justin/crop-chem-docs:corpus-2026.05.24
# :latest for dev / Watchtower auto-pull
container_name: crop-chem-docs
restart: unless-stopped
ports:
- "8001:8000" # MCP server (streamable-http). Adjust host port.
# No environment block needed — the image's defaults handle it:
# OLLAMA_URL=http://ollama:11434
# RERANK_URL=http://llama-rerank:8080
# HYBRID_SEARCH=true
# PRODUCT_NAME=crop_chem
# Override here only if your services have different names.
depends_on:
- llama-rerank
networks:
- default
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
llama-rerank-cache:
```
## Note on the existing `ollama` service
The Dockerfile default is `OLLAMA_URL=http://ollama:11434` — that
assumes there's an `ollama` service in the same compose stack. If
trashpanda's Ollama is a host-mode process (not a compose service),
override the env in the `crop-chem-docs` block:
```yaml
environment:
OLLAMA_URL: "http://host.docker.internal:11434"
extra_hosts:
- "host.docker.internal:host-gateway"
```
Or just add Ollama itself to the compose stack as a sibling service.
## Test once both are up
```bash
docker compose up -d llama-rerank crop-chem-docs
# Wait ~10s for both to come up, then:
docker exec crop-chem-docs python -c \
"from docs_mcp.server import corpus_status; print(corpus_status())"
```
Expect: `# crop-chem-docs corpus status`, 4,159 labels, 216,467
chunks, BM25 db present, `RERANK_URL=http://llama-rerank:8080`,
`HYBRID_SEARCH=on`.
Then a live search to verify hybrid+rerank:
```bash
docker exec crop-chem-docs python -c \
"from docs_mcp.server import search_docs; print(search_docs('soybean herbicide for waterhemp', k=2))"
```
Expect: 2 hits with Sencor/Tackle/Warrant in top-2, `mode=hybrid-rrf+rerank` in the header.
## What the MCP container exposes
| Tool | What it does |
|---|---|
| `search_docs` | Hybrid+rerank pesticide-label search with optional filters |
| `get_page` | Full label markdown + metadata by `(source, source_key)` |
| `list_versions` | Discover sources, product classes, signal words, registrants |
| `corpus_status` | Counts + freshness; useful for health probes |
| `crop_chem_api_lessons` | Curated agronomy / label-handling knowledge — call before recommending |
## Tag scheme
| Tag | When | Use for |
|---|---|---|
| `:latest` | Every monthly refresh + every code push | Dev / Watchtower auto-pull |
| `:<sha12>` | Every build | Rollback pin |
| `:corpus-YYYY.MM.DD` | Every build | **Production pin** (frozen corpus version) |
## Updating the corpus
- **Monthly cron** — 1st @ 06:00 UTC, full re-scrape of Bayer + EPA PPLS,
reindex, image push. Watchtower pulls the new `:latest` automatically.
- **Manual** — Gitea Actions UI → `Monthly corpus refresh` → `Run workflow`.
Optional `sources` input for single-source refresh (e.g., `bayer` only).
+1 -1
View File
@@ -541,7 +541,7 @@ def corpus_status() -> str:
Cheap — no embedder call.
"""
with TimedCall("corpus_status", {}) as _call:
lines: list[str] = ["# PPLS corpus status\n"]
lines: list[str] = ["# crop-chem-docs corpus status\n"]
# On-disk corpus
labels_by_source: dict[str, int] = {}
+108 -49
View File
@@ -1,43 +1,69 @@
"""Gitea container-registry garbage collection.
Lists package versions for one container package and deletes versions
older than --keep-days. Always preserves:
Prunes old container tags from a Gitea registry package. Always
preserves:
- the :latest tag
- the --keep-latest most-recent date-tagged versions
- anything pushed in the last --keep-days days
- The ``latest`` tag (Watchtower auto-pull target)
- Any ``corpus-*`` tag (production pins; Drawbar may have them locked)
- The ``--keep-latest`` most-recent OTHER tags (typically commit-sha pins)
- Anything pushed within ``--keep-days`` days
The actual disk reclaim happens on Gitea's next package GC cron (admin
site settings). This script just marks the versions for deletion.
The actual disk reclaim happens on Gitea's next package GC cron
(admin site settings). This script marks versions for deletion.
Why this script doesn't use the Docker Registry v2 API: that API has
tag listing + manifest delete by digest, but no per-tag created-at
timestamp without an extra blob-fetch round-trip. Gitea's packages
API gives us {tag, created_at} in one call, which is what the keep
policy needs.
The endpoint shape that actually works (matches Gitea 1.21+):
GET /api/v1/packages/{owner}?type=container&q={name}
→ JSON array, ONE entry per tag, each with id + version=tag + created_at
DELETE /api/v1/packages/{owner}/container/{name}/{tag}
→ 204 on success, 404 if already gone
Auth: GITEA_TOKEN env var (PAT with delete:packages scope; the
push-only PAT we use as REGISTRY_TOKEN may not be enough — if you
see 403s, mint a separate PAT and pass it as GITEA_TOKEN here).
Usage:
python scripts/registry_gc.py \\
--owner <user> \\
--package <product>-docs-mcp \\
--keep-days 90 \\
--keep-latest 5
Auth: reads GITEA_TOKEN from env (set in the workflow as a secret).
--owner justin \\
--package crop-chem-docs \\
--keep-days 180 \\
--keep-latest 6
[--dry-run]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timedelta, timezone
from urllib.request import Request, urlopen
from urllib.error import HTTPError
import json
from urllib.request import Request, urlopen
GITEA_HOST = os.environ.get("GITEA_HOST", "https://git.jpaul.io")
def api(token: str, method: str, path: str) -> object:
req = Request(f"{GITEA_HOST}{path}",
headers={"Authorization": f"token {token}"},
method=method)
# User-Agent matters: Cloudflare in front of git.jpaul.io returns
# 403 to the default `Python-urllib/3.x` UA. Any non-Python UA
# passes. Curl works, requests works, we just need to not look
# like a vanilla urllib script.
req = Request(
f"{GITEA_HOST}{path}",
headers={
"Authorization": f"token {token}",
"User-Agent": "crop-chem-docs-registry-gc/0.1",
},
method=method,
)
try:
with urlopen(req, timeout=30) as r:
body = r.read()
@@ -48,60 +74,93 @@ def api(token: str, method: str, path: str) -> object:
raise
def _parse_created(version: dict) -> datetime:
"""Gitea returns RFC3339 with offset like '2026-05-24T16:07:50-04:00'.
Python 3.11+ handles this directly via fromisoformat."""
return datetime.fromisoformat(version["created_at"])
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--owner", required=True)
p.add_argument("--package", required=True)
p.add_argument("--keep-days", type=int, default=90)
p.add_argument("--keep-latest", type=int, default=5)
p.add_argument("--dry-run", action="store_true")
p.add_argument("--keep-days", type=int, default=180)
p.add_argument("--keep-latest", type=int, default=6,
help="Keep this many most-recent commit-sha (etc.) "
"tags BEFORE applying --keep-days. corpus-* and "
":latest are kept regardless.")
p.add_argument("--dry-run", action="store_true",
help="Show what would be deleted without calling DELETE.")
args = p.parse_args()
token = os.environ.get("GITEA_TOKEN")
if not token:
print("GITEA_TOKEN not set", file=sys.stderr)
print("GITEA_TOKEN env var not set", file=sys.stderr)
return 1
versions = api(token, "GET",
f"/api/v1/packages/{args.owner}/container/{args.package}/versions") or []
# Gitea's q= is a substring match; filter to exact name so we don't
# accidentally GC a sibling package that shares the prefix.
versions = api(
token, "GET",
f"/api/v1/packages/{args.owner}?type=container&q={args.package}",
) or []
versions = [v for v in versions if v.get("name") == args.package]
if not versions:
print(f"no versions found for {args.owner}/{args.package}")
print(f"no versions found for {args.owner}/{args.package} — nothing to GC")
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=args.keep_days)
versions.sort(key=_parse_created, reverse=True) # newest first
# Date-tagged versions (YYYY.MM.DD), newest first
date_tagged = []
for v in versions:
tags = v.get("tags") or []
for t in tags:
if len(t) == 10 and t[4] == "." and t[7] == ".":
date_tagged.append((t, v))
break
date_tagged.sort(key=lambda kv: kv[0], reverse=True)
keep_date_tags = {t for t, _ in date_tagged[:args.keep_latest]}
keep: list[tuple[str, str]] = [] # (tag, reason)
delete: list[dict] = []
other_kept = 0
deleted = 0
for v in versions:
tags = v.get("tags") or []
if "latest" in tags:
tag = v.get("version", "")
created = _parse_created(v)
if tag == "latest":
keep.append((tag, "always-keep (:latest)"))
continue
if any(t in keep_date_tags for t in tags):
if tag.startswith("corpus-"):
keep.append((tag, "production pin (corpus-*)"))
continue
try:
created = datetime.fromisoformat(v["created_at"].replace("Z", "+00:00"))
except (KeyError, ValueError):
if other_kept < args.keep_latest:
other_kept += 1
keep.append((tag, f"keep-latest #{other_kept}/{args.keep_latest}"))
continue
if created >= cutoff:
keep.append((tag, f"within --keep-days ({args.keep_days})"))
continue
version_id = v.get("id")
print(f" deleting v{version_id} tags={tags} created={v['created_at']}")
if not args.dry_run:
delete.append(v)
print(f"=== {args.owner}/{args.package}: {len(versions)} total tag(s) ===")
for tag, reason in keep:
print(f" KEEP {tag:<28} {reason}")
for v in delete:
print(f" DEL {v['version']:<28} created={v['created_at']}")
if not delete:
print("nothing to delete")
return 0
if args.dry_run:
print(f"--dry-run; would delete {len(delete)} tag(s)")
return 0
failed = 0
for v in delete:
tag = v["version"]
try:
api(token, "DELETE",
f"/api/v1/packages/{args.owner}/container/{args.package}/versions/{version_id}")
deleted += 1
print(f"done: {deleted} version(s) deleted")
return 0
f"/api/v1/packages/{args.owner}/container/{args.package}/{tag}")
print(f" ✓ deleted {tag}")
except HTTPError as e:
print(f" ✗ failed {tag}: HTTP {e.code} {e.reason}", file=sys.stderr)
failed += 1
print(f"done: deleted {len(delete) - failed} / {len(delete)} tag(s)")
return 0 if failed == 0 else 1
if __name__ == "__main__":