Phase 11: crop_seed_api_lessons tool + Pioneer fallback

Add the fifth MCP tool — crop_seed_api_lessons(topic?) — backed by
docs_mcp/lessons.md, the ONLY source of opinionated content in the
server. Everything else (search_docs, get_page, lookup_variety)
returns verbatim from vendor catalogs; lessons.md fills the gaps
the corpus can't cover.

The Pioneer fallback is the critical anti-hallucination piece:
Pioneer's ToS bans automation, so the corpus has no Pioneer data.
Without this tool, an agent might surface Bayer/Asgrow chunks as
mediocre matches for a Pioneer query. The tool's docstring tells
the agent to call it on any Pioneer / P-series question; the
'pioneer' section says clearly:

  "I don't have Pioneer's variety data indexed... please consult
  Pioneer or an extension service."

  "Do NOT invent Pioneer hybrid ratings."

Other lesson sections cover knowledge the agent needs to interpret
search_docs / get_page output correctly:

- rating-scales: Bayer 1-9, Golden Harvest 9-to-1, what
  R/MR/S/Rps1c/R3 mean in soybean disease columns
- maturity-semantics: corn RM days vs soybean MG vs wheat class +
  qualitative early/medium/late
- trait-glossary: SSRIB, VT2PRIB, XF, E3, Conkesta, Clearfield, etc.
- scn-resistance: race coverage + Peking vs PI 88788 source
- regional-listings: how to interpret Bayer's "local profiles"
- sources-not-yet-indexed: which vendors aren't in the corpus yet
- checking-your-work: always call lookup_variety before quoting

Lesson lookup prefers slug-match (returns just `rating-scales` for
topic="rating", not every section that mentions ratings); falls
back to body-match only when no slug matches.

Smoke-tested with topic=pioneer, topic=rating, topic=trait,
topic=zzzzzz (no match), and topic=None (full index = 10K chars,
8 sections).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 13:18:57 -04:00
parent 3cab941c08
commit 4009dc0b78
2 changed files with 365 additions and 0 deletions
+106
View File
@@ -369,6 +369,40 @@ def _structured_ratings_block(sidecar: dict) -> str:
return "\n".join(lines).rstrip() + "\n"
# ---------------------------------------------------------------------------
# Curated lessons — docs_mcp/lessons.md is the canonical source.
# ---------------------------------------------------------------------------
LESSONS_FILE = Path(__file__).resolve().parent / "lessons.md"
_lessons_cache: list[tuple[str, str]] | None = None
def _load_lessons() -> list[tuple[str, str]]:
"""Parse lessons.md into ``[(slug, body), ...]`` sections.
Sections are delimited by ``## <slug>`` headings. The slug is the
`<slug>` token (whitespace stripped); the body is everything between
that heading and the next ``## `` heading (or EOF).
"""
global _lessons_cache
if _lessons_cache is not None:
return _lessons_cache
out: list[tuple[str, str]] = []
if not LESSONS_FILE.exists():
_lessons_cache = out
return out
text = LESSONS_FILE.read_text(encoding="utf-8")
parts = re.split(r"(?m)^## (.+)$", text)
# parts = [preamble, slug1, body1, slug2, body2, ...]
for i in range(1, len(parts), 2):
slug = parts[i].strip()
body = parts[i + 1] if i + 1 < len(parts) else ""
# Drop trailing horizontal rule that separates sections.
body = re.sub(r"\n---\s*$", "", body).strip()
out.append((slug, body))
_lessons_cache = out
return out
# ===========================================================================
# Tools
# ===========================================================================
@@ -711,6 +745,78 @@ def lookup_variety(
return "\n".join(out)
@mcp.tool()
def crop_seed_api_lessons(
topic: Annotated[
str | None,
Field(description=(
"OPTIONAL topic — match against lesson section slugs or body "
"(substring, case-insensitive). Known slugs: pioneer, "
"rating-scales, maturity-semantics, trait-glossary, "
"scn-resistance, regional-listings, sources-not-yet-indexed, "
"checking-your-work. Omit for the full curated index."
)),
] = None,
) -> str:
"""Curated knowledge that does NOT live in the scraped corpus —
vendor scale-direction notes, trait glossary, maturity semantics,
SCN resistance interpretation, the **Pioneer fallback policy**,
and rules for fact-checking your work.
Call this tool when:
* The user asks about **Pioneer** or any P-series hybrid — Pioneer
is intentionally NOT scraped (ToS bans it); the lesson tells you
what to say instead.
* You need to compare ratings across vendors — different vendors
publish on different scale directions.
* You're parsing a trait code or disease abbreviation you don't
recognize.
* Before quoting a specific rating value to a farmer — the
``checking-your-work`` lesson reminds you to call
``lookup_variety`` to confirm.
This tool is **the only source of opinionated content** in the
server. Everything else returned by search_docs / get_page /
lookup_variety is verbatim from vendor catalogs.
"""
with TimedCall("crop_seed_api_lessons", {"topic": topic}) as _call:
sections = _load_lessons()
if not sections:
_call.set(sections_returned=0)
return "_(no lessons file present — docs_mcp/lessons.md missing)_"
if not topic:
_call.set(sections_returned=len(sections))
return "\n\n---\n\n".join(
f"## {slug}\n\n{body}" for slug, body in sections
)
needle = topic.strip().lower()
# Prefer slug matches (most specific). Fall back to body match
# only when no slug matches — keeps a query like "rating" from
# returning every section that happens to mention the word.
slug_matches: list[tuple[str, str]] = []
body_matches: list[tuple[str, str]] = []
for slug, body in sections:
if needle in slug.lower():
slug_matches.append((slug, body))
elif needle in body.lower():
body_matches.append((slug, body))
matched = slug_matches if slug_matches else body_matches
_call.set(sections_returned=len(matched), topic=topic)
if not matched:
slugs = ", ".join(s for s, _ in sections)
return (
f"_(no lesson section matched topic '{topic}'. "
f"Available slugs: {slugs}.)_"
)
return "\n\n---\n\n".join(
f"## {slug}\n\n{body}" for slug, body in matched
)
# ===========================================================================
# Entry point
# ===========================================================================