|
@@ -1,12 +1,18 @@
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
from typing import Any, Dict, Optional
|
|
from typing import Any, Dict, Optional
|
|
|
import os
|
|
import os
|
|
|
|
|
+import uuid
|
|
|
|
|
|
|
|
from fastapi import HTTPException
|
|
from fastapi import HTTPException
|
|
|
from virtuoso_mcp import run_sparql, run_sparql_update, SPARQL_MAX_LIMIT, ttl_to_sparql_insert
|
|
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
|
|
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"
|
|
_ALLOW_EXAMPLE_LOAD = os.getenv("MCP_ALLOW_EXAMPLE_LOAD", "false").lower() == "true"
|
|
|
_EXAMPLES_DIR = BASE_DIR.parent.parent / "examples"
|
|
_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)
|
|
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]:
|
|
def load_examples(files: Optional[list[str]] = None, graph: Optional[str] = None) -> Dict[str, Any]:
|
|
|
if not _ALLOW_EXAMPLE_LOAD:
|
|
if not _ALLOW_EXAMPLE_LOAD:
|
|
|
raise HTTPException(status_code=403, detail="Example loading is disabled")
|
|
raise HTTPException(status_code=403, detail="Example loading is disabled")
|