Explorar el Código

Trim garden tool surface and update docs/tests

Lukas Goldschmidt hace 1 mes
padre
commit
cbd740ae5a
Se han modificado 7 ficheros con 127 adiciones y 170 borrados
  1. 41 60
      README.md
  2. 1 1
      config.py
  3. 2 63
      src/garden_layer/__init__.py
  4. 1 1
      src/garden_layer/config.py
  5. 45 0
      src/garden_layer/domain_tools.py
  6. 14 0
      test.sh
  7. 23 45
      test_garden_layer.py

+ 41 - 60
README.md

@@ -1,80 +1,61 @@
-# Garden Layer helper module
+# Garden Layer
 
-The garden layer sits on top of `virtuoso_mcp`. It knows your garden-specific URIs (cycles, clone roots, seed products) but delegates every SPARQL/insert/discovery call to the MCP helpers.
+Garden-domain helpers and plugin tools for `virtuoso_mcp`.
 
-## Structure
-
-- `config.py` loads `.env` values and exposes the Garden URIs (seed product, cycle `2026-3`, clone property, etc.).
-- `helpers.py` defines `GardenLayer`, which wraps MCP tools such as `insert_triple`, `traverse_property`, and ontology discovery into domain actions (`add_seedling`, `traverse`).
-
-## Domain helpers
+## Current role
 
-`GardenLayer` now exposes these convenience methods:
+- Provides domain workflows (cycles, clones, seedlings).
+- Keeps domain logic separate from generic Virtuoso MCP tools.
+- Registers `garden_*` plugin tools into `virtuoso_mcp` via `register_layer(...)`.
 
-- `describe_subject(subject_uri, limit=10)` – returns predicates/objects (with labels) for a given node.
-- `path_traverse(subject_uri, property_path, direction="outgoing", limit=10)` – walks a property sequence from the subject and returns each step’s bindings.
-- `property_usage_statistics(property_uri, examples_limit=5)` – reports how often a property is used and includes sample subjects/objects.
-- `batch_insert(ttl, graph=None)` – pushes a TTL snippet (or multiple triples via TTL) into the chosen graph in a single request.
-- `load_examples(files=None, graph=None)` – loads TTL fixtures from `garden_layer/examples/` via the `garden_load_examples` tool (guarded by `MCP_ALLOW_EXAMPLE_LOAD`).
+## Current garden tool surface (trimmed)
 
-These helpers keep domain code focused on planned workflows while still leveraging the generic MCP toolset.
+Only domain-unique prefixed tools are exposed:
 
-## Architectural split
+- `garden_add_seedling`
+- `garden_cycle_plants`
+- `garden_latest_cycle_by_dates`
+- `garden_clone_to`
+- `garden_cycle_list_detailed`
+- `garden_reassign_cycle`
 
-- `virtuoso_mcp` owns **generic** capabilities: query guardrails, traversal, ontology/class/property discovery.
-- `garden_layer` owns **domain workflows**: breeding lifecycle helpers, documentation flows, trait-specific routines.
+Redundant prefixed aliases of generic tools were intentionally removed.
 
-This split allows additional specialized layers to reuse the same generic ontology tooling without copy/paste.
+## Runtime coupling
 
-### Example fixtures
+- Default endpoint for direct helper calls: `GARDEN_MCP_URL=http://127.0.0.1:8501/rpc`
+- This uses the compatibility router in `virtuoso_mcp` for simple tool invocation during migration.
 
-The domain-specific `examples/` directory now lives inside this package. The MCP exposes a `garden_load_examples` helper so you can insert those fixtures through `/mcp` (just set `MCP_ALLOW_EXAMPLE_LOAD=true` first). The loader calls `garden_layer.examples/*.ttl` and inserts them into the `GARDEN_EXAMPLE_GRAPH` (defaulting to `GRAPH`), which is useful for reproducible tests or for warming up the data set before running higher-level helpers.
-
-## Example
+## Structure
 
-```python
-from garden_layer import GardenLayer
-from garden_layer.config import SEED_PRODUCT, CYCLE_2026_3
+- `src/garden_layer/domain_tools.py` — domain-native logic
+- `src/garden_layer/__init__.py` — plugin registration and schemas
+- `src/garden_layer/helpers.py` — convenience class for direct usage
+- `test_garden_layer.py` — integration-style tests against running `virtuoso_mcp`
 
-garden = GardenLayer()
-garden.add_seedling(
-    plant_uri="http://world.eu.org/example1#Plant_cookie_kerosene_2027",
-    seed_product_uri=SEED_PRODUCT,
-    cycle_uri=CYCLE_2026_3,
-    label="Cookie x Kerosene 2027",
-)
-``` 
+## Tests
 
-## Testing
+```bash
+./test.sh
+```
 
-`garden_layer/test_garden_layer.py` exercises the garden helpers (`traverse`, `describe_subject`, `path_traverse`, `property_usage_statistics`, and `batch_insert`) against a running MCP server. The suite now also posts directly to the `garden_*` MCP tools (e.g., `garden_describe_subject` and `garden_property_usage_statistics`) to confirm the server endpoint is registered and returns expected bindings. Start the MCP service (`./virtuoso_mcp/run.sh` or `./restart.sh`) before running the tests and invoke them with `python3 -m pytest garden_layer/test_garden_layer.py`. If `pytest` is not installed, install it inside a virtual environment (e.g., `python3 -m venv .env && .env/bin/pip install pytest`).
+`test.sh` is self-contained:
+- activates local `.venv` when present
+- installs missing test deps if needed
+- runs pytest with `PYTHONPATH=src`
 
-## Installation & usage
+Expected result depends on fixture availability; current baseline is passing core domain/plugin checks with optional data-dependent skips.
 
-1. Publish the `garden_layer` repo to your private git server (e.g., `https://repo.home.world.eu.org/lucky/garden_layer.git`).
-2. Install it inside the MCP host environment:
-   ```bash
-   pip install --upgrade git+https://repo.home.world.eu.org/lucky/garden_layer.git
-   ```
-   The package can be uninstalled later with `pip uninstall garden-layer`.
-3. Restart the MCP server so it picks up the new helpers (our `restart.sh` already calls `killserver.sh` before `run.sh`):
-   ```bash
-   cd /home/lucky/.openclaw/workspace/virtuoso_mcp
-   ./restart.sh
-   ```
-4. Re-run the garden-layer suite with `GARDEN_LAYER_VENV` pointing at whatever virtualenv you want to use (our example venv lives at `garden_layer/.venv`):
-   ```bash
-   cd /home/lucky/.openclaw/workspace/garden_layer
-   GARDEN_LAYER_VENV="$PWD/.venv" ./test.sh
-   ```
+## Install (plugin host)
 
-Once the package is installed you can import `garden_layer.GardenLayer` from your own MCP host code (or use it in agents) to orchestrate the domain helpers; building additional `/mcp` endpoints that simply call `GardenLayer` keeps your plugins reusable and the core MCP server generic.
+```bash
+pip install --upgrade git+https://repo.home.world.eu.org/lucky/garden_layer.git
+```
 
-## MCP tools exposed by the garden layer
+Then in `virtuoso_mcp` set:
 
-In addition to the garden layer’s helper-oriented API, the MCP server registers these `garden_*` tools via `DOMAIN_LAYERS`:
+```bash
+DOMAIN_LAYERS=garden_layer.plugin
+```
 
-- `garden_latest_cycle_by_dates` — resolve the latest `ProductionCycle` by `productionStartDate` / `productionEndDate`
-- `garden_cycle_plants` — list plants assigned to a cycle via `inCycle`
-- `garden_cycle_list_detailed` — detailed cycle listing including clone lineage, strain label/description, gender, and clone siblings
-- `garden_clone_to` — create a new plant clone by label, copy mother triples (except label/inCycle/cloneOf), set `cloneOf` + `inCycle`
+and restart the server.

+ 1 - 1
config.py

@@ -6,7 +6,7 @@ from dotenv import load_dotenv
 BASE_DIR = Path(__file__).resolve().parent
 load_dotenv(BASE_DIR.parent / ".env")
 
-MCP_URL = os.getenv("GARDEN_MCP_URL", "http://127.0.0.1:8501/mcp")
+MCP_URL = os.getenv("GARDEN_MCP_URL", "http://127.0.0.1:8501/rpc")
 GRAPH = os.getenv("GARDEN_GRAPH", "http://world.eu.org/example1")
 CLONE_OF = "http://world.eu.org/cannabis-breeding#cloneOf"
 IN_CYCLE = "http://world.eu.org/cannabis-breeding#inCycle"

+ 2 - 63
src/garden_layer/__init__.py

@@ -2,14 +2,12 @@ from typing import Callable, Dict
 
 from .helpers import GardenLayer
 from .domain_tools import (
+    add_seedling,
     cycle_plants,
-    load_examples,
     reassign_cycle,
     latest_cycle_by_dates,
     clone_to,
     cycle_list_detailed,
-    describe_subject,
-    property_usage_statistics,
 )
 
 __all__ = ["GardenLayer", "register_layer"]
@@ -23,18 +21,11 @@ def register_layer(
     tools: Dict[str, Callable[[Dict[str, object]], object]],
     tool_schemas: Dict[str, object] = None,
 ) -> None:
-    garden = GardenLayer()
-    tools["garden_add_seedling"] = _make_tool(garden.add_seedling)
-    tools["garden_describe_subject"] = _make_tool(describe_subject)
-    tools["garden_path_traverse"] = _make_tool(garden.path_traverse)
-    tools["garden_property_usage_statistics"] = _make_tool(property_usage_statistics)
-    tools["garden_batch_insert"] = _make_tool(garden.batch_insert)
+    tools["garden_add_seedling"] = _make_tool(add_seedling)
     tools["garden_cycle_plants"] = _make_tool(cycle_plants)
     tools["garden_latest_cycle_by_dates"] = _make_tool(latest_cycle_by_dates)
     tools["garden_clone_to"] = _make_tool(clone_to)
     tools["garden_cycle_list_detailed"] = _make_tool(cycle_list_detailed)
-    tools["garden_load_examples"] = _make_tool(load_examples)
-
     tools["garden_reassign_cycle"] = _make_tool(reassign_cycle)
 
     # Register MCP tool input schemas (best-effort). The MCP server will
@@ -96,58 +87,6 @@ def register_layer(
         ["plant_uri", "seed_product_uri", "cycle_uri", "label"],
     )
 
-    tool_schemas["garden_describe_subject"] = _obj_schema(
-        {
-            "subject_uri": {"type": "string"},
-            "limit": {"type": "integer", "minimum": 1, "maximum": 500},
-        },
-        ["subject_uri"],
-    )
-
-    tool_schemas["garden_path_traverse"] = _obj_schema(
-        {
-            "subject_uri": {"type": "string"},
-            "property_path": {
-                "type": "string",
-                "description": "Comma-separated predicate URIs",
-            },
-            "properties": {
-                "type": "array",
-                "items": {"type": "string"},
-            },
-            "direction": {"type": "string", "enum": ["outgoing", "incoming"]},
-            "limit": {"type": "integer", "minimum": 1, "maximum": 500},
-        },
-        ["subject_uri"],
-    )
-
-    tool_schemas["garden_property_usage_statistics"] = _obj_schema(
-        {
-            "property_uri": {"type": "string"},
-            "examples_limit": {"type": "integer", "minimum": 1, "maximum": 500},
-        },
-        ["property_uri"],
-    )
-
-    tool_schemas["garden_batch_insert"] = _obj_schema(
-        {
-            "ttl": {"type": "string"},
-            "graph": {"type": "string"},
-        },
-        ["ttl"],
-    )
-
-    tool_schemas["garden_load_examples"] = _obj_schema(
-        {
-            "files": {
-                "type": "array",
-                "items": {"type": "string"},
-            },
-            "graph": {"type": "string"},
-        },
-        [],
-    )
-
     tool_schemas["garden_reassign_cycle"] = _obj_schema(
         {
             "subject": {"type": "string"},

+ 1 - 1
src/garden_layer/config.py

@@ -6,7 +6,7 @@ from dotenv import load_dotenv
 BASE_DIR = Path(__file__).resolve().parent
 load_dotenv(BASE_DIR.parent / ".env")
 
-MCP_URL = os.getenv("GARDEN_MCP_URL", "http://127.0.0.1:8501/mcp")
+MCP_URL = os.getenv("GARDEN_MCP_URL", "http://127.0.0.1:8501/rpc")
 GRAPH = os.getenv("GARDEN_GRAPH", "http://world.eu.org/example1")
 CLONE_OF = "http://world.eu.org/cannabis-breeding#cloneOf"
 IN_CYCLE = "http://world.eu.org/cannabis-breeding#inCycle"

+ 45 - 0
src/garden_layer/domain_tools.py

@@ -317,6 +317,51 @@ def load_examples(files: Optional[list[str]] = None, graph: Optional[str] = None
     return {"loaded": results}
 
 
+def add_seedling(
+    plant_uri: str,
+    seed_product_uri: str,
+    cycle_uri: str,
+    label: str,
+    strain_uri: str = "http://world.eu.org/example1#Strain_lucky_experimental_line",
+    graph: str = GRAPH,
+) -> Dict[str, Any]:
+    """Create a seedling individual with a standard triple bundle."""
+    if not plant_uri or not seed_product_uri or not cycle_uri or not label:
+        raise ValueError("Provide plant_uri, seed_product_uri, cycle_uri, and label")
+
+    triples = [
+        ("http://www.w3.org/1999/02/22-rdf-syntax-ns#type", "http://world.eu.org/cannabis-breeding#IndividualPlant", "uri"),
+        ("http://www.w3.org/2000/01/rdf-schema#label", label, "literal"),
+        ("http://world.eu.org/cannabis-breeding#inCycle", cycle_uri, "uri"),
+        ("http://world.eu.org/cannabis-breeding#grownFromSeedProduct", seed_product_uri, "uri"),
+        ("http://world.eu.org/cannabis-breeding#belongsToStrain", strain_uri, "uri"),
+    ]
+
+    lines = []
+    for predicate, obj, obj_type in triples:
+        if obj_type == "uri":
+            obj_value = f"<{obj}>"
+        else:
+            escaped = str(obj).replace("\\", "\\\\").replace('"', '\\"')
+            obj_value = f'"{escaped}"'
+        lines.append(f"<{plant_uri}> <{predicate}> {obj_value} .")
+
+    update_query = f"""
+    INSERT DATA {{
+      GRAPH <{graph}> {{
+        {' '.join(lines)}
+      }}
+    }}
+    """
+    run_sparql_update(update_query)
+    return {
+        "status": "ok",
+        "plant_uri": plant_uri,
+        "graph": graph,
+        "triples_inserted": len(triples),
+    }
+
+
 def reassign_cycle(
     subject: str,
     new_cycle: str,

+ 14 - 0
test.sh

@@ -1,6 +1,15 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+# Prefer the project-local venv so tests are independent of caller shell state.
+if [[ -f ".venv/bin/activate" ]]; then
+  # shellcheck disable=SC1091
+  source .venv/bin/activate
+fi
+
 if [[ -n "${GARDEN_LAYER_VENV:-}" ]]; then
   if [[ -f "$GARDEN_LAYER_VENV/bin/activate" ]]; then
     # shellcheck disable=SC1091
@@ -12,4 +21,9 @@ fi
 
 export PYTHONPATH="$PWD/src${PYTHONPATH:+:$PYTHONPATH}"
 export MCP_ALLOW_EXAMPLE_LOAD="${MCP_ALLOW_EXAMPLE_LOAD:-true}"
+
+if ! python3 -c "import requests, pytest" >/dev/null 2>&1; then
+  python3 -m pip install -r requirements.txt >/dev/null
+fi
+
 python3 -m pytest -v -s test_garden_layer.py

+ 23 - 45
test_garden_layer.py

@@ -106,28 +106,6 @@ def call_mcp_tool(tool_name: str, payload: Dict[str, Any]) -> Any:
     return body["result"]
 
 
-def test_mcp_garden_describe_subject():
-    result = call_mcp_tool(
-        "garden_describe_subject",
-        {"subject_uri": KEROSENE_ROOT, "limit": 1},
-    )
-    bindings = result.get("results", {}).get("bindings", [])
-    assert bindings, "garden_describe_subject should return bindings"
-    print("garden_describe_subject returned", len(bindings), "bindings via /mcp")
-
-
-def test_mcp_garden_property_usage_statistics():
-    result = call_mcp_tool(
-        "garden_property_usage_statistics",
-        {"property_uri": CLONE_OF, "examples_limit": 1},
-    )
-    count_bindings = result.get("count", {}).get("results", {}).get("bindings", [])
-    assert count_bindings, "Expected usage count bindings from the garden tool"
-    examples = result.get("examples", {}).get("results", {}).get("bindings", [])
-    assert isinstance(examples, list)
-    print("garden_property_usage_statistics sample bindings:", len(examples))
-
-
 def test_mcp_garden_cycle_plants():
     result = call_mcp_tool(
         "garden_cycle_plants",
@@ -166,34 +144,34 @@ def test_mcp_garden_cycle_list_detailed_has_strain_info():
     assert any_strain_desc or any_siblings, "Expected strainDesc and/or cloneSiblings to be present"
 
 
-def test_mcp_input_schema_announced_for_garden_tools():
-    """OpenClaw MCP compliance: garden_* tools should expose inputSchema via tools/list."""
-    response = requests.post(
-        MCP_URL,
-        json={"jsonrpc": "2.0", "id": 999, "method": "tools/list", "params": {}},
-        timeout=10,
-    )
+def test_tool_surface_trimmed_and_domain_focused():
+    """Ensure garden layer exposes only domain-unique prefixed tools."""
+    health_url = MCP_URL.rsplit("/", 1)[0] + "/health"
+    response = requests.get(health_url, timeout=10)
     response.raise_for_status()
     body = response.json()
-    assert body.get("result"), f"Expected tools/list result, got: {body}"
-
-    tools = body["result"].get("tools") or []
-    tool_by_name = {t.get("name"): t for t in tools if isinstance(t, dict)}
+    tools = set(body.get("tools", []))
 
-    # At least validate a couple of the “new” tools.
-    for name in [
-        "garden_clone_to",
+    expected_present = {
+        "garden_add_seedling",
         "garden_cycle_plants",
+        "garden_latest_cycle_by_dates",
+        "garden_clone_to",
         "garden_cycle_list_detailed",
-    ]:
-        assert name in tool_by_name, f"Missing tool {name} in tools/list"
-        input_schema = tool_by_name[name].get("inputSchema")
-        assert isinstance(input_schema, dict), f"{name}.inputSchema missing/invalid"
-
-    # Specifically: garden_cycle_plants should allow missing cycle_uri.
-    cycle_plants_schema = tool_by_name["garden_cycle_plants"].get("inputSchema", {})
-    props = cycle_plants_schema.get("properties", {})
-    assert "cycle_uri" in props, "garden_cycle_plants inputSchema must define cycle_uri"
+        "garden_reassign_cycle",
+    }
+    expected_absent = {
+        "garden_describe_subject",
+        "garden_path_traverse",
+        "garden_property_usage_statistics",
+        "garden_batch_insert",
+        "garden_load_examples",
+    }
+
+    missing = expected_present - tools
+    assert not missing, f"Missing expected garden tools: {sorted(missing)}"
+    leaked = expected_absent & tools
+    assert not leaked, f"Found deprecated/redundant garden tools: {sorted(leaked)}"
 
 
 def test_load_examples_fixture(garden_layer):