diff --git a/CHANGELOG.md b/CHANGELOG.md index b02ffbf..8a6ae0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,21 @@ those changes. ## [Unreleased] +## [1.51.0] - 2026-06-30 + +### Added + +- A reusable analysis-recipe framework (`process_improve.recipes`): frozen + `RecipeStep` / `AnalysisRecipe` dataclasses, a `register_recipe` registry with + lazy `discover_recipes`, a substring matcher `select_recipe`, and a general + `select_analysis_recipe` agent tool that maps a free-text request to a guided, + step-by-step workflow chaining existing tools. Any subpackage can register its + own recipes. +- Sensory analysis recipes (`process_improve.sensory.recipes`): guided workflows + for data intake, panel consistency / scale-use correction, and relating + attributes to product covariates (with the genuine / proxy / coincidence + separation), plus a parked placeholder for a future visualisation workflow. + ## [1.50.0] - 2026-06-30 ### Added @@ -2282,7 +2297,8 @@ this entry records them together. - Reworked the README with a sharper value proposition and a "Why not scikit-learn?" comparison table. -[Unreleased]: https://github.com/kgdunn/process-improve/compare/v1.50.0...HEAD +[Unreleased]: https://github.com/kgdunn/process-improve/compare/v1.51.0...HEAD +[1.51.0]: https://github.com/kgdunn/process-improve/compare/v1.50.0...v1.51.0 [1.50.0]: https://github.com/kgdunn/process-improve/compare/v1.49.1...v1.50.0 [1.49.1]: https://github.com/kgdunn/process-improve/compare/v1.49.0...v1.49.1 [1.49.0]: https://github.com/kgdunn/process-improve/compare/v1.48.0...v1.49.0 diff --git a/CITATION.cff b/CITATION.cff index 86a9ab1..e2c43ca 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,7 +12,7 @@ authors: repository-code: "https://github.com/kgdunn/process-improve" url: "https://kgdunn.github.io/process-improve/" license: MIT -version: 1.50.0 +version: 1.51.0 date-released: "2026-06-30" keywords: - chemometrics diff --git a/pyproject.toml b/pyproject.toml index b75bc23..ea6d5d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "process-improve" -version = "1.50.0" +version = "1.51.0" description = 'Designed Experiments; Latent Variables (PCA, PLS, multivariate methods with missing data); Process Monitoring; Batch data analysis.' readme = "README.md" license = "MIT" diff --git a/src/process_improve/recipes.py b/src/process_improve/recipes.py new file mode 100644 index 0000000..bf787aa --- /dev/null +++ b/src/process_improve/recipes.py @@ -0,0 +1,294 @@ +"""(c) Kevin Dunn, 2010-2026. MIT License. + +Reusable analysis-recipe framework. + +An *analysis recipe* is a predefined, step-by-step workflow an LLM agent can +follow when a user's request matches a known analytical scenario (intake, +panel processing, relating to covariates, ...). Recipes reference existing agent +tools by name so the agent chains calls deterministically instead of improvising +the order. + +The framework is package-wide and domain-agnostic: any subpackage may define its +own recipes and register them. The sensory subpackage is the first consumer (see +:mod:`process_improve.sensory.recipes`). + +Adding recipes for a subpackage +------------------------------- +1. Create ``process_improve//recipes.py``. +2. Build :class:`AnalysisRecipe` instances and pass each to + :func:`register_recipe`. +3. Add ``"process_improve..recipes"`` to ``_RECIPE_MODULES`` below so + :func:`discover_recipes` imports it. + +No changes to the agent tool layer are required: the single, general +``select_analysis_recipe`` tool matches across every registered recipe. +""" + +from __future__ import annotations + +import importlib +import logging +import re +from dataclasses import dataclass, field +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from process_improve.tool_spec import clean, tool_spec + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class RecipeStep: + """One step in an analysis recipe the agent should execute. + + Attributes + ---------- + order : int + 1-based position of the step in the recipe. + directive : str + Natural-language instruction telling the agent what to do. + tools : list of str + Names of agent tools this step may call (empty for prose-only steps + such as interpretation or data assembly). + arg_hints : dict + Optional ``{parameter: "where the value comes from"}`` hints, for + example ``{"score_min": "0", "mode": "observational"}``. + """ + + order: int + directive: str + tools: list[str] = field(default_factory=list) + arg_hints: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AnalysisRecipe: + """A reusable, multi-step analysis workflow for the agent. + + Attributes + ---------- + key : str + Unique snake_case identifier. + title : str + Human-readable name. + summary : str + One-paragraph description of what the recipe does and when to use it. + domain : str + The subpackage the recipe belongs to (e.g. ``"sensory"``). + cue_phrases : list of str + Lower-case substrings; each one found in a user's request scores the + recipe one point during matching. + inputs_needed : list of str + What the agent must resolve from the user before running, each with a + short example. + stages : list of RecipeStep + The ordered steps. Empty for a planned (not yet available) recipe. + status : str + ``"available"`` (default) or ``"planned"`` for parked future work. + """ + + key: str + title: str + summary: str + domain: str + cue_phrases: list[str] + inputs_needed: list[str] + stages: list[RecipeStep] + status: str = "available" + + def to_payload(self) -> dict[str, Any]: + """Serialise to a JSON-friendly dict the agent can consume.""" + payload: dict[str, Any] = { + "recipe_key": self.key, + "title": self.title, + "summary": self.summary, + "domain": self.domain, + "status": self.status, + "inputs_needed": self.inputs_needed, + "stages": [ + { + "step": s.order, + "directive": s.directive, + **({"tools": s.tools} if s.tools else {}), + **({"arg_hints": s.arg_hints} if s.arg_hints else {}), + } + for s in self.stages + ], + } + if self.status != "available": + payload["note"] = ( + "This recipe is planned and not yet runnable; tell the user the workflow is " + "coming rather than attempting it." + ) + return payload + + +# --------------------------------------------------------------------------- +# Registry and discovery (mirrors process_improve.tool_spec) +# --------------------------------------------------------------------------- + +#: Maps recipe key -> registered recipe. Populated by ``register_recipe``. +_RECIPE_REGISTRY: dict[str, AnalysisRecipe] = {} + +#: Whether ``discover_recipes()`` has already run. +_recipes_discovered: bool = False + +#: Subpackage recipe modules imported by ``discover_recipes``. Append new +#: ``process_improve..recipes`` modules here as they are added. +_RECIPE_MODULES: list[str] = [ + "process_improve.sensory.recipes", +] + + +def register_recipe(recipe: AnalysisRecipe) -> AnalysisRecipe: + """Register *recipe* in the global catalog and return it. + + Raises + ------ + ValueError + If a recipe with the same ``key`` is already registered. + """ + if recipe.key in _RECIPE_REGISTRY: + raise ValueError(f"A recipe with key {recipe.key!r} is already registered.") + _RECIPE_REGISTRY[recipe.key] = recipe + return recipe + + +def _import_recipe_module(module: str) -> None: + """Import one subpackage recipe module, tolerating a missing dependency.""" + try: + importlib.import_module(module) + except ModuleNotFoundError as exc: + logger.warning("Recipe module %r not loaded (missing dependency): %s", module, exc) + + +def discover_recipes() -> None: + """Import every subpackage recipe module to populate the registry. + + Called lazily by the public query helpers. Safe to call repeatedly + (subsequent calls are no-ops). A genuinely missing module is logged rather + than raised, mirroring :func:`process_improve.tool_spec.discover_tools`. + """ + global _recipes_discovered # noqa: PLW0603 + if _recipes_discovered: + return + for module in _RECIPE_MODULES: + _import_recipe_module(module) + _recipes_discovered = True + + +# --------------------------------------------------------------------------- +# Matching +# --------------------------------------------------------------------------- + +_WORD_PATTERN = re.compile(r"\w+") + + +def _canonicalise(text: str) -> str: + """Lower-case and collapse to single-spaced word tokens.""" + return " ".join(_WORD_PATTERN.findall(text.lower())) + + +def select_recipe(query: str) -> AnalysisRecipe | None: + """Return the best-matching recipe for *query*, or ``None``. + + Scoring is intentionally simple: each cue phrase that appears as a substring + of the canonicalised query contributes one point. The highest-scoring recipe + wins (ties broken by registration order); a minimum score of one is required + to match. Replace with embedding similarity if the catalog grows beyond a few + dozen recipes. + """ + discover_recipes() + canonical = _canonicalise(query) + best: AnalysisRecipe | None = None + best_score = 0 + for recipe in _RECIPE_REGISTRY.values(): + score = sum(1 for phrase in recipe.cue_phrases if _canonicalise(phrase) in canonical) + if score > best_score: + best_score = score + best = recipe + return best + + +def list_recipes() -> list[AnalysisRecipe]: + """Return every registered recipe (registration order).""" + discover_recipes() + return list(_RECIPE_REGISTRY.values()) + + +def get_recipe(key: str) -> AnalysisRecipe | None: + """Return the registered recipe with *key*, or ``None``.""" + discover_recipes() + return _RECIPE_REGISTRY.get(key) + + +# --------------------------------------------------------------------------- +# Agent tool +# --------------------------------------------------------------------------- + + +class _RecipeQuery(BaseModel): + """Input contract for ``select_analysis_recipe``.""" + + model_config = ConfigDict(extra="forbid") + + query: str = Field( + ..., + min_length=1, + description="The user's request in natural language; the best-matching analysis recipe is returned.", + ) + + +@tool_spec( + name="select_analysis_recipe", + description=( + "Match a free-text request to a predefined, step-by-step analysis recipe (a guided workflow that " + "chains existing process-improve tools in the right order). Use this first when a user asks an " + "open-ended 'how do I analyse this' question, then follow the returned stages. " + "Returns: {ok: true, matched: bool, recipe, available}. 'recipe' is the matched recipe payload " + "(recipe_key, title, summary, domain, status, inputs_needed, and ordered 'stages' each with a " + "directive plus optional tools/arg_hints; a planned recipe also carries a 'note') or null when " + "nothing matches. 'available' always lists every registered recipe as {recipe_key, title, " + "summary, domain, status} so you can offer a choice even when matched is false." + ), + input_model=_RecipeQuery, + category="recipes", +) +def select_analysis_recipe(spec: _RecipeQuery) -> dict: + """Return the best-matching recipe payload plus the full catalogue.""" + match = select_recipe(spec.query) + available = [ + { + "recipe_key": r.key, + "title": r.title, + "summary": r.summary, + "domain": r.domain, + "status": r.status, + } + for r in list_recipes() + ] + return clean( + { + "ok": True, + "matched": match is not None, + "recipe": match.to_payload() if match is not None else None, + "available": available, + } + ) + + +__all__ = [ + "AnalysisRecipe", + "RecipeStep", + "get_recipe", + "list_recipes", + "register_recipe", + "select_analysis_recipe", + "select_recipe", +] diff --git a/src/process_improve/sensory/__init__.py b/src/process_improve/sensory/__init__.py index 0492223..5eb28b3 100644 --- a/src/process_improve/sensory/__init__.py +++ b/src/process_improve/sensory/__init__.py @@ -27,6 +27,7 @@ from process_improve.sensory.ingest import reshape_to_long from process_improve.sensory.mam import MAMResult, align_scores, mixed_assessor_model from process_improve.sensory.panel import PanelScorecard, apply_correction, panel_scorecard +from process_improve.sensory.recipes import SENSORY_RECIPES from process_improve.sensory.validation import ( DESCRIPTIVE_LONG_COLUMNS, ValidationResult, @@ -35,6 +36,7 @@ __all__ = [ "DESCRIPTIVE_LONG_COLUMNS", + "SENSORY_RECIPES", "AnalysisResult", "MAMResult", "PanelScorecard", diff --git a/src/process_improve/sensory/recipes.py b/src/process_improve/sensory/recipes.py new file mode 100644 index 0000000..72abc43 --- /dev/null +++ b/src/process_improve/sensory/recipes.py @@ -0,0 +1,335 @@ +"""(c) Kevin Dunn, 2010-2026. MIT License. + +Analysis recipes for the descriptive sensory pipeline. + +Each recipe is a guided, multi-step workflow the agent follows, chaining the +sensory tools (``sensory_reshape_to_long``, ``sensory_validate_descriptive``, +``sensory_panel_check``, ``sensory_analyze_descriptive``) in the right order. +The recipes are registered into the package-wide catalog on import; see +:mod:`process_improve.recipes` for the framework and the general +``select_analysis_recipe`` tool. +""" + +from __future__ import annotations + +from process_improve.recipes import AnalysisRecipe, RecipeStep, register_recipe + +_DOMAIN = "sensory" + + +_INTAKE = AnalysisRecipe( + key="sensory_intake", + title="Sensory data intake: organise and check a panel file", + summary=( + "Take a freshly received descriptive-panel spreadsheet from raw rows to a validated, canonical " + "long table ready for analysis. Resolve the layout and column roles, reshape to the " + "descriptive_long schema with round-trip checks, then validate the schema, score range and panel " + "balance. Use this whenever new panel data arrives and needs organising and checking." + ), + domain=_DOMAIN, + cue_phrases=[ + "panel file", + "panel data", + "load panel", + "import panel", + "ingest", + "raw panel", + "new sensory data", + "received data", + "got a spreadsheet", + "wide panel", + "reshape", + "organise the data", + "organize the data", + "tidy the data", + "check the data", + "clean the file", + "descriptive panel", + ], + inputs_needed=[ + "the parsed spreadsheet as rows (the front end or a code sandbox reads the file first; these " + "recipes do not read files themselves)", + "which columns hold the panelist, product, attribute, replicate, session and score", + "the layout: one column per attribute, one column per product, or already one row per score", + "the valid score range if known (for example 0 to 10)", + ], + stages=[ + RecipeStep( + order=1, + directive=( + "Inspect the parsed rows (header names and a few sample rows) to decide the layout and the " + "column roles. Choose 'wide_by_attribute' (one column per attribute), 'wide_by_product' " + "(one column per product plus an attribute-label column), or 'long' (already one row per " + "score). Note any nuisance columns (a site or batch code) to ignore." + ), + ), + RecipeStep( + order=2, + directive=( + "Reshape to the canonical descriptive_long schema with sensory_reshape_to_long, passing the " + "explicit column mapping and any nuisance columns to ignore. Confirm checks.ok is true (the " + "grand, per-attribute and per-panelist means and the cell count are preserved). If it is " + "false the mapping is wrong: fix the column roles and rerun rather than proceeding." + ), + tools=["sensory_reshape_to_long"], + arg_hints={ + "layout": "", + "panelist_id": "", + "product": "", + "ignore": "", + }, + ), + RecipeStep( + order=3, + directive=( + "Validate the reshaped table (and the product-covariate table if one was supplied) with " + "sensory_validate_descriptive. Read ok, warnings, errors, stats and content_hash. Stop and " + "report the errors if ok is false; warnings (such as panel-imbalance notes) can be carried " + "forward." + ), + tools=["sensory_validate_descriptive"], + arg_hints={ + "mode": "observational", + "score_min": "", + "score_max": "", + }, + ), + RecipeStep( + order=4, + directive=( + "Summarise for the user: counts of panelists, products, attributes and replicates, the " + "score range, any balance warnings, and the content hash that identifies this dataset. The " + "validated canonical descriptive_long table is the artifact the panel-processing recipe " + "consumes next." + ), + ), + ], +) + + +_PANEL_PROCESSING = AnalysisRecipe( + key="sensory_panel_processing", + title="Panel consistency and scale-use correction", + summary=( + "Check whether the panel is consistent and how each assessor uses the scale, explain the findings " + "in plain language, and produce a corrected canonical table ready for relating to covariates. " + "Covers the per-panelist scorecard, the Mixed Assessor Model scaling coefficient (beta), and " + "rescaling the panel onto a common scale. Use this after intake and before relating." + ), + domain=_DOMAIN, + cue_phrases=[ + "panel consistency", + "panel performance", + "panel check", + "panelist", + "assessor", + "consistent", + "agreement", + "scale usage", + "scale use", + "using the scale", + "beta", + "scaling coefficient", + "harmonise the panel", + "harmonize the panel", + "align the panel", + "correct the panel", + "process the panel", + "ready for analysis", + ], + inputs_needed=[ + "a validated canonical descriptive_long table (from the intake recipe)", + "a decision on whether to rescale every panelist, drop anomalous panelists, or both", + ], + stages=[ + RecipeStep( + order=1, + directive=( + "Run sensory_panel_check on the validated panel (no covariates are needed). Read the " + "scorecard (discrimination, agreement, scale_shift, scale_spread, drift), the flagged " + "panelists and their reasons, and the Mixed Assessor Model results: each panelist's scaling " + "coefficient beta per attribute, and the MAM versus classical product-effect F-tests." + ), + tools=["sensory_panel_check"], + ), + RecipeStep( + order=2, + directive=( + "Interpret the numbers for a non-statistician. beta near 1 means the panelist uses the scale " + "like the rest of the panel; beta below 1 means they compress it (scores bunched together); " + "beta above 1 means they stretch it. A large offset means they sit consistently high or low. " + "Low agreement means their ranking of the products does not track the panel, which is " + "genuine disagreement rather than a scale habit. A panelist is flagged only when it is both " + "an outlier and genuinely poor on agreement or discrimination. When the MAM product F-test " + "is larger than the classical one, removing scale-use differences makes the products " + "separate more clearly." + ), + ), + RecipeStep( + order=3, + directive=( + "Decide the correction and produce the corrected canonical table. Call sensory_panel_check " + "with align=true to rescale every panelist onto a common scale (a location lever removes " + "their offset, a scale lever divides by beta); the returned aligned_panel is the new " + "canonical descriptive_long table, with scale-use artefacts removed but genuine " + "disagreement preserved. A panelist who truly disagrees (low agreement, not just scaling) is " + "better dropped than rescaled; aligning and dropping can be combined." + ), + tools=["sensory_panel_check"], + arg_hints={"align": "true", "align_method": "both"}, + ), + RecipeStep( + order=4, + directive=( + "Hand off: the corrected canonical descriptive_long table (aligned, with any genuine " + "disagreers earmarked for dropping) plus the recorded correction decision are the inputs to " + "the relate-to-covariates recipe. Report what was changed and why, in plain language." + ), + ), + ], +) + + +_RELATE_COVARIATES = AnalysisRecipe( + key="sensory_relate_covariates", + title="Relate panel attributes to product covariates", + summary=( + "Relate each sensory attribute to measured product covariates and separate genuine drivers from " + "proxies and chance correlations. Runs PLS plus per-pair correlations and the cross-validated " + "discriminator (out-of-sample Q-squared gate, selectivity ratio, collinear clustering), then " + "interprets which descriptors really carry signal. Use this after the panel has been processed." + ), + domain=_DOMAIN, + cue_phrases=[ + "relate to", + "relate the", + "relate attributes", + "covariate", + "what drives", + "drivers of", + "which measurements", + "instrumental", + "descriptors", + "chemistry", + "correlate attributes", + "predict liking", + "explain sweetness", + "link sensory", + "selectivity", + "confound", + "spurious", + "causal", + "root cause", + ], + inputs_needed=[ + "the corrected canonical panel from the panel-processing recipe", + "a product-covariate table: one row per product with the measured numeric descriptors", + "mode is observational (designed / DoE relate is not implemented yet)", + ], + stages=[ + RecipeStep( + order=1, + directive=( + "Assemble the product-covariate table: one row per product, numeric descriptors only (drop " + "id and non-numeric columns). Confirm every product in the panel has covariates." + ), + ), + RecipeStep( + order=2, + directive=( + "Run sensory_analyze_descriptive on the corrected panel and the covariate table. Set " + "correction to what was already applied in the processing recipe (use 'none' if the panel " + "is already aligned) and drop the genuine disagreers identified there. This one call " + "validates, relates the attributes to the descriptors with PLS and per-pair correlations, " + "and runs the cross-validated discriminator." + ), + tools=["sensory_analyze_descriptive"], + arg_hints={ + "mode": "observational", + "correction": "", + "drop_flagged": "", + }, + ), + RecipeStep( + order=3, + directive=( + "Read the marginal associations in relate.associations: each (attribute, descriptor) " + "correlation with a Benjamini-Hochberg q_value and a significant flag. These flag every " + "descriptor that correlates in this sample, genuine drivers, proxies and coincidences " + "alike." + ), + ), + RecipeStep( + order=4, + directive=( + "Read the discriminator in relate.discriminator: per_attribute gives the cross-validated " + "q2_cv and a predictable flag; descriptors gives, per (attribute, descriptor), the " + "selectivity_ratio, a permutation q_value, a discriminator_significant flag, and a " + "cluster_id." + ), + ), + RecipeStep( + order=5, + directive=( + "Interpret for a non-statistician. An association that survives the discriminator (the " + "attribute is predictable out of sample and the descriptor is selectivity-significant) " + "carries real, transferable predictive signal. An association that the marginal test flags " + "but the discriminator demotes is most likely a coincidence of this sample. Descriptors " + "that share a cluster_id carry the same information and cannot be told apart, so a " + "significant one may be a proxy riding on the true driver." + ), + ), + RecipeStep( + order=6, + directive=( + "State the limit plainly. From one observational panel you cannot prove causation or rank " + "descriptors within a collinear cluster; separating a genuine driver from a collinear proxy " + "needs an external dataset that breaks the collinearity, a designed experiment, or " + "mechanistic knowledge. Report the drivers grouped by cluster, with a genuine / proxy / " + "coincidence verdict per descriptor." + ), + ), + ], +) + + +_VISUALISATION = AnalysisRecipe( + key="sensory_visualisation", + title="Sensory visualisation (planned)", + summary=( + "A planned workflow to turn relate and discriminator output into sensory maps, driver biplots and " + "small-multiple panels. Not yet available; this entry advertises the workflow so the agent can tell " + "the user it is coming rather than improvising plots." + ), + domain=_DOMAIN, + cue_phrases=[ + "visualise sensory", + "visualize sensory", + "sensory map", + "perceptual map", + "biplot", + "loadings plot", + "spider plot", + "plot the drivers", + "chart the panel", + ], + inputs_needed=[ + "not yet available - this visualisation workflow is planned for a later release", + ], + stages=[], + status="planned", +) + + +SENSORY_RECIPES: list[AnalysisRecipe] = [ + _INTAKE, + _PANEL_PROCESSING, + _RELATE_COVARIATES, + _VISUALISATION, +] + +for _recipe in SENSORY_RECIPES: + register_recipe(_recipe) + + +__all__ = ["SENSORY_RECIPES"] diff --git a/src/process_improve/tool_spec.py b/src/process_improve/tool_spec.py index 665fa31..79ec24e 100644 --- a/src/process_improve/tool_spec.py +++ b/src/process_improve/tool_spec.py @@ -271,6 +271,7 @@ def discover_tools() -> None: "process_improve.visualization.tools", "process_improve.simulation.tools", "process_improve.sensory.tools", + "process_improve.recipes", ]: _import_tool_module(module) diff --git a/tests/test_recipes.py b/tests/test_recipes.py new file mode 100644 index 0000000..549a031 --- /dev/null +++ b/tests/test_recipes.py @@ -0,0 +1,134 @@ +"""Tests for the reusable analysis-recipe framework and the sensory recipes.""" + +from __future__ import annotations + +import pytest + +from process_improve.recipes import ( + AnalysisRecipe, + RecipeStep, + get_recipe, + list_recipes, + register_recipe, + select_recipe, +) +from process_improve.tool_spec import discover_tools, execute_tool_call, get_tool_specs + + +def test_to_payload_shape_and_conditional_keys(): + recipe = AnalysisRecipe( + key="demo_recipe", + title="Demo", + summary="A demo.", + domain="testing", + cue_phrases=["demo cue"], + inputs_needed=["nothing"], + stages=[ + RecipeStep(order=1, directive="Do a thing.", tools=["some_tool"], arg_hints={"x": "1"}), + RecipeStep(order=2, directive="Interpret it."), # prose-only + ], + ) + payload = recipe.to_payload() + assert payload["recipe_key"] == "demo_recipe" + assert payload["status"] == "available" + assert "note" not in payload # only planned recipes carry a note + first, second = payload["stages"] + assert first["step"] == 1 + assert first["tools"] == ["some_tool"] + assert first["arg_hints"] == {"x": "1"} + # The prose-only step omits the empty tools / arg_hints keys entirely. + assert "tools" not in second + assert "arg_hints" not in second + + +def test_planned_recipe_payload_carries_note(): + planned = AnalysisRecipe( + key="planned_demo", + title="Planned", + summary="Later.", + domain="testing", + cue_phrases=[], + inputs_needed=[], + stages=[], + status="planned", + ) + payload = planned.to_payload() + assert payload["status"] == "planned" + assert payload["stages"] == [] + assert "note" in payload + + +def test_register_recipe_rejects_duplicate_key(): + recipe = AnalysisRecipe( + key="sensory_intake", # already registered by the sensory recipes module + title="x", + summary="x", + domain="testing", + cue_phrases=[], + inputs_needed=[], + stages=[], + ) + list_recipes() # ensure discovery has registered the sensory recipes + with pytest.raises(ValueError, match="already registered"): + register_recipe(recipe) + + +def test_sensory_recipes_are_discovered(): + keys = {r.key for r in list_recipes()} + assert { + "sensory_intake", + "sensory_panel_processing", + "sensory_relate_covariates", + "sensory_visualisation", + } <= keys + assert get_recipe("sensory_visualisation").status == "planned" + + +@pytest.mark.parametrize( + ("query", "expected"), + [ + ("I have a wide panel spreadsheet to load", "sensory_intake"), + ("are my panelists consistent and using the scale the same way", "sensory_panel_processing"), + ("what chemistry drives sweetness", "sensory_relate_covariates"), + ("make me a perceptual map biplot", "sensory_visualisation"), + ], +) +def test_select_recipe_matches(query: str, expected: str): + matched = select_recipe(query) + assert matched is not None + assert matched.key == expected + + +def test_select_recipe_returns_none_for_unrelated_query(): + assert select_recipe("totally unrelated request about widgets") is None + + +def test_recipe_steps_only_reference_registered_tools(): + discover_tools() + tool_names = {spec["name"] for spec in get_tool_specs()} + for recipe in list_recipes(): + for step in recipe.stages: + for tool in step.tools: + assert tool in tool_names, f"{recipe.key} step {step.order} names unknown tool {tool!r}" + + +def test_select_analysis_recipe_tool(): + out = execute_tool_call("select_analysis_recipe", {"query": "what drives liking from the measurements"}) + assert out["ok"] is True + assert out["matched"] is True + assert out["recipe"]["recipe_key"] == "sensory_relate_covariates" + keys = {entry["recipe_key"] for entry in out["available"]} + assert "sensory_intake" in keys + assert "sensory_visualisation" in keys + + miss = execute_tool_call("select_analysis_recipe", {"query": "completely off-topic widget chatter"}) + assert miss["matched"] is False + assert miss["recipe"] is None + assert miss["available"] # the catalogue is always offered + + +def test_select_analysis_recipe_rejects_unknown_kwargs(): + from process_improve.tool_spec import ToolInputInvalidError + + with pytest.raises(ToolInputInvalidError): + execute_tool_call("select_analysis_recipe", {"query": "x", "surprise": True})