Преглед изворни кода

docs/tools: add garden_cycle_list_detailed, garden_clone_to, and document tools

Lukas Goldschmidt пре 1 месец
родитељ
комит
1e350c4241
3 измењених фајлова са 225 додато и 1 уклоњено
  1. 9 0
      README.md
  2. 4 1
      src/garden_layer/__init__.py
  3. 212 0
      src/garden_layer/domain_tools.py

+ 9 - 0
README.md

@@ -69,3 +69,12 @@ garden.add_seedling(
    ```
 
 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.
+
+## MCP tools exposed by the garden layer
+
+In addition to the garden layer’s helper-oriented API, the MCP server registers these `garden_*` tools via `DOMAIN_LAYERS`:
+
+- `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`

+ 4 - 1
src/garden_layer/__init__.py

@@ -1,7 +1,7 @@
 from typing import Callable, Dict
 
 from .helpers import GardenLayer
-from .domain_tools import cycle_plants, load_examples, reassign_cycle
+from .domain_tools import cycle_plants, load_examples, reassign_cycle, latest_cycle_by_dates, clone_to, cycle_list_detailed
 
 __all__ = ["GardenLayer", "register_layer"]
 
@@ -18,6 +18,9 @@ def register_layer(tools: Dict[str, Callable[[Dict[str, object]], object]]) -> N
     tools["garden_property_usage_statistics"] = _make_tool(garden.property_usage_statistics)
     tools["garden_batch_insert"] = _make_tool(garden.batch_insert)
     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)

+ 212 - 0
src/garden_layer/domain_tools.py

@@ -1,12 +1,18 @@
 from pathlib import Path
 from typing import Any, Dict, Optional
 import os
+import uuid
 
 from fastapi import HTTPException
 from virtuoso_mcp import run_sparql, run_sparql_update, SPARQL_MAX_LIMIT, ttl_to_sparql_insert
 
 from .config import BASE_DIR, CLONE_OF, GRAPH, IN_CYCLE
 
+# Date predicates used by the garden example export fixtures.
+PRODUCTION_START_DATE = "http://world.eu.org/cannabis-breeding#productionStartDate"
+PRODUCTION_END_DATE = "http://world.eu.org/cannabis-breeding#productionEndDate"
+PRODUCTION_CYCLE_CLASS = "http://world.eu.org/cannabis-breeding#ProductionCycle"
+
 
 _ALLOW_EXAMPLE_LOAD = os.getenv("MCP_ALLOW_EXAMPLE_LOAD", "false").lower() == "true"
 _EXAMPLES_DIR = BASE_DIR.parent.parent / "examples"
@@ -36,6 +42,212 @@ def cycle_plants(cycle_uri: str, limit: Optional[int] = 50) -> Dict[str, Any]:
     return run_sparql(query)
 
 
+def latest_cycle_by_dates(limit: Optional[int] = 1) -> Dict[str, Any]:
+    """Pick the latest cycle using available start/end dates.
+
+    Strategy:
+    - Prefer ordering by productionStartDate desc (latest start).
+    - Tie-break by productionEndDate desc.
+    """
+    bounded_limit = _safe_limit(limit, default=1)
+    query = f"""
+    SELECT ?cycle ?label ?start ?end WHERE {{
+        ?cycle rdf:type <{PRODUCTION_CYCLE_CLASS}> .
+        OPTIONAL {{ ?cycle rdfs:label ?label . }}
+        OPTIONAL {{ ?cycle <{PRODUCTION_START_DATE}> ?start . }}
+        OPTIONAL {{ ?cycle <{PRODUCTION_END_DATE}> ?end . }}
+    }}
+    ORDER BY DESC(?start) DESC(?end)
+    LIMIT {bounded_limit}
+    """
+    return run_sparql(query)
+
+
+def _format_obj_sparql(binding: Dict[str, Any]) -> str:
+    btype = binding.get("type")
+    val = binding.get("value")
+    if btype == "uri":
+        return f"<{val}>"
+    if btype == "literal":
+        # Virtuoso JSON uses plain xsd-less literals unless datatype/lang is present.
+        # If your results include datatype/lang, we should extend this.
+        escaped = str(val).replace('"', '\\"').replace("\\", "\\\\")
+        return f"\"{escaped}\""
+    # Fallback: stringify as literal.
+    escaped = str(val).replace('"', '\\"').replace("\\", "\\\\")
+    return f"\"{escaped}\""
+
+
+def clone_to(
+    mother_label: str,
+    new_label: str,
+    target_cycle_uri: Optional[str] = None,
+    limit: Optional[int] = None,
+) -> Dict[str, Any]:
+    """Clone a plant by inheriting all triples from the mother plant.
+
+    - Resolves mother plant by exact rdfs:label match.
+    - Copies all predicate/object triples for the mother, excluding:
+      - rdfs:label
+      - cb:inCycle
+      - cb:cloneOf (will be set to the mother)
+    - Creates a new plant URI and inserts:
+      - rdf:type from the mother (if present)
+      - rdfs:label = new_label
+      - cb:cloneOf = mother
+      - cb:inCycle = target_cycle_uri (or mother inCycle if omitted)
+    """
+    if not mother_label:
+        raise ValueError("Missing 'mother_label'")
+    if not new_label:
+        raise ValueError("Missing 'new_label'")
+
+    bounded = _safe_limit(limit, default=50) if limit is not None else None
+    # 1) Resolve mother
+    mother_query = f"""
+    SELECT ?mother WHERE {{
+        ?mother rdfs:label ?label .
+        FILTER(STR(?label) = "{mother_label}")
+    }} LIMIT 2
+    """
+    mother_res = run_sparql(mother_query)
+    bindings = mother_res.get("results", {}).get("bindings", [])
+    if not bindings:
+        raise ValueError(f"No plant found with label: {mother_label}")
+    if len(bindings) > 1:
+        # Deterministic selection for now: take the first.
+        pass
+    mother_uri = bindings[0]["mother"]["value"]
+
+    # 2) Determine target cycle: provided or inherited from mother
+    cycle_uri = target_cycle_uri
+    if not cycle_uri:
+        cycle_q = f"""
+        SELECT ?c WHERE {{ <{mother_uri}> <{IN_CYCLE}> ?c . }} LIMIT 1
+        """
+        cycle_res = run_sparql(cycle_q)
+        cycle_bindings = cycle_res.get("results", {}).get("bindings", [])
+        if not cycle_bindings:
+            raise ValueError("Mother plant has no cb:inCycle; provide target_cycle_uri")
+        cycle_uri = cycle_bindings[0]["c"]["value"]
+
+    # 3) Collect inheritance triples from mother
+    triples_q = f"""
+    SELECT ?p ?o WHERE {{
+        <{mother_uri}> ?p ?o .
+        FILTER(?p NOT IN (rdfs:label, <{IN_CYCLE}>, <{CLONE_OF}>))
+    }}
+    """
+    triples_res = run_sparql(triples_q)
+    t_bindings = triples_res.get("results", {}).get("bindings", [])
+    # Optional cap
+    if bounded is not None and len(t_bindings) > bounded:
+        t_bindings = t_bindings[:bounded]
+
+    # 4) Create new plant URI
+    # Use same graph base as your example data (GRAPH env / GRAPH constant likely includes it).
+    plant_uri = f"{GRAPH}#Plant_{str(uuid.uuid4())[:8]}"
+
+    # 5) Build INSERT DATA
+    insert_lines = [
+        f"<{plant_uri}> <{IN_CYCLE}> <{cycle_uri}> .",
+        f"<{plant_uri}> <{CLONE_OF}> <{mother_uri}> .",
+        f"<{plant_uri}> rdfs:label \"{new_label.replace('\\', '\\\\').replace('"', '\\"')}\" .",
+    ]
+
+    for tb in t_bindings:
+        p = tb["p"]["value"]
+        o = tb["o"]
+        insert_lines.append(f"<{plant_uri}> <{p}> {_format_obj_sparql(o)} .")
+
+    update_ntriples = "\n      ".join(insert_lines)
+    sparql_update = f"""
+    INSERT DATA {{
+      GRAPH <{GRAPH}> {{
+        {update_ntriples}
+      }}
+    }}
+    """
+    run_sparql_update(sparql_update)
+
+    return {
+        "created": {
+            "plant_uri": plant_uri,
+            "label": new_label,
+            "mother_uri": mother_uri,
+            "target_cycle_uri": cycle_uri,
+        },
+        "copied_triple_count": len(t_bindings),
+    }
+
+
+def cycle_list_detailed(
+    target_cycle_uri: Optional[str] = None,
+    limit: Optional[int] = 50,
+) -> Dict[str, Any]:
+    """Detailed listing of plants in a cycle.
+
+    Matches the working curl/SPARQL behavior:
+      plantLabel, cloneMotherLabel, strainLabel, strainDesc, gender, cloneSiblings
+    """
+
+    bounded_limit = _safe_limit(limit, default=200)
+
+    belongsToStrain = "http://world.eu.org/cannabis-breeding#belongsToStrain"
+    hasGender = "http://world.eu.org/cannabis-breeding#hasGender"
+    description_pred = "http://purl.org/dc/elements/1.1/description"
+
+    if target_cycle_uri:
+        cycle_clause = f"VALUES ?cycle {{ <{target_cycle_uri}> }}"
+    else:
+        cycle_clause = f"""
+        {{
+          SELECT ?cycle WHERE {{
+            ?cycle rdf:type <{PRODUCTION_CYCLE_CLASS}> .
+            OPTIONAL {{ ?cycle <{PRODUCTION_START_DATE}> ?start . }}
+            OPTIONAL {{ ?cycle <{PRODUCTION_END_DATE}> ?end . }}
+          }}
+          ORDER BY DESC(?start) DESC(?end)
+          LIMIT 1
+        }}
+        """
+
+    query = f"""
+    SELECT ?plant ?plantLabel ?cloneMother ?cloneMotherLabel ?strain ?strainLabel ?strainDesc ?gender
+           (GROUP_CONCAT(DISTINCT ?sibLabel; separator=" | ") AS ?cloneSiblings)
+    WHERE {{
+      {cycle_clause}
+      ?plant <{IN_CYCLE}> ?cycle .
+
+      OPTIONAL {{ ?plant rdfs:label ?plantLabel . }}
+
+      OPTIONAL {{
+        ?plant <{CLONE_OF}> ?cloneMother .
+        ?cloneMother rdfs:label ?cloneMotherLabel .
+      }}
+
+      OPTIONAL {{
+        ?plant <{belongsToStrain}> ?strain .
+        OPTIONAL {{ ?strain rdfs:label ?strainLabel . }}
+        OPTIONAL {{ ?strain <{description_pred}> ?strainDesc . }}
+      }}
+
+      OPTIONAL {{ ?plant <{hasGender}> ?gender . }}
+
+      OPTIONAL {{
+        ?plant <{CLONE_OF}> ?cm2 .
+        ?sib <{CLONE_OF}> ?cm2 .
+        ?sib rdfs:label ?sibLabel .
+      }}
+    }}
+    GROUP BY ?plant ?plantLabel ?cloneMother ?cloneMotherLabel ?strain ?strainLabel ?strainDesc ?gender
+    ORDER BY ?plantLabel
+    LIMIT {bounded_limit}
+    """
+
+    return run_sparql(query)
+
+
 def load_examples(files: Optional[list[str]] = None, graph: Optional[str] = None) -> Dict[str, Any]:
     if not _ALLOW_EXAMPLE_LOAD:
         raise HTTPException(status_code=403, detail="Example loading is disabled")