jdmagent / tests /test_tools.py
expAge
fix(.enrich): SKIP les triplets non-inférés au lieu d'écrire un placeholder
dcf7897
"""Tests des outils LangChain (mockant JDMClient via respx)."""
from __future__ import annotations
import httpx
import pytest
import respx
from jdm_agent.client import JDMClient
from jdm_agent.client.cache import DiskJSONCache
from jdm_agent.tools.jdm_tools import (
ALL_TOOLS,
build_jdm_tools,
disambiguate,
get_agents,
get_consequences,
get_instruments,
get_patients,
get_relations_between,
get_relations_of_type,
get_synonyms,
list_relation_types,
lookup_term,
set_default_client,
)
BASE = "https://jdm-api.demo.lirmm.fr"
REL_TYPES = [
{"id": 5, "name": "r_syn", "help": "synonymes"},
{"id": 6, "name": "r_isa", "help": "hyperonymes"},
{"id": 13, "name": "r_agent", "help": "sujet"},
{"id": 14, "name": "r_patient", "help": "objet"},
{"id": 15, "name": "r_lieu", "help": "lieux typiques"},
{"id": 16, "name": "r_instr", "help": "instrument"},
{"id": 41, "name": "r_has_conseq", "help": "consequence"},
{"id": 42, "name": "r_has_causatif", "help": "cause"},
]
NODE_TYPES = [{"id": 1, "name": "n_generic", "help": ""}]
NODE_CHAT = {"id": 150, "name": "chat", "type": 1, "w": 7967}
SYN_RESP = {
"nodes": [
{"id": 150, "name": "chat", "type": 1, "w": 7967},
{"id": 999, "name": "matou", "type": 1, "w": 100},
],
"relations": [
{"id": 1, "node1": 150, "node2": 999, "type": 5, "w": 80.0},
],
}
REFINEMENTS_RESP = {
"nodes": [{"id": 1, "name": "avocat", "type": 1, "w": 10}],
"refinements": [
{"id": 11, "name": "avocat>fruit", "type": 1, "w": 50},
{"id": 12, "name": "avocat>juriste", "type": 1, "w": 60},
],
}
@pytest.fixture
def patched_client(tmp_path):
cache = DiskJSONCache(cache_dir=tmp_path / "cache")
client = JDMClient(base_url=BASE, cache=cache)
set_default_client(client)
return client
@respx.mock
def test_lookup_term(patched_client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/node_by_name/chat").mock(return_value=httpx.Response(200, json=NODE_CHAT))
out = lookup_term.invoke({"term": "chat"})
assert out["name"] == "chat"
assert out["id"] == 150
assert "weight" in out
@respx.mock
def test_lookup_term_unknown(patched_client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/node_by_name/zzzzz").mock(return_value=httpx.Response(404, json={}))
out = lookup_term.invoke({"term": "zzzzz"})
assert "error" in out
@respx.mock
def test_get_synonyms_returns_triplets(patched_client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/relations/from/chat").mock(return_value=httpx.Response(200, json=SYN_RESP))
out = get_synonyms.invoke({"term": "chat", "min_weight": 0, "limit": 10})
assert isinstance(out, list)
# Termes simples : pas de source_id / target_id (champ omis).
assert out[0]["source"] == "chat"
assert out[0]["target"] == "matou"
assert out[0]["relation"] == "r_syn"
assert out[0]["w"] == 80.0
assert "source_id" not in out[0]
assert "target_id" not in out[0]
@respx.mock
def test_get_relations_of_type_unknown_relation(patched_client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
out = get_relations_of_type.invoke({"term": "chat", "relation_name": "r_invented"})
assert out and "error" in out[0]
@respx.mock
def test_get_relations_of_type_to_direction(patched_client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
route = respx.get(f"{BASE}/v0/relations/to/poisson").mock(
return_value=httpx.Response(200, json={
"nodes": [{"id": 50, "name": "truite", "type": 1, "w": 10}],
"relations": [{"id": 1, "node1": 50, "node2": 1, "type": 6, "w": 90.0}],
})
)
out = get_relations_of_type.invoke({
"term": "poisson", "relation_name": "r_isa", "direction": "to",
})
assert route.called
# Pour direction="to", le terme interrogé est la CIBLE du triplet (target),
# et la source est l'autre bout (ici "truite").
assert out[0]["target"] == "poisson"
assert out[0]["source"] == "truite"
@respx.mock
def test_get_relations_between(patched_client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
# Pré-check : le tool vérifie que les deux termes existent avant
# de querier les relations. Mocks requis.
respx.get(f"{BASE}/v0/node_by_name/chat").mock(return_value=httpx.Response(200, json={
"id": 150, "name": "chat", "type": 1, "w": 100,
}))
respx.get(f"{BASE}/v0/node_by_name/internet").mock(return_value=httpx.Response(200, json={
"id": 999, "name": "internet", "type": 1, "w": 100,
}))
respx.get(f"{BASE}/v0/relations/from/chat/to/internet").mock(return_value=httpx.Response(200, json={
"nodes": [],
"relations": [
{"id": 1, "node1": 150, "node2": 999, "type": 5, "w": 30.0},
{"id": 2, "node1": 150, "node2": 999, "type": 15, "w": 50.0},
],
}))
out = get_relations_between.invoke({"term1": "chat", "term2": "internet", "min_weight": 0})
assert len(out) == 2
# Trié par poids décroissant.
assert out[0]["w"] >= out[1]["w"]
@respx.mock
def test_get_relations_between_missing_term(patched_client):
"""Si l'un des termes n'existe pas, on renvoie un item d'erreur
explicite plutôt qu'une liste vide ambiguë."""
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/node_by_name/tuile").mock(return_value=httpx.Response(200, json={
"id": 200, "name": "tuile", "type": 1, "w": 50,
}))
# Le terme « materiau » n'existe pas (404 ou 500 avec body "not found")
respx.get(f"{BASE}/v0/node_by_name/materiau").mock(return_value=httpx.Response(404, json={
"error": "Node 'materiau' not found!",
}))
out = get_relations_between.invoke({"term1": "tuile", "term2": "materiau"})
assert len(out) == 1
assert "error" in out[0]
assert out[0]["missing"] == "materiau"
@respx.mock
def test_disambiguate(patched_client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/refinements/avocat").mock(return_value=httpx.Response(200, json=REFINEMENTS_RESP))
out = disambiguate.invoke({"term": "avocat"})
ids = [d["sense_id"] for d in out]
assert "avocat>fruit" in ids and "avocat>juriste" in ids
@respx.mock
def test_list_relation_types_with_prefix(patched_client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
out = list_relation_types.invoke({"prefix": "r_is"})
names = [d["name"] for d in out]
assert "r_isa" in names
assert "r_syn" not in names
def test_build_jdm_tools_enriches_docstrings(patched_client):
"""Vérifie que les docstrings sont enrichies par describe_relation()."""
tools = build_jdm_tools(enrich_docstrings=True)
by_name = {t.name: t for t in tools}
desc = by_name["get_synonyms"].description
# L'enrichissement ajoute la balise [JDM] si le fichier .md est trouvé.
# En cas d'absence du fichier, on tolère le test (skip silencieux).
if "[JDM]" not in desc:
pytest.skip("relation_definitions.md non trouvé depuis ce contexte")
assert "r_syn" in desc
def test_all_tools_have_unique_names():
names = [t.name for t in ALL_TOOLS]
assert len(names) == len(set(names))
# Sanity sur quelques outils-clés.
for n in ("get_synonyms", "lookup_term", "get_agents", "get_patients",
"get_instruments", "get_consequences", "get_actions_of",
"get_uses_with", "get_domain_members"):
assert n in names, f"{n} missing"
@respx.mock
def test_get_agents_calls_r_agent(patched_client):
"""get_agents doit filtrer sur r_agent (id=13) côté HTTP."""
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
route = respx.get(f"{BASE}/v0/relations/from/manger").mock(return_value=httpx.Response(200, json={
"nodes": [
{"id": 1, "name": "manger", "type": 1, "w": 10},
{"id": 2, "name": "chat", "type": 1, "w": 5},
],
"relations": [{"id": 1, "node1": 1, "node2": 2, "type": 13, "w": 200.0}],
}))
out = get_agents.invoke({"verb": "manger", "min_weight": 0, "limit": 10})
assert route.called
assert dict(route.calls.last.request.url.params)["types_ids"] == "13"
assert out[0]["source"] == "manger"
assert out[0]["target"] == "chat"
assert out[0]["relation"] == "r_agent"
assert out[0]["w"] == 200.0
assert "source_id" not in out[0] and "target_id" not in out[0]
@respx.mock
def test_get_consequences_uses_correct_relation_id(patched_client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
route = respx.get(f"{BASE}/v0/relations/from/pluie").mock(return_value=httpx.Response(200, json={
"nodes": [], "relations": [],
}))
get_consequences.invoke({"term": "pluie"})
assert dict(route.calls.last.request.url.params)["types_ids"] == "41"
@respx.mock
def test_disambiguate_returns_sense_and_id(patched_client):
"""disambiguate doit renvoyer 'sense' (décodé) et 'sense_id' (brut)."""
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/refinements/avocat").mock(return_value=httpx.Response(200, json={
"nodes": [
{"id": 1, "name": "avocat", "type": 1, "w": 100},
{"id": 116477, "name": "personne", "type": 1, "w": 1000},
{"id": 87286, "name": "fruit", "type": 1, "w": 50},
],
"refinements": [
{"id": 100, "name": "avocat>116477>66699", "type": 1, "w": 356.0},
{"id": 101, "name": "avocat>87286", "type": 1, "w": 98.0},
],
}))
respx.get(f"{BASE}/v0/node_by_id/66699").mock(return_value=httpx.Response(200, json={
"id": 66699, "name": "juriste", "type": 1, "w": 911,
}))
out = disambiguate.invoke({"term": "avocat"})
by_id = {d["sense_id"]: d for d in out}
assert by_id["avocat>116477>66699"]["sense"] == "avocat (personne, juriste)"
assert by_id["avocat>87286"]["sense"] == "avocat (fruit)"
# Trié par poids décroissant.
assert out[0]["weight"] >= out[-1]["weight"]
@respx.mock
def test_relations_decode_refinement_target(patched_client):
"""Un target qui EST un refinement doit apparaître décodé + target_id préservé."""
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
# avocat → r_raff_sem → avocat>116477>66699 (le sens "juriste")
respx.get(f"{BASE}/v0/relations/from/avocat").mock(return_value=httpx.Response(200, json={
"nodes": [
{"id": 1, "name": "avocat", "type": 1, "w": 100},
{"id": 116477, "name": "personne", "type": 1, "w": 1000},
{"id": 565903, "name": "avocat>116477>66699", "type": 1, "w": 50},
],
"relations": [
{"id": 1, "node1": 1, "node2": 565903, "type": 5, "w": 200.0},
],
}))
# r_syn id=5 dans REL_TYPES, peu importe la sémantique pour ce test.
respx.get(f"{BASE}/v0/node_by_id/66699").mock(return_value=httpx.Response(200, json={
"id": 66699, "name": "juriste", "type": 1, "w": 911,
}))
out = get_synonyms.invoke({"term": "avocat", "min_weight": 0, "limit": 5})
assert out[0]["source"] == "avocat"
assert out[0]["target"] == "avocat (personne, juriste)"
assert out[0]["target_id"] == "avocat>116477>66699"
assert "source_id" not in out[0] # source = terme simple
@respx.mock
def test_detect_gaps_tool(patched_client):
"""detect_gaps doit renvoyer des Gap dicts pour un terme sans triplets."""
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/relations/from/smartphone").mock(return_value=httpx.Response(200, json={
"nodes": [], "relations": [],
}))
from jdm_agent.tools.jdm_tools import detect_gaps as detect_gaps_tool
out = detect_gaps_tool.invoke({
"term": "smartphone",
"relations": ["r_isa"], # une relation présente dans REL_TYPES (id=6)
})
assert isinstance(out, list)
assert any(g["term"] == "smartphone" for g in out)
@respx.mock
def test_validate_candidate_tool(patched_client):
"""validate_candidate doit retourner un dict avec validation_status."""
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/node_by_name/processeur").mock(return_value=httpx.Response(200, json={
"id": 200, "name": "processeur", "type": 1, "w": 800,
}))
respx.get(f"{BASE}/v0/relations/from/smartphone").mock(return_value=httpx.Response(200, json={
"nodes": [], "relations": [],
}))
from jdm_agent.tools.jdm_tools import validate_candidate as validate_tool
out = validate_tool.invoke({
"term": "smartphone", "relation": "r_isa", "target": "processeur",
})
assert out["validation_status"] in ("ok", "duplicate", "unknown_term", "inconsistent")
@respx.mock
def test_lookup_term_includes_decoded(patched_client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/node_by_name/avocat%3E116477%3E66699").mock(return_value=httpx.Response(200, json={
"id": 565903, "name": "avocat>116477>66699", "type": 1, "w": 50,
}))
respx.get(f"{BASE}/v0/node_by_id/116477").mock(return_value=httpx.Response(200, json={
"id": 116477, "name": "personne", "type": 1, "w": 1000,
}))
respx.get(f"{BASE}/v0/node_by_id/66699").mock(return_value=httpx.Response(200, json={
"id": 66699, "name": "juriste", "type": 1, "w": 911,
}))
out = lookup_term.invoke({"term": "avocat>116477>66699"})
assert out["name"] == "avocat>116477>66699"
assert out["decoded"] == "avocat (personne, juriste)"
def test_all_tools_have_valid_schemas():
"""Chaque @tool doit avoir un args_schema Pydantic exploitable par un LLM."""
for t in ALL_TOOLS:
schema = t.args_schema.model_json_schema() if t.args_schema else {}
assert "properties" in schema, f"{t.name} sans schema"
# -------------------- Phase 12 : write_submission_file avec upload --------------------
@respx.mock
def test_write_submission_file_local_only_default(tmp_path, monkeypatch):
"""Par défaut upload=False : on écrit le fichier local, on ne POST rien."""
monkeypatch.setenv("JDM_DROPS_API_KEY", "would-be-used")
from jdm_agent.tools.jdm_tools import write_submission_file
from jdm_agent.enrich.uploader import DEFAULT_ENDPOINT_URL
# Si un POST partait, respx mock le bloquerait (pas de route → erreur).
out = write_submission_file.invoke({
"triplets": [{
"term": "chat", "relation": "r_isa", "target": "mammifère",
"annotation": "constitutif", "explanation": "trivialement",
}],
"path": str(tmp_path / "sub.txt"),
})
assert out["count"] == 1
assert "upload" not in out # pas tenté
@respx.mock
def test_write_submission_file_with_upload_success(tmp_path, monkeypatch):
monkeypatch.delenv("JDM_DROPS_API_KEY", raising=False)
from jdm_agent.tools.jdm_tools import write_submission_file
from jdm_agent.enrich.uploader import DEFAULT_ENDPOINT_URL
respx.post(DEFAULT_ENDPOINT_URL).mock(
return_value=httpx.Response(200, json={"status": "ok", "id": 7})
)
out = write_submission_file.invoke({
"triplets": [{
"term": "chat", "relation": "r_isa", "target": "mammifère",
"annotation": "", "explanation": "trivialement",
}],
"path": str(tmp_path / "sub.txt"),
"upload": True,
"model_name": "claude-sonnet-4-7",
"api_key": "explicit-key",
})
assert out["count"] == 1
assert out["upload"]["ok"] is True
assert out["upload"]["status_code"] == 200
assert out["upload"]["uploaded_as"].endswith(
"_automatic_submission_from_claude-sonnet-4-7.enrich"
)
assert out["upload"]["response"] == {"status": "ok", "id": 7}
def test_write_submission_file_upload_without_api_key(tmp_path, monkeypatch):
"""upload=True sans clé : fichier local OK, upload reporte l'erreur."""
monkeypatch.delenv("JDM_DROPS_API_KEY", raising=False)
from jdm_agent.tools.jdm_tools import write_submission_file
out = write_submission_file.invoke({
"triplets": [{
"term": "chat", "relation": "r_isa", "target": "mammifère",
"annotation": "", "explanation": "trivialement",
}],
"path": str(tmp_path / "sub.txt"),
"upload": True,
"model_name": "claude-haiku",
})
assert out["count"] == 1
assert out["upload"]["ok"] is False
assert "API" in out["upload"]["error"] or "JDM_DROPS_API_KEY" in out["upload"]["error"]