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:
@@ -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
|
||||
# ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user