ontology
This commit is contained in:
12
ontology_platform/vendored/ontocast/test/__init__.py
Normal file
12
ontology_platform/vendored/ontocast/test/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Test suite for OntoCast.
|
||||
|
||||
This package contains comprehensive tests for the OntoCast framework,
|
||||
including unit tests, integration tests, and test utilities.
|
||||
|
||||
Test categories:
|
||||
- Triple store tests (Neo4j, Fuseki)
|
||||
- Ontology and state management tests
|
||||
- Fact extraction and rendering tests
|
||||
- Validation and utility tests
|
||||
- Integration tests for the complete workflow
|
||||
"""
|
||||
@@ -0,0 +1,934 @@
|
||||
from rdflib import OWL, RDF, RDFS, Literal, URIRef
|
||||
|
||||
from ontocast.onto.constants import DEFAULT_IRI, PROV, RDF_REIFIES, SCHEMA
|
||||
from ontocast.onto.content_unit import ContentUnit, OutputType
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.tool.agg.aggregate import EmbeddingBasedAggregator
|
||||
from ontocast.util import render_text_hash
|
||||
|
||||
|
||||
def make_fact_unit(
|
||||
text: str,
|
||||
index: int,
|
||||
doc_iri: URIRef | str,
|
||||
ttl: str,
|
||||
) -> ContentUnit:
|
||||
graph = RDFGraph()
|
||||
graph.parse(data=ttl, format="turtle")
|
||||
return ContentUnit(
|
||||
text=text,
|
||||
index=index,
|
||||
doc_iri=URIRef(str(doc_iri)),
|
||||
graph=graph,
|
||||
type=OutputType.FACTS,
|
||||
)
|
||||
|
||||
|
||||
def make_ontology_unit(
|
||||
text: str,
|
||||
index: int,
|
||||
doc_iri: URIRef | str,
|
||||
ttl: str,
|
||||
) -> ContentUnit:
|
||||
graph = RDFGraph()
|
||||
graph.parse(data=ttl, format="turtle")
|
||||
return ContentUnit(
|
||||
text=text,
|
||||
index=index,
|
||||
doc_iri=URIRef(str(doc_iri)),
|
||||
graph=graph,
|
||||
type=OutputType.ONTOLOGIES,
|
||||
)
|
||||
|
||||
|
||||
def test_aggregate_graphs_returns_empty_graph_for_no_units() -> None:
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
result = aggregator.aggregate_graphs([])
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_fact_entities_use_doc_iri_namespace() -> None:
|
||||
doc_iri = "https://my-org.io/reports/annual2025"
|
||||
ttl = f"""
|
||||
@prefix facts: <{DEFAULT_IRI}/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
facts:Revenue rdf:type facts:FinancialMetric .
|
||||
facts:Revenue rdfs:label "Revenue" .
|
||||
facts:Revenue facts:amount "42000000" .
|
||||
"""
|
||||
unit = make_fact_unit("Revenue was $42M.", 0, doc_iri, ttl)
|
||||
|
||||
result = EmbeddingBasedAggregator().aggregate_graphs([unit])
|
||||
assert len(result) > 0
|
||||
|
||||
fact_subjects = {
|
||||
str(subject)
|
||||
for subject, predicate, _ in result
|
||||
if isinstance(subject, URIRef)
|
||||
and predicate != RDF.type
|
||||
and not str(subject).startswith("http://www.w3.org")
|
||||
and not str(subject).startswith("https://schema.org")
|
||||
and "/stmt/" not in str(subject)
|
||||
and "/chunk/" not in str(subject)
|
||||
}
|
||||
assert fact_subjects
|
||||
assert any(subject.startswith(doc_iri) for subject in fact_subjects)
|
||||
|
||||
|
||||
def test_aggregate_graphs_merges_overlapping_facts(monkeypatch) -> None:
|
||||
doc_iri = "https://example.org/docs/report1"
|
||||
ttl_chunk_0 = f"""
|
||||
@prefix facts: <{DEFAULT_IRI}/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
facts:UnitedStates rdf:type facts:Country .
|
||||
facts:UnitedStates rdfs:label "United States" .
|
||||
facts:UnitedStates facts:capitalCity "Washington, D.C." .
|
||||
facts:UnitedStates facts:currency "USD" .
|
||||
"""
|
||||
ttl_chunk_1 = f"""
|
||||
@prefix facts: <{DEFAULT_IRI}/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
facts:united_states rdf:type facts:Country .
|
||||
facts:united_states rdfs:label "United States" .
|
||||
facts:united_states facts:population "331000000" .
|
||||
"""
|
||||
ttl_chunk_2 = f"""
|
||||
@prefix facts: <{DEFAULT_IRI}/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
facts:UnitedStatesBank rdf:type facts:Company .
|
||||
facts:UnitedStatesBank rdfs:label "United States Bank" .
|
||||
facts:UnitedStatesBank facts:headquarters "Portland" .
|
||||
"""
|
||||
|
||||
text_0 = "The United States has capital Washington, D.C. and uses USD."
|
||||
text_1 = "In another section, united_states is described with population data."
|
||||
text_2 = "United States Bank is headquartered in Portland."
|
||||
units = [
|
||||
make_fact_unit(
|
||||
text_0,
|
||||
0,
|
||||
doc_iri,
|
||||
ttl_chunk_0,
|
||||
),
|
||||
make_fact_unit(
|
||||
text_1,
|
||||
1,
|
||||
doc_iri,
|
||||
ttl_chunk_1,
|
||||
),
|
||||
make_fact_unit(
|
||||
text_2,
|
||||
2,
|
||||
doc_iri,
|
||||
ttl_chunk_2,
|
||||
),
|
||||
]
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
|
||||
def cluster_by_normal_form(representations):
|
||||
clusters_by_key: dict[str, list[URIRef]] = {}
|
||||
for entity, representation in representations.items():
|
||||
clusters_by_key.setdefault(representation.normal_form, []).append(entity)
|
||||
return list(clusters_by_key.values()), {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer, "cluster_entities", cluster_by_normal_form
|
||||
)
|
||||
result = aggregator.aggregate_graphs(units)
|
||||
result.bind("unused", "https://unused.example/")
|
||||
turtle = result.serialize(format="turtle")
|
||||
|
||||
assert "Washington, D.C." in turtle
|
||||
assert "USD" in turtle
|
||||
assert "331000000" in turtle
|
||||
assert "Portland" in turtle
|
||||
assert "@prefix doc:" in turtle
|
||||
assert "@prefix unused:" not in turtle
|
||||
assert len(list(result.triples((None, RDFS.label, None)))) >= 2
|
||||
|
||||
us_subjects = {
|
||||
subject
|
||||
for subject in result.subjects(RDFS.label, Literal("United States"))
|
||||
if isinstance(subject, URIRef)
|
||||
}
|
||||
assert len(us_subjects) == 1
|
||||
us_entity = next(iter(us_subjects))
|
||||
|
||||
bank_subjects = {
|
||||
subject
|
||||
for subject in result.subjects(RDFS.label, Literal("United States Bank"))
|
||||
if isinstance(subject, URIRef)
|
||||
}
|
||||
assert len(bank_subjects) == 1
|
||||
bank_entity = next(iter(bank_subjects))
|
||||
|
||||
assert us_entity != bank_entity
|
||||
assert str(us_entity).startswith(doc_iri)
|
||||
assert str(bank_entity).startswith(doc_iri)
|
||||
|
||||
assert (us_entity, None, Literal("USD")) in result
|
||||
assert (us_entity, None, Literal("331000000")) in result
|
||||
assert (bank_entity, None, Literal("Portland")) in result
|
||||
|
||||
original_camel = URIRef(f"{DEFAULT_IRI}/UnitedStates")
|
||||
original_snake = URIRef(f"{DEFAULT_IRI}/united_states")
|
||||
assert (us_entity, OWL.sameAs, original_camel) not in result
|
||||
assert (us_entity, OWL.sameAs, original_snake) not in result
|
||||
|
||||
statement_nodes = list(result.subjects(RDF_REIFIES, None))
|
||||
assert statement_nodes
|
||||
assert all(
|
||||
len(set(result.objects(stmt, PROV.wasDerivedFrom))) >= 1
|
||||
for stmt in statement_nodes
|
||||
)
|
||||
|
||||
chunk_ids = {str(value) for value in result.objects(None, SCHEMA.identifier)}
|
||||
expected_ids = {
|
||||
render_text_hash(text_0),
|
||||
render_text_hash(text_1),
|
||||
render_text_hash(text_2),
|
||||
}
|
||||
assert expected_ids <= chunk_ids
|
||||
|
||||
|
||||
def test_aggregate_graphs_preserves_ontology_uris_and_provenance(monkeypatch) -> None:
|
||||
doc_iri = "https://example.org/docs/report1"
|
||||
ttl_chunk_0 = """
|
||||
@prefix ex: <http://example.org/onto#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
ex:Person rdf:type rdfs:Class .
|
||||
ex:Person rdfs:label "Person" .
|
||||
"""
|
||||
ttl_chunk_1 = """
|
||||
@prefix ex: <http://example.org/onto#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
ex:Persno rdf:type rdfs:Class .
|
||||
ex:Persno rdfs:label "Person" .
|
||||
"""
|
||||
units = [
|
||||
make_ontology_unit("Defines Person class.", 0, doc_iri, ttl_chunk_0),
|
||||
make_ontology_unit("Repeats class with typo URI.", 1, doc_iri, ttl_chunk_1),
|
||||
]
|
||||
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
|
||||
def force_typo_and_canonical_in_one_cluster(representations):
|
||||
canonical = URIRef("http://example.org/onto#Person")
|
||||
typo = URIRef("http://example.org/onto#Persno")
|
||||
entities = set(representations.keys())
|
||||
if canonical in entities and typo in entities:
|
||||
return [[canonical, typo]], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer,
|
||||
"cluster_entities",
|
||||
force_typo_and_canonical_in_one_cluster,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs(units)
|
||||
|
||||
canonical = URIRef("http://example.org/onto#Person")
|
||||
typo = URIRef("http://example.org/onto#Persno")
|
||||
|
||||
assert (canonical, RDFS.label, Literal("Person")) in result
|
||||
assert (typo, RDFS.label, Literal("Person")) in result
|
||||
assert (canonical, OWL.sameAs, typo) in result or (
|
||||
typo,
|
||||
OWL.sameAs,
|
||||
canonical,
|
||||
) in result
|
||||
assert str(canonical).startswith("http://example.org/onto#")
|
||||
|
||||
statement_nodes = list(result.subjects(RDF_REIFIES, None))
|
||||
assert statement_nodes
|
||||
assert all(
|
||||
len(set(result.objects(stmt, PROV.wasDerivedFrom))) >= 1
|
||||
for stmt in statement_nodes
|
||||
)
|
||||
|
||||
|
||||
def test_facts_doc_entity_does_not_replace_ontology_entity(monkeypatch) -> None:
|
||||
doc_iri = "https://example.org/docs/case-42"
|
||||
ontology_court = URIRef("https://growgraph.dev/fcaont#CourAppelRouen")
|
||||
doc_court = URIRef(f"{doc_iri}/CourAppelRouen")
|
||||
heard_at = URIRef("https://growgraph.dev/fcaont#heardAt")
|
||||
court_type = URIRef("https://growgraph.dev/fcaont#Court")
|
||||
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix fcaont: <https://growgraph.dev/fcaont#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
doc:Case1 fcaont:heardAt doc:CourAppelRouen .
|
||||
doc:Case2 fcaont:heardAt fcaont:CourAppelRouen .
|
||||
doc:CourAppelRouen rdf:type fcaont:Court .
|
||||
fcaont:CourAppelRouen rdf:type fcaont:Court .
|
||||
"""
|
||||
unit = make_fact_unit("Rouen court references.", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
ontology_graph = RDFGraph()
|
||||
ontology_graph.add((ontology_court, RDF.type, court_type))
|
||||
|
||||
def force_doc_and_ontology_court_together(representations):
|
||||
entities = set(representations.keys())
|
||||
if doc_court in entities and ontology_court in entities:
|
||||
remaining = [e for e in entities if e not in {doc_court, ontology_court}]
|
||||
return [[doc_court, ontology_court], *[[e] for e in remaining]], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer,
|
||||
"cluster_entities",
|
||||
force_doc_and_ontology_court_together,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
|
||||
|
||||
assert (ontology_court, RDF.type, court_type) in result
|
||||
assert (doc_court, RDF.type, court_type) in result
|
||||
assert (ontology_court, OWL.sameAs, doc_court) not in result
|
||||
assert (doc_court, OWL.sameAs, ontology_court) not in result
|
||||
|
||||
heard_at_targets = set(result.objects(None, heard_at))
|
||||
assert ontology_court in heard_at_targets
|
||||
assert doc_court in heard_at_targets
|
||||
|
||||
|
||||
def test_ontology_entities_in_same_cluster_keep_original_iris(monkeypatch) -> None:
|
||||
doc_iri = "https://example.org/docs/case-43"
|
||||
court_fr = URIRef("https://growgraph.dev/fcaont#CourAppelRouen")
|
||||
court_en = URIRef("https://growgraph.dev/fcaont#AppealCourt_Rouen")
|
||||
heard_at = URIRef("https://growgraph.dev/fcaont#heardAt")
|
||||
same_as = OWL.sameAs
|
||||
rdfs_label = RDFS.label
|
||||
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix fcaont: <https://growgraph.dev/fcaont#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
doc:Case1 fcaont:heardAt fcaont:CourAppelRouen .
|
||||
doc:Case2 fcaont:heardAt fcaont:AppealCourt_Rouen .
|
||||
fcaont:CourAppelRouen rdfs:label "Cour d'appel de Rouen" .
|
||||
fcaont:AppealCourt_Rouen rdfs:label "Rouen Court of Appeal" .
|
||||
"""
|
||||
unit = make_fact_unit("Rouen court variants.", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
ontology_graph = RDFGraph()
|
||||
ontology_graph.add((court_fr, rdfs_label, Literal("Cour d'appel de Rouen")))
|
||||
ontology_graph.add((court_en, rdfs_label, Literal("Rouen Court of Appeal")))
|
||||
|
||||
def force_ontology_variants_together(representations):
|
||||
entities = set(representations.keys())
|
||||
if court_fr in entities and court_en in entities:
|
||||
remaining = [
|
||||
entity for entity in entities if entity not in {court_fr, court_en}
|
||||
]
|
||||
return [[court_fr, court_en], *[[entity] for entity in remaining]], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer,
|
||||
"cluster_entities",
|
||||
force_ontology_variants_together,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
|
||||
|
||||
assert (court_fr, rdfs_label, Literal("Cour d'appel de Rouen")) in result
|
||||
assert (court_en, rdfs_label, Literal("Rouen Court of Appeal")) in result
|
||||
assert (court_fr, heard_at, None) not in result
|
||||
assert (court_en, heard_at, None) not in result
|
||||
|
||||
heard_at_targets = set(result.objects(None, heard_at))
|
||||
assert court_fr in heard_at_targets
|
||||
assert court_en in heard_at_targets
|
||||
|
||||
assert (court_fr, same_as, court_en) in result or (
|
||||
court_en,
|
||||
same_as,
|
||||
court_fr,
|
||||
) in result
|
||||
|
||||
|
||||
def test_tentative_ontology_like_alias_maps_to_known_ontology(monkeypatch) -> None:
|
||||
doc_iri = "https://example.org/docs/case-44"
|
||||
known_court = URIRef("https://growgraph.dev/fcaont#AppealCourtRouen")
|
||||
invented_court = URIRef("https://growgraph.dev/fcaont#AppealCourt_Rouen")
|
||||
heard_at = URIRef("https://growgraph.dev/fcaont#heardAt")
|
||||
court_type = URIRef("https://growgraph.dev/fcaont#Court")
|
||||
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix fcaont: <https://growgraph.dev/fcaont#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
doc:Case1 fcaont:heardAt fcaont:AppealCourt_Rouen .
|
||||
fcaont:AppealCourt_Rouen rdf:type fcaont:Court .
|
||||
"""
|
||||
unit = make_fact_unit("Invented ontology-like alias.", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
ontology_graph = RDFGraph()
|
||||
ontology_graph.add((known_court, RDF.type, court_type))
|
||||
ontology_graph.add((known_court, RDFS.label, Literal("Rouen Court of Appeal")))
|
||||
|
||||
def force_known_and_invented_together(representations):
|
||||
entities = set(representations.keys())
|
||||
if known_court in entities and invented_court in entities:
|
||||
remaining = [
|
||||
entity
|
||||
for entity in entities
|
||||
if entity not in {known_court, invented_court}
|
||||
]
|
||||
return [
|
||||
[known_court, invented_court],
|
||||
*[[entity] for entity in remaining],
|
||||
], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer,
|
||||
"cluster_entities",
|
||||
force_known_and_invented_together,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
|
||||
|
||||
heard_at_targets = set(result.objects(None, heard_at))
|
||||
assert known_court in heard_at_targets
|
||||
assert invented_court not in heard_at_targets
|
||||
assert (known_court, OWL.sameAs, invented_court) not in result
|
||||
|
||||
|
||||
def test_tentative_only_ontology_like_entities_are_preserved(monkeypatch) -> None:
|
||||
doc_iri = "https://example.org/docs/case-45"
|
||||
invented_court_1 = URIRef("https://growgraph.dev/fcaont#AppealCourt_Rouen")
|
||||
invented_court_2 = URIRef("https://growgraph.dev/fcaont#CourtOfAppealRouen")
|
||||
heard_at = URIRef("https://growgraph.dev/fcaont#heardAt")
|
||||
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix fcaont: <https://growgraph.dev/fcaont#> .
|
||||
doc:Case1 fcaont:heardAt fcaont:AppealCourt_Rouen .
|
||||
doc:Case2 fcaont:heardAt fcaont:CourtOfAppealRouen .
|
||||
"""
|
||||
unit = make_fact_unit("Tentative ontology-like terms only.", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
|
||||
def force_tentatives_together(representations):
|
||||
entities = set(representations.keys())
|
||||
if invented_court_1 in entities and invented_court_2 in entities:
|
||||
remaining = [
|
||||
entity
|
||||
for entity in entities
|
||||
if entity not in {invented_court_1, invented_court_2}
|
||||
]
|
||||
return [
|
||||
[invented_court_1, invented_court_2],
|
||||
*[[entity] for entity in remaining],
|
||||
], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer,
|
||||
"cluster_entities",
|
||||
force_tentatives_together,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs([unit])
|
||||
|
||||
heard_at_targets = set(result.objects(None, heard_at))
|
||||
assert invented_court_1 in heard_at_targets
|
||||
assert invented_court_2 in heard_at_targets
|
||||
|
||||
|
||||
def test_unused_ontology_entities_do_not_create_spurious_sameas() -> None:
|
||||
doc_iri = "https://example.org/docs/case-46"
|
||||
court_in_facts = URIRef("https://growgraph.dev/fcaont#CourAppelRouen")
|
||||
heard_at = URIRef("https://growgraph.dev/fcaont#heardAt")
|
||||
court_type = URIRef("https://growgraph.dev/fcaont#AppealCourt")
|
||||
unused_a = URIRef("https://growgraph.dev/fcaont#CourAppelParis")
|
||||
unused_b = URIRef("https://growgraph.dev/fcaont#CourAppelLyon")
|
||||
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix fcaont: <https://growgraph.dev/fcaont#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
doc:Case1 fcaont:heardAt fcaont:CourAppelRouen .
|
||||
fcaont:CourAppelRouen rdf:type fcaont:AppealCourt .
|
||||
"""
|
||||
unit = make_fact_unit("Case heard at Rouen court of appeal.", 0, doc_iri, ttl)
|
||||
ontology_graph = RDFGraph()
|
||||
ontology_graph.add((court_in_facts, RDF.type, court_type))
|
||||
ontology_graph.add((unused_a, RDF.type, court_type))
|
||||
ontology_graph.add((unused_b, RDF.type, court_type))
|
||||
|
||||
result = EmbeddingBasedAggregator().aggregate_graphs(
|
||||
[unit], ontology_graph=ontology_graph
|
||||
)
|
||||
|
||||
assert (unused_a, OWL.sameAs, unused_b) not in result
|
||||
assert (unused_b, OWL.sameAs, unused_a) not in result
|
||||
assert court_in_facts in set(result.objects(None, heard_at))
|
||||
|
||||
|
||||
def test_tentative_with_incompatible_type_does_not_merge_to_known_ontology(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
doc_iri = "https://example.org/docs/case-47"
|
||||
known_conviction = URIRef("https://growgraph.dev/fcaont#Conviction")
|
||||
tentative_person = URIRef("https://growgraph.dev/fcaont#Conviction1")
|
||||
associated_with = URIRef("https://growgraph.dev/fcaont#isAssociatedWith")
|
||||
conviction_type = URIRef("https://growgraph.dev/fcaont#Conviction")
|
||||
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix fcaont: <https://growgraph.dev/fcaont#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix schema: <https://schema.org/> .
|
||||
doc:Judgment1 fcaont:isAssociatedWith fcaont:Conviction1 .
|
||||
fcaont:Conviction1 rdf:type schema:Person .
|
||||
"""
|
||||
unit = make_fact_unit("Person associated with judgment.", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
ontology_graph = RDFGraph()
|
||||
ontology_graph.add((known_conviction, RDF.type, conviction_type))
|
||||
ontology_graph.add((known_conviction, RDFS.label, Literal("Conviction")))
|
||||
|
||||
def force_known_and_tentative_together(representations):
|
||||
entities = set(representations.keys())
|
||||
if known_conviction in entities and tentative_person in entities:
|
||||
remaining = [
|
||||
entity
|
||||
for entity in entities
|
||||
if entity not in {known_conviction, tentative_person}
|
||||
]
|
||||
return [
|
||||
[known_conviction, tentative_person],
|
||||
*[[entity] for entity in remaining],
|
||||
], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer,
|
||||
"cluster_entities",
|
||||
force_known_and_tentative_together,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
|
||||
|
||||
assert tentative_person in set(result.objects(None, associated_with))
|
||||
assert known_conviction not in set(result.objects(None, associated_with))
|
||||
assert (known_conviction, OWL.sameAs, tentative_person) not in result
|
||||
|
||||
|
||||
def test_tentative_alias_merged_without_sameas_leak(monkeypatch) -> None:
|
||||
doc_iri = "https://example.org/docs/case-47b"
|
||||
known_conviction = URIRef("https://growgraph.dev/fcaont#Conviction")
|
||||
tentative_alias = URIRef("https://growgraph.dev/fcaont#Conviction1")
|
||||
associated_with = URIRef("https://growgraph.dev/fcaont#isAssociatedWith")
|
||||
class_type = URIRef("http://www.w3.org/2000/01/rdf-schema#Class")
|
||||
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix fcaont: <https://growgraph.dev/fcaont#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
doc:Judgment1 fcaont:isAssociatedWith fcaont:Conviction1 .
|
||||
fcaont:Conviction1 rdf:type fcaont:Conviction .
|
||||
"""
|
||||
unit = make_fact_unit("Ontology-like alias mention.", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
ontology_graph = RDFGraph()
|
||||
ontology_graph.add((known_conviction, RDF.type, class_type))
|
||||
|
||||
def force_known_and_tentative_together(representations):
|
||||
entities = set(representations.keys())
|
||||
if known_conviction in entities and tentative_alias in entities:
|
||||
remaining = [
|
||||
entity
|
||||
for entity in entities
|
||||
if entity not in {known_conviction, tentative_alias}
|
||||
]
|
||||
return [
|
||||
[known_conviction, tentative_alias],
|
||||
*[[entity] for entity in remaining],
|
||||
], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer,
|
||||
"cluster_entities",
|
||||
force_known_and_tentative_together,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
|
||||
|
||||
assert known_conviction in set(result.objects(None, associated_with))
|
||||
assert tentative_alias not in set(result.objects(None, associated_with))
|
||||
assert (known_conviction, OWL.sameAs, tentative_alias) not in result
|
||||
|
||||
|
||||
def test_non_alias_ontology_terms_do_not_emit_sameas(monkeypatch) -> None:
|
||||
doc_iri = "https://example.org/docs/case-48"
|
||||
appeal = URIRef("https://growgraph.dev/fcaont#Appeal")
|
||||
appeal_decision = URIRef("https://growgraph.dev/fcaont#AppealDecision")
|
||||
type_class = URIRef("http://www.w3.org/2000/01/rdf-schema#Class")
|
||||
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix fcaont: <https://growgraph.dev/fcaont#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
fcaont:Appeal rdf:type rdfs:Class .
|
||||
fcaont:AppealDecision rdf:type rdfs:Class .
|
||||
"""
|
||||
unit = make_fact_unit("Ontology class references.", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
ontology_graph = RDFGraph()
|
||||
ontology_graph.add((appeal, RDF.type, type_class))
|
||||
ontology_graph.add((appeal_decision, RDF.type, type_class))
|
||||
|
||||
def force_together(representations):
|
||||
entities = set(representations.keys())
|
||||
if appeal in entities and appeal_decision in entities:
|
||||
remaining = [
|
||||
entity for entity in entities if entity not in {appeal, appeal_decision}
|
||||
]
|
||||
return [[appeal, appeal_decision], *[[entity] for entity in remaining]], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(aggregator.clusterer, "cluster_entities", force_together)
|
||||
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
|
||||
|
||||
assert (appeal, OWL.sameAs, appeal_decision) not in result
|
||||
assert (appeal_decision, OWL.sameAs, appeal) not in result
|
||||
|
||||
|
||||
def test_entity_in_namespace_accepts_exact_prefix_namespace() -> None:
|
||||
entity = URIRef("https://growgraph.dev/factsConviction1")
|
||||
assert EmbeddingBasedAggregator._entity_in_namespace(
|
||||
entity, "https://growgraph.dev/facts"
|
||||
)
|
||||
|
||||
|
||||
def test_fact_entity_forced_with_known_ontology_uses_identity_guard(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
doc_iri = "https://example.org/docs/case-49"
|
||||
known_conviction = URIRef("https://growgraph.dev/fcaont#Conviction")
|
||||
fact_conviction = URIRef("https://growgraph.dev/factsConviction1")
|
||||
associated_with = URIRef("https://growgraph.dev/fcaont#isAssociatedWith")
|
||||
class_type = URIRef("http://www.w3.org/2000/01/rdf-schema#Class")
|
||||
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix cd: <https://growgraph.dev/facts> .
|
||||
@prefix fcaont: <https://growgraph.dev/fcaont#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix schema: <https://schema.org/> .
|
||||
doc:Judgment1 fcaont:isAssociatedWith cd:Conviction1 .
|
||||
cd:Conviction1 rdf:type schema:Person .
|
||||
"""
|
||||
unit = make_fact_unit("Forced mixed cluster.", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
ontology_graph = RDFGraph()
|
||||
ontology_graph.add((known_conviction, RDF.type, class_type))
|
||||
|
||||
def force_known_and_fact_together(representations):
|
||||
entities = set(representations.keys())
|
||||
if known_conviction in entities and fact_conviction in entities:
|
||||
remaining = [
|
||||
entity
|
||||
for entity in entities
|
||||
if entity not in {known_conviction, fact_conviction}
|
||||
]
|
||||
return [
|
||||
[known_conviction, fact_conviction],
|
||||
*[[entity] for entity in remaining],
|
||||
], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer,
|
||||
"cluster_entities",
|
||||
force_known_and_fact_together,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
|
||||
|
||||
associated_targets = {
|
||||
obj for obj in result.objects(None, associated_with) if isinstance(obj, URIRef)
|
||||
}
|
||||
assert associated_targets
|
||||
assert all(str(obj).startswith(doc_iri) for obj in associated_targets)
|
||||
assert known_conviction not in associated_targets
|
||||
|
||||
uri_nodes = {
|
||||
term for s, _, o in result for term in (s, o) if isinstance(term, URIRef)
|
||||
}
|
||||
assert all(not str(node).startswith(DEFAULT_IRI) for node in uri_nodes)
|
||||
|
||||
|
||||
def test_fact_predicate_is_collected_and_rewritten_to_doc_namespace() -> None:
|
||||
doc_iri = "https://example.org/docs/predicate-case"
|
||||
predicate = URIRef("https://growgraph.dev/factsHasCase")
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix facts: <https://growgraph.dev/facts> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
doc:CaseA facts:HasCase doc:CaseB .
|
||||
doc:CaseA rdf:type doc:Case .
|
||||
"""
|
||||
unit = make_fact_unit("Predicate-only fact URI.", 0, doc_iri, ttl)
|
||||
|
||||
result = EmbeddingBasedAggregator().aggregate_graphs([unit])
|
||||
|
||||
rewritten_predicates = {
|
||||
p for _, p, _ in result if isinstance(p, URIRef) and str(p).startswith(doc_iri)
|
||||
}
|
||||
assert rewritten_predicates
|
||||
assert any("HasCase" in str(p) for p in rewritten_predicates)
|
||||
assert predicate not in set(result.predicates(None, None))
|
||||
|
||||
|
||||
def test_cross_chunk_entity_context_is_merged_for_representation(monkeypatch) -> None:
|
||||
doc_iri = "https://example.org/docs/context-merge"
|
||||
shared = URIRef("https://growgraph.dev/factsSharedEntity")
|
||||
rel_a = URIRef("https://growgraph.dev/factsHasAlpha")
|
||||
rel_b = URIRef("https://growgraph.dev/factsHasBeta")
|
||||
ttl_chunk_0 = """
|
||||
@prefix facts: <https://growgraph.dev/facts> .
|
||||
facts:SharedEntity facts:HasAlpha "A" .
|
||||
"""
|
||||
ttl_chunk_1 = """
|
||||
@prefix facts: <https://growgraph.dev/facts> .
|
||||
facts:SharedEntity facts:HasBeta "B" .
|
||||
"""
|
||||
units = [
|
||||
make_fact_unit("First chunk", 0, doc_iri, ttl_chunk_0),
|
||||
make_fact_unit("Second chunk", 1, doc_iri, ttl_chunk_1),
|
||||
]
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
original_create_representation = aggregator.normalizer.create_representation
|
||||
seen_shared_context: dict[str, set[URIRef]] = {"properties": set()}
|
||||
|
||||
def capture_representation(entity, graph):
|
||||
representation = original_create_representation(entity, graph)
|
||||
if entity == shared:
|
||||
seen_shared_context["properties"] = set(representation.properties)
|
||||
return representation
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.normalizer,
|
||||
"create_representation",
|
||||
capture_representation,
|
||||
)
|
||||
|
||||
aggregator.aggregate_graphs(units)
|
||||
|
||||
assert rel_a in seen_shared_context["properties"]
|
||||
assert rel_b in seen_shared_context["properties"]
|
||||
|
||||
|
||||
def test_doc_namespace_forcing_avoids_uri_collisions(monkeypatch) -> None:
|
||||
doc_iri = "https://example.org/docs/collision-safe"
|
||||
ttl = """
|
||||
@prefix facts: <https://growgraph.dev/facts> .
|
||||
facts:EntityA facts:RelatedTo "left" .
|
||||
facts:EntityB facts:RelatedTo "right" .
|
||||
"""
|
||||
unit = make_fact_unit("Collision case", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
|
||||
def singleton_clusters(representations):
|
||||
return [[entity] for entity in representations], {}
|
||||
|
||||
original_create_representations = aggregator.normalizer.create_representations_batch
|
||||
|
||||
def force_same_normal_form(entities, entity_graphs):
|
||||
representations = original_create_representations(entities, entity_graphs)
|
||||
for entity in entities:
|
||||
if str(entity).endswith("EntityA") or str(entity).endswith("EntityB"):
|
||||
rep = representations[entity]
|
||||
rep.normal_form = "collision"
|
||||
rep.representation = "collision"
|
||||
return representations
|
||||
|
||||
monkeypatch.setattr(aggregator.clusterer, "cluster_entities", singleton_clusters)
|
||||
monkeypatch.setattr(
|
||||
aggregator.normalizer,
|
||||
"create_representations_batch",
|
||||
force_same_normal_form,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs([unit])
|
||||
|
||||
subject_targets = {
|
||||
subject
|
||||
for subject, _, obj in result
|
||||
if isinstance(subject, URIRef)
|
||||
and str(subject).startswith(doc_iri)
|
||||
and isinstance(obj, Literal)
|
||||
and str(obj) in {"left", "right"}
|
||||
}
|
||||
assert len(subject_targets) == 2
|
||||
assert len({str(target).split("/")[-1] for target in subject_targets}) == 2
|
||||
assert all(str(target).startswith(doc_iri) for target in subject_targets)
|
||||
|
||||
|
||||
def test_select_ontology_anchor_candidates_preserves_trigger_doc_iri() -> None:
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
doc_a = URIRef("https://example.org/docs/a")
|
||||
doc_b = URIRef("https://example.org/docs/b")
|
||||
known_court = URIRef("https://growgraph.dev/fcaont#AppealCourtRouen")
|
||||
tentative_a = URIRef("https://growgraph.dev/fcaont#AppealCourt_Rouen")
|
||||
tentative_b = URIRef("https://growgraph.dev/fcaont#AppealCourtRouenAlias")
|
||||
|
||||
ontology_graph = RDFGraph()
|
||||
ontology_graph.add((known_court, RDFS.label, Literal("Appeal Court Rouen")))
|
||||
|
||||
tentative_graph = RDFGraph()
|
||||
tentative_graph.add((tentative_a, RDFS.label, Literal("Appeal Court Rouen")))
|
||||
tentative_graph.add((tentative_b, RDFS.label, Literal("Appeal Court Rouen")))
|
||||
tentative_representations = aggregator.normalizer.create_representations_batch(
|
||||
[tentative_a, tentative_b],
|
||||
{
|
||||
tentative_a: tentative_graph,
|
||||
tentative_b: tentative_graph,
|
||||
},
|
||||
)
|
||||
|
||||
selected = aggregator._select_ontology_anchor_candidates(
|
||||
tentative_entities=[tentative_a, tentative_b],
|
||||
tentative_representations=tentative_representations,
|
||||
tentative_doc_iris={
|
||||
tentative_a: doc_a,
|
||||
tentative_b: doc_b,
|
||||
},
|
||||
ontology_graph=ontology_graph,
|
||||
known_ontology_entities={known_court},
|
||||
)
|
||||
|
||||
assert selected[known_court] == doc_a
|
||||
|
||||
|
||||
def test_jaccard_handles_empty_and_partial_overlap() -> None:
|
||||
assert EmbeddingBasedAggregator._jaccard(set(), set()) == 1.0
|
||||
assert EmbeddingBasedAggregator._jaccard(set(), {"a"}) == 0.0
|
||||
assert EmbeddingBasedAggregator._jaccard({"a", "b"}, {"b", "c"}) == 1 / 3
|
||||
|
||||
|
||||
def test_fact_to_fact_candidate_rejected_when_symbolically_incompatible(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
doc_iri = "https://example.org/docs/case-merge-gate-1"
|
||||
criminal_court = URIRef(f"{DEFAULT_IRI}/CriminalCourt")
|
||||
civil_court = URIRef(f"{DEFAULT_IRI}/CivilCourt")
|
||||
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix facts: <{DEFAULT_IRI}/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
doc:Case1 facts:heardAt facts:CriminalCourt .
|
||||
doc:Case2 facts:heardAt facts:CivilCourt .
|
||||
facts:CriminalCourt rdf:type <https://example.org/onto#CriminalCourt> .
|
||||
facts:CivilCourt rdf:type <https://example.org/onto#CivilCourt> .
|
||||
facts:CriminalCourt rdfs:label "Criminal Court" .
|
||||
facts:CivilCourt rdfs:label "Civil Court" .
|
||||
"""
|
||||
unit = make_fact_unit("Two related courts", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
|
||||
def force_candidate_cluster(representations):
|
||||
entities = set(representations.keys())
|
||||
if criminal_court in entities and civil_court in entities:
|
||||
remaining = [
|
||||
entity
|
||||
for entity in entities
|
||||
if entity not in {criminal_court, civil_court}
|
||||
]
|
||||
return [
|
||||
[criminal_court, civil_court],
|
||||
*[[entity] for entity in remaining],
|
||||
], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer,
|
||||
"cluster_entities",
|
||||
force_candidate_cluster,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs([unit])
|
||||
heard_at_targets = {
|
||||
subject
|
||||
for subject in result.subjects(RDFS.label, Literal("Criminal Court"))
|
||||
if isinstance(subject, URIRef)
|
||||
} | {
|
||||
subject
|
||||
for subject in result.subjects(RDFS.label, Literal("Civil Court"))
|
||||
if isinstance(subject, URIRef)
|
||||
}
|
||||
|
||||
assert len(heard_at_targets) == 2
|
||||
assert all(str(target).startswith(doc_iri) for target in heard_at_targets)
|
||||
|
||||
|
||||
def test_fact_to_fact_candidate_merges_when_symbolically_compatible(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
doc_iri = "https://example.org/docs/case-merge-gate-2"
|
||||
united_states = URIRef(f"{DEFAULT_IRI}/UnitedStates")
|
||||
united_states_alias = URIRef(f"{DEFAULT_IRI}/united_states")
|
||||
ttl = f"""
|
||||
@prefix doc: <{doc_iri}/> .
|
||||
@prefix facts: <{DEFAULT_IRI}/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
facts:UnitedStates rdf:type <https://example.org/onto#Country> .
|
||||
facts:united_states rdf:type <https://example.org/onto#Country> .
|
||||
facts:UnitedStates rdfs:label "United States" .
|
||||
facts:united_states rdfs:label "United States" .
|
||||
facts:UnitedStates facts:population "331000000" .
|
||||
facts:united_states facts:population "332000000" .
|
||||
"""
|
||||
unit = make_fact_unit("US aliases", 0, doc_iri, ttl)
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
|
||||
def force_candidate_cluster(representations):
|
||||
entities = set(representations.keys())
|
||||
if united_states in entities and united_states_alias in entities:
|
||||
remaining = [
|
||||
entity
|
||||
for entity in entities
|
||||
if entity not in {united_states, united_states_alias}
|
||||
]
|
||||
return [
|
||||
[united_states, united_states_alias],
|
||||
*[[entity] for entity in remaining],
|
||||
], {}
|
||||
return [list(entities)], {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
aggregator.clusterer,
|
||||
"cluster_entities",
|
||||
force_candidate_cluster,
|
||||
)
|
||||
|
||||
result = aggregator.aggregate_graphs([unit])
|
||||
population_subjects = {
|
||||
subject
|
||||
for subject, _, obj in result
|
||||
if isinstance(subject, URIRef)
|
||||
and isinstance(obj, Literal)
|
||||
and str(obj) in {"331000000", "332000000"}
|
||||
}
|
||||
|
||||
assert len(population_subjects) == 1
|
||||
target = next(iter(population_subjects))
|
||||
assert str(target).startswith(doc_iri)
|
||||
@@ -0,0 +1,89 @@
|
||||
from typing import cast
|
||||
from unittest.mock import Mock
|
||||
|
||||
from rdflib import URIRef
|
||||
|
||||
from ontocast.onto.constants import DEFAULT_IRI
|
||||
from ontocast.tool.agg.clustering import ClusterRepresentativeSelector
|
||||
from ontocast.tool.agg.normalizer import EntityRepresentation
|
||||
|
||||
|
||||
def test_simplicity_score_prefers_simple_uris(
|
||||
cluster_representative_selector: ClusterRepresentativeSelector,
|
||||
) -> None:
|
||||
simple = URIRef("http://ex.org/Thing")
|
||||
complex_uri = URIRef("http://example.org/deeply/nested/path/ComplexEntity_123")
|
||||
|
||||
simple_score = cluster_representative_selector.compute_simplicity_score(simple)
|
||||
complex_score = cluster_representative_selector.compute_simplicity_score(
|
||||
complex_uri
|
||||
)
|
||||
|
||||
assert simple_score < complex_score
|
||||
|
||||
|
||||
def test_select_representative_prefers_ontology_entity(
|
||||
cluster_representative_selector: ClusterRepresentativeSelector,
|
||||
) -> None:
|
||||
ont_entity = URIRef("http://ontology.org/Thing")
|
||||
chunk_entity = URIRef(f"{DEFAULT_IRI}/entity_long_name")
|
||||
|
||||
ont_rep = Mock(is_ontology_entity=True)
|
||||
chunk_rep = Mock(is_ontology_entity=False)
|
||||
reps = cast(
|
||||
dict[URIRef, EntityRepresentation],
|
||||
{ont_entity: ont_rep, chunk_entity: chunk_rep},
|
||||
)
|
||||
|
||||
selected = cluster_representative_selector.select_representative(
|
||||
[ont_entity, chunk_entity], reps
|
||||
)
|
||||
assert selected == ont_entity
|
||||
|
||||
|
||||
def test_select_representative_prefers_simple_non_ontology_uri(
|
||||
cluster_representative_selector: ClusterRepresentativeSelector,
|
||||
) -> None:
|
||||
simple = URIRef("http://chunk1.org/Thing")
|
||||
complex_uri = URIRef("http://chunk2.org/very_long_complex_entity_name_123")
|
||||
|
||||
simple_rep = Mock(is_ontology_entity=False)
|
||||
complex_rep = Mock(is_ontology_entity=False)
|
||||
reps = cast(
|
||||
dict[URIRef, EntityRepresentation],
|
||||
{simple: simple_rep, complex_uri: complex_rep},
|
||||
)
|
||||
|
||||
selected = cluster_representative_selector.select_representative(
|
||||
[simple, complex_uri], reps
|
||||
)
|
||||
assert selected == simple
|
||||
|
||||
|
||||
def test_select_representative_returns_singleton(
|
||||
cluster_representative_selector: ClusterRepresentativeSelector,
|
||||
) -> None:
|
||||
entity = URIRef("http://chunk1.org/Only")
|
||||
rep = Mock(is_ontology_entity=False)
|
||||
reps = cast(dict[URIRef, EntityRepresentation], {entity: rep})
|
||||
|
||||
selected = cluster_representative_selector.select_representative([entity], reps)
|
||||
assert selected == entity
|
||||
|
||||
|
||||
def test_create_mapping_maps_all_cluster_members(
|
||||
cluster_representative_selector: ClusterRepresentativeSelector,
|
||||
) -> None:
|
||||
e1 = URIRef("http://chunk1.org/A")
|
||||
e2 = URIRef("http://chunk1.org/B")
|
||||
e3 = URIRef("http://chunk2.org/C")
|
||||
|
||||
rep1 = Mock(is_ontology_entity=False)
|
||||
rep2 = Mock(is_ontology_entity=False)
|
||||
rep3 = Mock(is_ontology_entity=False)
|
||||
|
||||
reps = cast(dict[URIRef, EntityRepresentation], {e1: rep1, e2: rep2, e3: rep3})
|
||||
mapping = cluster_representative_selector.create_mapping([[e1, e2], [e3]], reps)
|
||||
|
||||
assert mapping[e1] == mapping[e2]
|
||||
assert mapping[e3] == e3
|
||||
@@ -0,0 +1,61 @@
|
||||
from rdflib import RDF, RDFS, Literal, Namespace, URIRef
|
||||
|
||||
from ontocast.onto.constants import DEFAULT_IRI
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.tool.agg.normalizer import EntityNormalizer
|
||||
|
||||
|
||||
def test_normalize_string_camel_case(normalizer: EntityNormalizer) -> None:
|
||||
assert normalizer.normalize_string("PLRedShift") == "pl red shift"
|
||||
|
||||
|
||||
def test_normalize_string_snake_case(normalizer: EntityNormalizer) -> None:
|
||||
assert normalizer.normalize_string("PL_red_shift_value") == "pl red shift value"
|
||||
|
||||
|
||||
def test_normalize_string_diacritics(normalizer: EntityNormalizer) -> None:
|
||||
assert normalizer.normalize_string("Café") == "cafe"
|
||||
|
||||
|
||||
def test_normalize_uri_variants(normalizer: EntityNormalizer) -> None:
|
||||
camel_uri = URIRef("http://example.org/PLRedShift")
|
||||
snake_uri = URIRef("http://example.org/PL_red_shift_value")
|
||||
assert normalizer.normalize_uri(camel_uri) == "pl red shift"
|
||||
assert normalizer.normalize_uri(snake_uri) == "pl red shift value"
|
||||
|
||||
|
||||
def test_is_ontology_entity(normalizer: EntityNormalizer) -> None:
|
||||
assert normalizer.is_ontology_entity(URIRef("http://ontology.org/Thing")) is True
|
||||
assert normalizer.is_ontology_entity(URIRef(f"{DEFAULT_IRI}/entity")) is False
|
||||
|
||||
|
||||
def test_create_representation_collects_metadata(normalizer: EntityNormalizer) -> None:
|
||||
graph = RDFGraph()
|
||||
ex = Namespace("http://example.org/")
|
||||
ont = Namespace("http://ontology.org/")
|
||||
|
||||
entity = ex.TestEntity
|
||||
graph.add((entity, RDF.type, ont.Thing))
|
||||
graph.add((entity, RDFS.label, Literal("Test Entity")))
|
||||
graph.add((entity, ex.hasValue, Literal("123")))
|
||||
|
||||
representation = normalizer.create_representation(entity, graph)
|
||||
|
||||
assert representation.entity == entity
|
||||
assert "test entity" in representation.normal_form
|
||||
assert representation.types == [ont.Thing]
|
||||
assert "Test Entity" in representation.labels
|
||||
assert ex.hasValue in representation.properties
|
||||
assert "type" in representation.representation
|
||||
|
||||
|
||||
def test_create_representation_marks_ontology_entity(
|
||||
normalizer: EntityNormalizer,
|
||||
) -> None:
|
||||
graph = RDFGraph()
|
||||
ont = Namespace("http://ontology.org/")
|
||||
entity = ont.SomeClass
|
||||
graph.add((entity, RDF.type, RDFS.Class))
|
||||
|
||||
representation = normalizer.create_representation(entity, graph)
|
||||
assert representation.is_ontology_entity is True
|
||||
@@ -0,0 +1,107 @@
|
||||
from rdflib import RDF, Literal, URIRef
|
||||
from rdflib.namespace import XSD
|
||||
|
||||
from ontocast.onto.constants import DEFAULT_IRI, PROV, RDF_REIFIES, SCHEMA
|
||||
from ontocast.onto.content_unit import ContentUnit, OutputType
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.tool.agg.rewriter import GraphRewriter
|
||||
|
||||
|
||||
def test_merge_graphs_with_provenance_adds_chunk_metadata(
|
||||
graph_rewriter: GraphRewriter,
|
||||
) -> None:
|
||||
graph = RDFGraph()
|
||||
entity = URIRef(f"{DEFAULT_IRI}/Entity1")
|
||||
graph.add((entity, RDF.type, URIRef(f"{DEFAULT_IRI}/Thing")))
|
||||
|
||||
unit = ContentUnit(
|
||||
text="test",
|
||||
index=5,
|
||||
doc_iri=URIRef("https://example.org/doc/abc123"),
|
||||
graph=graph,
|
||||
type=OutputType.FACTS,
|
||||
)
|
||||
merged = graph_rewriter.merge_graphs_with_provenance([unit], mapping={})
|
||||
unit_uri = URIRef(unit.iri_absolute)
|
||||
|
||||
assert (unit_uri, RDF.type, PROV.Entity) in merged
|
||||
assert (unit_uri, SCHEMA.position, Literal(5, datatype=XSD.integer)) in merged
|
||||
assert (unit_uri, SCHEMA.identifier, Literal(unit.hid)) in merged
|
||||
|
||||
namespaces = {prefix: str(namespace) for prefix, namespace in merged.namespaces()}
|
||||
|
||||
assert namespaces["prov"] == str(PROV)
|
||||
assert namespaces["schema"] == str(SCHEMA)
|
||||
assert namespaces["doc"] == "https://example.org/doc/abc123/"
|
||||
|
||||
|
||||
def test_merge_graphs_with_provenance_reifies_mapped_triple(
|
||||
graph_rewriter: GraphRewriter,
|
||||
) -> None:
|
||||
graph = RDFGraph()
|
||||
old_subject = URIRef("http://chunk.org/OldEntity")
|
||||
old_predicate = URIRef("http://chunk.org/prop")
|
||||
value = Literal("value")
|
||||
graph.add((old_subject, old_predicate, value))
|
||||
|
||||
new_subject = URIRef(f"{DEFAULT_IRI}/NewEntity")
|
||||
new_predicate = URIRef(f"{DEFAULT_IRI}/prop")
|
||||
unit = ContentUnit(
|
||||
text="test",
|
||||
index=0,
|
||||
doc_iri=URIRef("https://example.org/doc"),
|
||||
graph=graph,
|
||||
type=OutputType.FACTS,
|
||||
)
|
||||
|
||||
merged = graph_rewriter.merge_graphs_with_provenance(
|
||||
[unit],
|
||||
{old_subject: new_subject, old_predicate: new_predicate},
|
||||
)
|
||||
stmt_nodes = list(merged.subjects(RDF_REIFIES, None))
|
||||
assert len(stmt_nodes) == 1
|
||||
|
||||
reified = list(merged.objects(stmt_nodes[0], RDF_REIFIES))
|
||||
assert len(reified) == 1
|
||||
quoted = reified[0]
|
||||
assert isinstance(quoted, tuple)
|
||||
assert quoted[0] == new_subject
|
||||
assert quoted[1] == new_predicate
|
||||
assert str(quoted[2]) == str(value)
|
||||
|
||||
|
||||
def test_shared_triple_accumulates_multiple_provenance_sources(
|
||||
graph_rewriter: GraphRewriter,
|
||||
) -> None:
|
||||
triple = (
|
||||
URIRef(f"{DEFAULT_IRI}/Alice"),
|
||||
URIRef(f"{DEFAULT_IRI}/knows"),
|
||||
URIRef(f"{DEFAULT_IRI}/Bob"),
|
||||
)
|
||||
graph_a = RDFGraph()
|
||||
graph_b = RDFGraph()
|
||||
graph_a.add(triple)
|
||||
graph_b.add(triple)
|
||||
|
||||
unit_a = ContentUnit(
|
||||
text="chunk 0",
|
||||
index=0,
|
||||
doc_iri=URIRef("https://example.org/doc"),
|
||||
graph=graph_a,
|
||||
type=OutputType.FACTS,
|
||||
)
|
||||
unit_b = ContentUnit(
|
||||
text="chunk 1",
|
||||
index=1,
|
||||
doc_iri=URIRef("https://example.org/doc"),
|
||||
graph=graph_b,
|
||||
type=OutputType.FACTS,
|
||||
)
|
||||
|
||||
merged = graph_rewriter.merge_graphs_with_provenance([unit_a, unit_b], mapping={})
|
||||
statements = list(merged.subjects(RDF_REIFIES, None))
|
||||
assert len(statements) == 1
|
||||
|
||||
sources = {str(src) for src in merged.objects(statements[0], PROV.wasDerivedFrom)}
|
||||
assert str(URIRef(unit_a.iri_absolute)) in sources
|
||||
assert str(URIRef(unit_b.iri_absolute)) in sources
|
||||
@@ -0,0 +1,123 @@
|
||||
from rdflib import OWL, RDF, Literal, URIRef
|
||||
|
||||
from ontocast.onto.constants import DEFAULT_IRI
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.tool.agg.rewriter import GraphRewriter
|
||||
|
||||
|
||||
def test_apply_mapping_to_triple(graph_rewriter: GraphRewriter) -> None:
|
||||
e1 = URIRef("http://chunk1.org/e1")
|
||||
p1 = URIRef("http://chunk1.org/p1")
|
||||
e2 = URIRef("http://chunk1.org/e2")
|
||||
|
||||
e1_new = URIRef(f"{DEFAULT_IRI}/Entity1")
|
||||
p1_new = URIRef(f"{DEFAULT_IRI}/property1")
|
||||
e2_new = URIRef(f"{DEFAULT_IRI}/Entity2")
|
||||
|
||||
mapped = graph_rewriter.apply_mapping_to_triple(
|
||||
e1,
|
||||
p1,
|
||||
e2,
|
||||
{e1: e1_new, p1: p1_new, e2: e2_new},
|
||||
)
|
||||
assert mapped == (e1_new, p1_new, e2_new)
|
||||
|
||||
|
||||
def test_apply_mapping_preserves_ontology_type_object(
|
||||
graph_rewriter: GraphRewriter,
|
||||
) -> None:
|
||||
entity = URIRef("http://chunk1.org/entity")
|
||||
ontology_type = URIRef("http://ontology.org/Thing")
|
||||
mapped_entity = URIRef(f"{DEFAULT_IRI}/Entity")
|
||||
|
||||
new_s, new_p, new_o = graph_rewriter.apply_mapping_to_triple(
|
||||
entity,
|
||||
RDF.type,
|
||||
ontology_type,
|
||||
{entity: mapped_entity},
|
||||
)
|
||||
assert (new_s, new_p, new_o) == (mapped_entity, RDF.type, ontology_type)
|
||||
|
||||
|
||||
def test_rewrite_graph_applies_mapping(graph_rewriter: GraphRewriter) -> None:
|
||||
graph = RDFGraph()
|
||||
e1 = URIRef("http://chunk1.org/e1")
|
||||
e2 = URIRef("http://chunk1.org/e2")
|
||||
p = URIRef("http://chunk1.org/p")
|
||||
ont_type = URIRef("http://ontology.org/Thing")
|
||||
|
||||
graph.add((e1, p, e2))
|
||||
graph.add((e1, RDF.type, ont_type))
|
||||
|
||||
e1_new = URIRef(f"{DEFAULT_IRI}/Entity1")
|
||||
e2_new = URIRef(f"{DEFAULT_IRI}/Entity2")
|
||||
p_new = URIRef(f"{DEFAULT_IRI}/relatesTo")
|
||||
|
||||
rewritten = graph_rewriter.rewrite_graph(graph, {e1: e1_new, e2: e2_new, p: p_new})
|
||||
assert (e1_new, p_new, e2_new) in rewritten
|
||||
assert (e1_new, RDF.type, ont_type) in rewritten
|
||||
|
||||
|
||||
def test_merge_graphs_deduplicates_triples(graph_rewriter: GraphRewriter) -> None:
|
||||
graph1 = RDFGraph()
|
||||
graph2 = RDFGraph()
|
||||
e = URIRef("http://chunk1.org/e")
|
||||
p = URIRef("http://chunk1.org/p")
|
||||
value = Literal("value")
|
||||
|
||||
graph1.add((e, p, value))
|
||||
graph2.add((e, p, value))
|
||||
|
||||
merged = graph_rewriter.merge_graphs(
|
||||
[graph1, graph2],
|
||||
mapping={
|
||||
e: URIRef(f"{DEFAULT_IRI}/Entity"),
|
||||
p: URIRef(f"{DEFAULT_IRI}/hasValue"),
|
||||
},
|
||||
base_namespace=DEFAULT_IRI,
|
||||
)
|
||||
assert (
|
||||
len(list(merged.triples((URIRef(f"{DEFAULT_IRI}/Entity"), None, value)))) == 1
|
||||
)
|
||||
|
||||
|
||||
def test_rewrite_graph_adds_sameas_for_merged_entities(
|
||||
graph_rewriter: GraphRewriter,
|
||||
) -> None:
|
||||
graph_rewriter = GraphRewriter(add_sameas_links=True)
|
||||
graph = RDFGraph()
|
||||
e1 = URIRef("http://chunk1.org/e1")
|
||||
e2 = URIRef("http://chunk2.org/e2")
|
||||
p = URIRef("http://chunk1.org/p")
|
||||
canonical = URIRef(f"{DEFAULT_IRI}/Entity")
|
||||
|
||||
graph.add((e1, p, Literal("a")))
|
||||
graph.add((e2, p, Literal("b")))
|
||||
|
||||
rewritten = graph_rewriter.rewrite_graph(graph, {e1: canonical, e2: canonical})
|
||||
assert len(list(rewritten.triples((canonical, OWL.sameAs, None)))) >= 1
|
||||
|
||||
|
||||
def test_rewriter_blocks_sameas_for_forbidden_namespace() -> None:
|
||||
base = "https://growgraph.dev/facts"
|
||||
graph_rewriter = GraphRewriter(
|
||||
add_sameas_links=True,
|
||||
blocked_sameas_namespaces=(base,),
|
||||
)
|
||||
graph = RDFGraph()
|
||||
original_fact = URIRef("https://growgraph.dev/factsPersonA")
|
||||
original_doc = URIRef("https://example.org/docs/case-1/PersonA")
|
||||
canonical_doc = URIRef("https://example.org/docs/case-1/PersonCanonical")
|
||||
relation = URIRef("https://example.org/relation")
|
||||
graph.add((original_doc, relation, Literal("value")))
|
||||
|
||||
rewritten = graph_rewriter.rewrite_graph(
|
||||
graph,
|
||||
{
|
||||
original_doc: canonical_doc,
|
||||
original_fact: canonical_doc,
|
||||
},
|
||||
)
|
||||
|
||||
assert (canonical_doc, OWL.sameAs, original_doc) in rewritten
|
||||
assert (canonical_doc, OWL.sameAs, original_fact) not in rewritten
|
||||
@@ -0,0 +1,141 @@
|
||||
from rdflib import OWL, RDF, RDFS, URIRef
|
||||
|
||||
from ontocast.onto.constants import DEFAULT_IRI
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.tool.agg.normalizer import EntityRepresentation
|
||||
from ontocast.tool.agg.uri_builder import (
|
||||
EntityRole,
|
||||
URIBuilder,
|
||||
detect_role,
|
||||
format_structured_id,
|
||||
has_structured_id,
|
||||
normalize_local_name,
|
||||
to_lower_camel_case,
|
||||
to_pascal_case,
|
||||
)
|
||||
|
||||
|
||||
def make_representation(uri: str, normal_form: str) -> EntityRepresentation:
|
||||
return EntityRepresentation(
|
||||
entity=URIRef(uri),
|
||||
normal_form=normal_form,
|
||||
types=[],
|
||||
properties=[],
|
||||
labels=[],
|
||||
representation=normal_form,
|
||||
is_ontology_entity=False,
|
||||
)
|
||||
|
||||
|
||||
def test_pascal_case_and_lower_camel_helpers() -> None:
|
||||
assert to_pascal_case("judicial decision") == "JudicialDecision"
|
||||
assert to_pascal_case("case") == "Case"
|
||||
assert to_lower_camel_case("has decision") == "hasDecision"
|
||||
assert to_lower_camel_case("name") == "name"
|
||||
|
||||
|
||||
def test_structured_id_helpers() -> None:
|
||||
assert has_structured_id(URIRef("http://ex.org/Case_2023_456")) is True
|
||||
assert has_structured_id(URIRef("http://ex.org/Person")) is False
|
||||
assert (
|
||||
format_structured_id(URIRef("http://ex.org/case_2023_456")) == "Case_2023_456"
|
||||
)
|
||||
|
||||
|
||||
def test_detect_role_for_class_property_and_instance() -> None:
|
||||
graph = RDFGraph()
|
||||
class_entity = URIRef("http://ex.org/Person")
|
||||
prop_entity = URIRef("http://ex.org/hasAge")
|
||||
instance_entity = URIRef("http://ex.org/Alice")
|
||||
|
||||
graph.add((class_entity, RDF.type, RDFS.Class))
|
||||
graph.add((prop_entity, RDF.type, OWL.DatatypeProperty))
|
||||
graph.add((instance_entity, RDF.type, class_entity))
|
||||
|
||||
assert detect_role(class_entity, graph) == EntityRole.CLASS
|
||||
assert detect_role(prop_entity, graph) == EntityRole.PROPERTY
|
||||
assert detect_role(instance_entity, graph) == EntityRole.INSTANCE
|
||||
|
||||
|
||||
def test_normalize_local_name_uses_role_specific_formatting() -> None:
|
||||
class_rep = make_representation(
|
||||
"http://ex.org/JudicialDecision", "judicial decision"
|
||||
)
|
||||
prop_rep = make_representation("http://ex.org/hasDecision", "has decision")
|
||||
structured_rep = make_representation("http://ex.org/Case_2023_456", "case 2023 456")
|
||||
|
||||
assert normalize_local_name(class_rep, EntityRole.CLASS) == "JudicialDecision"
|
||||
assert normalize_local_name(prop_rep, EntityRole.PROPERTY) == "hasDecision"
|
||||
assert normalize_local_name(structured_rep, EntityRole.INSTANCE) == "Case_2023_456"
|
||||
|
||||
|
||||
def test_build_uri_preserves_ontology_entities(uri_builder: URIBuilder) -> None:
|
||||
entity = URIRef("http://ontology.org/Thing")
|
||||
rep = EntityRepresentation(
|
||||
entity=entity,
|
||||
normal_form="thing",
|
||||
types=[],
|
||||
properties=[],
|
||||
labels=[],
|
||||
representation="thing",
|
||||
is_ontology_entity=True,
|
||||
)
|
||||
assert uri_builder.build_uri(entity, rep, EntityRole.CLASS) == entity
|
||||
|
||||
|
||||
def test_compose_mappings_flattens_two_stage_mapping() -> None:
|
||||
e1 = URIRef("http://chunk1.org/A")
|
||||
e2 = URIRef("http://chunk2.org/B")
|
||||
representative = URIRef("http://chunk1.org/A")
|
||||
final = URIRef(f"{DEFAULT_IRI}/SomeEntity")
|
||||
|
||||
composed = URIBuilder.compose_mappings(
|
||||
{e1: representative, e2: representative},
|
||||
{representative: final},
|
||||
)
|
||||
|
||||
assert composed[e1] == final
|
||||
assert composed[e2] == final
|
||||
|
||||
|
||||
def test_create_entity_uri_mapping_uses_doc_namespace_and_avoids_collisions() -> None:
|
||||
builder = URIBuilder(base_iri=DEFAULT_IRI)
|
||||
doc_iri = URIRef("https://example.org/docs/case-1")
|
||||
left = URIRef("https://growgraph.dev/factsEntityA")
|
||||
right = URIRef("https://growgraph.dev/factsEntityB")
|
||||
left_canonical = URIRef("https://growgraph.dev/factsCanonicalA")
|
||||
right_canonical = URIRef("https://growgraph.dev/factsCanonicalB")
|
||||
|
||||
shared_representation = EntityRepresentation(
|
||||
entity=left_canonical,
|
||||
normal_form="collision",
|
||||
types=[],
|
||||
properties=[],
|
||||
labels=[],
|
||||
representation="collision",
|
||||
is_ontology_entity=False,
|
||||
)
|
||||
|
||||
representations = {
|
||||
left_canonical: shared_representation,
|
||||
right_canonical: EntityRepresentation(
|
||||
entity=right_canonical,
|
||||
normal_form="collision",
|
||||
types=[],
|
||||
properties=[],
|
||||
labels=[],
|
||||
representation="collision",
|
||||
is_ontology_entity=False,
|
||||
),
|
||||
}
|
||||
|
||||
mapping = builder.create_entity_uri_mapping(
|
||||
identity_mapping={left: left_canonical, right: right_canonical},
|
||||
representations=representations,
|
||||
entity_doc_iris={left: doc_iri, right: doc_iri},
|
||||
entity_is_ontology={left_canonical: False, right_canonical: False},
|
||||
)
|
||||
|
||||
assert str(mapping[left]).startswith(f"{doc_iri}/")
|
||||
assert str(mapping[right]).startswith(f"{doc_iri}/")
|
||||
assert mapping[left] != mapping[right]
|
||||
549
ontology_platform/vendored/ontocast/test/conftest.py
Normal file
549
ontology_platform/vendored/ontocast/test/conftest.py
Normal file
@@ -0,0 +1,549 @@
|
||||
"""Pytest configuration for test suite."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import pytest
|
||||
from suthing import FileHandle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_huggingface import HuggingFaceEmbeddings
|
||||
|
||||
from ontocast.config import (
|
||||
Config,
|
||||
LLMConfig,
|
||||
LLMProvider,
|
||||
OpenAIModel,
|
||||
PathConfig,
|
||||
ToolConfig,
|
||||
)
|
||||
from ontocast.onto.constants import DEFAULT_DOMAIN
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.onto.state import AgentState
|
||||
from ontocast.tool import (
|
||||
FilesystemTripleStoreManager,
|
||||
LLMTool,
|
||||
OntologyManager,
|
||||
)
|
||||
from ontocast.tool.triple_manager.mock import (
|
||||
MockFusekiTripleStoreManager,
|
||||
MockNeo4jTripleStoreManager,
|
||||
)
|
||||
from ontocast.toolbox import ToolBox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Suppress deprecation warnings from third-party libraries that we cannot control
|
||||
# Note: We adapt to new conventions where possible (e.g., using pyld directly for JSON-LD
|
||||
# instead of rdflib's deprecated ConjunctiveGraph). These suppressions are only for
|
||||
# warnings from external libraries that we cannot modify.
|
||||
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
category=DeprecationWarning,
|
||||
message=".*@model_validator.*mode='after'.*",
|
||||
module="docling_core",
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest to suppress known deprecation warnings from third-party libraries."""
|
||||
# Suppress Pydantic deprecation warnings from docling_core (third-party library we cannot modify)
|
||||
config.addinivalue_line(
|
||||
"filterwarnings",
|
||||
"ignore::DeprecationWarning:docling_core",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_domain():
|
||||
return os.getenv("CURRENT_DOMAIN", DEFAULT_DOMAIN)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def llm_base_url():
|
||||
return os.getenv("LLM_BASE_URL", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider():
|
||||
return os.getenv("LLM_PROVIDER", LLMProvider.OPENAI)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_name():
|
||||
return OpenAIModel(os.getenv("LLM_MODEL_NAME", OpenAIModel.GPT4_O_MINI))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temperature():
|
||||
return 0.1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_ontology():
|
||||
from ontocast.onto.ontology import Ontology
|
||||
|
||||
graph = RDFGraph._from_turtle_str(
|
||||
"""
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
||||
@prefix ex: <http://example.org/to/> .
|
||||
@prefix schema: <https://schema.org/> .
|
||||
@prefix dcterms: <http://purl.org/dc/terms/> .
|
||||
|
||||
ex: rdf:type owl:Ontology ;
|
||||
rdfs:label "Test Domain Ontology" ;
|
||||
dcterms:title "test_onto"^^rdf:XMLLiteral ;
|
||||
rdfs:comment "An ontology for testing that covers basic concepts and relationships in a test domain. Used for validating ontology processing functionality." .
|
||||
|
||||
ex:SpaceTimeEvent a rdfs:Class ;
|
||||
rdfs:label "Event" ;
|
||||
rdfs:comment "Some kind of event with spacetime coordinates" ;
|
||||
rdfs:subClassOf schema:Event . """
|
||||
)
|
||||
return Ontology(graph=graph)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ontology_path():
|
||||
return Path("data/ontologies")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def working_directory():
|
||||
return None
|
||||
# return Path("test/tmp")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def llm_tool(provider, model_name, temperature, llm_base_url):
|
||||
config = LLMConfig(
|
||||
provider=LLMProvider(provider),
|
||||
model_name=model_name,
|
||||
temperature=temperature,
|
||||
base_url=llm_base_url,
|
||||
)
|
||||
llm_tool = LLMTool.create(config=config)
|
||||
return llm_tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tsm_tool(ontology_path, working_directory):
|
||||
return FilesystemTripleStoreManager(
|
||||
working_directory=working_directory, ontology_path=ontology_path
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tools(
|
||||
ontology_path,
|
||||
working_directory,
|
||||
model_name,
|
||||
temperature,
|
||||
provider,
|
||||
llm_base_url,
|
||||
om_tool_fname,
|
||||
) -> ToolBox:
|
||||
# Create LLM config
|
||||
llm_config = LLMConfig(
|
||||
provider=LLMProvider(provider),
|
||||
model_name=model_name,
|
||||
temperature=temperature,
|
||||
base_url=llm_base_url,
|
||||
)
|
||||
|
||||
# Create path config
|
||||
path_config = PathConfig(
|
||||
working_directory=working_directory,
|
||||
ontology_directory=ontology_path,
|
||||
)
|
||||
|
||||
# Create tool config
|
||||
tool_config = ToolConfig(
|
||||
llm_config=llm_config,
|
||||
path_config=path_config,
|
||||
)
|
||||
|
||||
# Create main config
|
||||
config = Config(tool_config=tool_config)
|
||||
|
||||
tools: ToolBox = ToolBox(config=config)
|
||||
import asyncio
|
||||
|
||||
asyncio.run(tools.initialize())
|
||||
|
||||
# Load ontologies from JSON file if it exists (using Pydantic's load method)
|
||||
json_path = Path(om_tool_fname)
|
||||
if json_path.exists():
|
||||
try:
|
||||
loaded_om = OntologyManager.load(json_path)
|
||||
# Merge loaded ontologies into the toolbox's ontology manager
|
||||
for iri, versions in loaded_om.ontology_versions.items():
|
||||
for ontology in versions:
|
||||
tools.ontology_manager.add_ontology(ontology)
|
||||
except Exception:
|
||||
# Silently fail if JSON loading fails
|
||||
pass
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_chunked(state_chunked_filename):
|
||||
return AgentState.load(state_chunked_filename)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_ontology_selected(state_onto_selected_filename):
|
||||
return AgentState.load(state_onto_selected_filename)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_ontology_rendered(state_ontology_rendered_filename):
|
||||
return AgentState.load(state_ontology_rendered_filename)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_ontology_criticized(state_ontology_criticized_filename):
|
||||
return AgentState.load(state_ontology_criticized_filename)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_rendered_facts(state_rendered_facts_filename):
|
||||
return AgentState.load(state_rendered_facts_filename)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_sublimated(state_sublimated_filename):
|
||||
return AgentState.load(state_sublimated_filename)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_facts_failed(state_facts_failed_filename):
|
||||
return AgentState.load(state_facts_failed_filename)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_facts_success(state_facts_success_filename):
|
||||
return AgentState.load(state_facts_success_filename)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_state_select_ontology_null(state_onto_null_filename):
|
||||
return AgentState.load(state_onto_null_filename)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def om_tool(om_tool_fname):
|
||||
try:
|
||||
return OntologyManager.load(om_tool_fname)
|
||||
except (FileNotFoundError, Exception):
|
||||
return OntologyManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def max_iter():
|
||||
return 2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def apple_report():
|
||||
r = FileHandle.load(Path("data/json/fin.10Q.apple.json"))
|
||||
return {"text": r["text"]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def random_report():
|
||||
return FileHandle.load(Path("data/json/random.json"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_state_onto_fresh():
|
||||
return AgentState.load("test/data/state_onto_addendum.json")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def neo4j_uri():
|
||||
return os.environ.get("NEO4J_URI", "bolt://localhost:7687")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def neo4j_auth():
|
||||
return os.environ.get("NEO4J_AUTH", "neo4j/test")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def neo4j_triple_store_manager(neo4j_uri, neo4j_auth):
|
||||
"""Mock Neo4j triple store manager for testing."""
|
||||
return MockNeo4jTripleStoreManager(uri=neo4j_uri, auth=neo4j_auth, clean=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def fuseki_triple_store_manager():
|
||||
"""Mock Fuseki triple store manager for testing."""
|
||||
uri = os.environ.get("FUSEKI_URI", "http://localhost:3030/test")
|
||||
auth = os.environ.get("FUSEKI_AUTH", None)
|
||||
if auth and "/" in auth:
|
||||
auth = tuple(auth.split("/", 1))
|
||||
return MockFusekiTripleStoreManager(uri=uri, auth=auth, dataset="test", clean=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def real_embeddings() -> Optional["HuggingFaceEmbeddings"]:
|
||||
"""Fixture providing real HuggingFace embeddings if available, otherwise None.
|
||||
|
||||
Uses the same model as in split_chunks.py for consistency.
|
||||
Session-scoped so the model is loaded only once per test session and reused.
|
||||
"""
|
||||
|
||||
try:
|
||||
torch = importlib.import_module("torch")
|
||||
from langchain_huggingface import HuggingFaceEmbeddings
|
||||
|
||||
embeddings = HuggingFaceEmbeddings(
|
||||
model_name="sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
|
||||
model_kwargs={
|
||||
"device": "cuda"
|
||||
if torch is not None and torch.cuda.is_available()
|
||||
else "cpu"
|
||||
},
|
||||
encode_kwargs={"normalize_embeddings": False},
|
||||
)
|
||||
return embeddings
|
||||
except ImportError as e:
|
||||
logger.error(f"Could not import HuggingFaceEmbeddings: {e}")
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mock_embeddings():
|
||||
try:
|
||||
from langchain_core.embeddings import Embeddings
|
||||
except ImportError as e:
|
||||
logger.error(f"Could not import Embeddings: {e}")
|
||||
|
||||
class MockEmbeddings(Embeddings):
|
||||
"""Mock embeddings for testing.
|
||||
|
||||
Returns deterministic embeddings based on text content.
|
||||
"""
|
||||
|
||||
def __init__(self, embedding_dim: int = 384):
|
||||
"""Initialize mock embeddings.
|
||||
|
||||
Args:
|
||||
embedding_dim: Dimension of the embedding vectors. Defaults to 384.
|
||||
"""
|
||||
self.embedding_dim = embedding_dim
|
||||
# Simple hash-based embedding for deterministic results
|
||||
self._cache: dict[str, list[float]] = {}
|
||||
|
||||
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Generate embeddings for a list of texts."""
|
||||
return [self.embed_query(text) for text in texts]
|
||||
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
"""Generate an embedding for a single text."""
|
||||
if text in self._cache:
|
||||
return self._cache[text]
|
||||
|
||||
from ontocast.util import render_text_hash
|
||||
|
||||
hash_int = int(render_text_hash(text, digits=None), 16)
|
||||
|
||||
embedding = []
|
||||
for i in range(self.embedding_dim):
|
||||
val = (hash_int + i * 17) % 1000
|
||||
embedding.append((val / 1000.0) - 0.5)
|
||||
|
||||
self._cache[text] = embedding
|
||||
return embedding
|
||||
|
||||
return MockEmbeddings()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def embeddings(real_embeddings, mock_embeddings):
|
||||
"""Fixture providing embeddings - prefers real embeddings, falls back to mock.
|
||||
|
||||
Session-scoped so the model is loaded only once per test session.
|
||||
"""
|
||||
if real_embeddings is not None:
|
||||
return real_embeddings
|
||||
return mock_embeddings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_text():
|
||||
"""Fixture providing realistic sample text (~10k characters) from clinical trial JSON."""
|
||||
json_file = (
|
||||
Path(__file__).parent.parent
|
||||
/ "data"
|
||||
/ "json"
|
||||
/ "clinical.trials.NCT01239745.json"
|
||||
)
|
||||
if json_file.exists():
|
||||
data = json.load(open(json_file))
|
||||
|
||||
def json_to_md(data, depth=1):
|
||||
md = []
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (str, int, float, bool, type(None))):
|
||||
md.append(f"{key}: {value}\n")
|
||||
elif isinstance(value, dict):
|
||||
md.append(f"{key}:\n")
|
||||
md.extend(json_to_md(value, depth + 1))
|
||||
elif isinstance(value, list):
|
||||
md.append(f"{key}:\n")
|
||||
for item in value:
|
||||
if isinstance(item, (str, int, float, bool, type(None))):
|
||||
md.append(f" - {item}\n")
|
||||
else:
|
||||
md.extend(json_to_md(item, depth + 1))
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, (str, int, float, bool, type(None))):
|
||||
md.append(f"- {item}\n")
|
||||
else:
|
||||
md.extend(json_to_md(item, depth))
|
||||
return md
|
||||
|
||||
text_lines = json_to_md(data)
|
||||
text = "".join(text_lines)
|
||||
return text[:10000]
|
||||
|
||||
# Fallback
|
||||
return (
|
||||
"This is the first sentence. "
|
||||
"This is the second sentence. "
|
||||
"This is the third sentence. "
|
||||
"This is the fourth sentence. "
|
||||
"This is the fifth sentence. "
|
||||
"This is the sixth sentence. "
|
||||
"This is the seventh sentence. "
|
||||
"This is the eighth sentence. "
|
||||
"This is the ninth sentence. "
|
||||
"This is the tenth sentence."
|
||||
) * 100
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def long_text():
|
||||
"""Fixture providing longer text for testing min/max size constraints."""
|
||||
paragraphs = []
|
||||
for i in range(5):
|
||||
sentences = []
|
||||
for j in range(10):
|
||||
sentences.append(
|
||||
f"This is paragraph {i + 1}, sentence {j + 1}. "
|
||||
f"It contains some content to make it longer. "
|
||||
f"Here is more text to ensure we have enough characters."
|
||||
)
|
||||
paragraphs.append(" ".join(sentences))
|
||||
return "\n\n".join(paragraphs)
|
||||
|
||||
|
||||
# --- Aggregator test fixtures (used by test_aggregator.py) ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def normalizer():
|
||||
"""EntityNormalizer instance for aggregator tests."""
|
||||
from ontocast.tool.agg.normalizer import EntityNormalizer
|
||||
|
||||
return EntityNormalizer()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cluster_representative_selector():
|
||||
"""ClusterRepresentativeSelector instance for aggregator tests."""
|
||||
from ontocast.tool.agg.clustering import ClusterRepresentativeSelector
|
||||
|
||||
return ClusterRepresentativeSelector()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uri_builder():
|
||||
"""URIBuilder instance for aggregator tests."""
|
||||
from ontocast.tool.agg.uri_builder import URIBuilder
|
||||
|
||||
return URIBuilder()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph_rewriter():
|
||||
"""GraphRewriter instance for aggregator tests (add_sameas_links=True)."""
|
||||
from ontocast.tool.agg.rewriter import GraphRewriter
|
||||
|
||||
return GraphRewriter(add_sameas_links=False)
|
||||
|
||||
|
||||
def triple_store_roundtrip(manager, test_ontology):
|
||||
# test_ontology is already an Ontology object, use it directly
|
||||
ontology = test_ontology
|
||||
# Store ontology
|
||||
manager.serialize(ontology)
|
||||
# Fetch ontologies
|
||||
ontologies = manager.fetch_ontologies()
|
||||
# There should be at least one ontology with the correct ontology_id
|
||||
assert any(o.ontology_id == "to" for o in ontologies)
|
||||
# The ontology graph should have the same number of triples as the input
|
||||
assert len(ontologies[0].graph) == len(ontology.graph)
|
||||
|
||||
|
||||
def triple_store_serialize_facts(manager):
|
||||
"""Test serializing facts (RDF triples) to triple store and retrieving them."""
|
||||
# Create test facts
|
||||
facts = RDFGraph._from_turtle_str(
|
||||
"""
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
@prefix ex: <http://example.org/test/> .
|
||||
@prefix schema: <https://schema.org/> .
|
||||
|
||||
ex:Person a rdfs:Class ;
|
||||
rdfs:label "Person" ;
|
||||
rdfs:comment "A human being" .
|
||||
|
||||
ex:John a ex:Person ;
|
||||
rdfs:label "John Doe" ;
|
||||
schema:name "John Doe" ;
|
||||
schema:email "john@example.com" .
|
||||
|
||||
ex:Jane a ex:Person ;
|
||||
rdfs:label "Jane Smith" ;
|
||||
schema:name "Jane Smith" ;
|
||||
schema:email "jane@example.com" .
|
||||
|
||||
ex:knows a rdf:Property ;
|
||||
rdfs:label "knows" ;
|
||||
rdfs:comment "Relationship between people who know each other" .
|
||||
|
||||
ex:John ex:knows ex:Jane .
|
||||
"""
|
||||
)
|
||||
# Verify we have the expected number of triples
|
||||
expected_triple_count = len(facts)
|
||||
assert expected_triple_count == 15, "Test facts should contain triples"
|
||||
# Serialize facts to triple store
|
||||
result = manager.serialize(facts)
|
||||
assert result is not None, "serialize should return a result"
|
||||
|
||||
|
||||
def triple_store_serialize_empty_facts(manager):
|
||||
"""Test serializing empty facts graph."""
|
||||
# Create empty facts
|
||||
empty_facts = RDFGraph()
|
||||
# Serialize empty facts - should not raise an error
|
||||
result = manager.serialize(empty_facts)
|
||||
assert result is not None, "serialize should return a result even for empty graph"
|
||||
@@ -0,0 +1,214 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rdflib import URIRef
|
||||
|
||||
from ontocast.agent.criticise_facts import criticise_facts
|
||||
from ontocast.agent.criticise_ontology import criticise_ontology
|
||||
from ontocast.agent.render_facts import render_facts
|
||||
from ontocast.agent.render_ontology import render_ontology
|
||||
from ontocast.config import Config, LLMProvider
|
||||
from ontocast.onto.content_unit import ContentUnit
|
||||
from ontocast.onto.enum import Status
|
||||
from ontocast.onto.ontology import Ontology
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState
|
||||
from ontocast.toolbox import ToolBox
|
||||
|
||||
RUN_MANUAL_TESTS = os.getenv("ONTOCAST_RUN_MANUAL_TESTS", "0") == "1"
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(
|
||||
not RUN_MANUAL_TESTS,
|
||||
reason="Set ONTOCAST_RUN_MANUAL_TESTS=1 to run live manual tests.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _require_env(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if value is None or value.strip() == "":
|
||||
pytest.fail(f"Missing required environment variable: {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _create_tools_from_env() -> ToolBox:
|
||||
_ = _require_env("LLM_PROVIDER")
|
||||
_ = _require_env("LLM_MODEL_NAME")
|
||||
provider = LLMProvider(_require_env("LLM_PROVIDER").lower())
|
||||
if provider == LLMProvider.OPENAI:
|
||||
_ = _require_env("LLM_API_KEY")
|
||||
elif provider == LLMProvider.OLLAMA:
|
||||
_ = _require_env("LLM_BASE_URL")
|
||||
|
||||
_ = _require_env("ONTOCAST_WORKING_DIRECTORY")
|
||||
|
||||
config = Config()
|
||||
config.validate_llm_config()
|
||||
|
||||
if config.tool_config.path_config.working_directory is None:
|
||||
pytest.fail("ONTOCAST_WORKING_DIRECTORY must be set to run manual agent tests.")
|
||||
|
||||
config.tool_config.path_config.working_directory = Path(
|
||||
config.tool_config.path_config.working_directory
|
||||
).expanduser()
|
||||
config.tool_config.path_config.working_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if config.tool_config.path_config.ontology_directory is not None:
|
||||
config.tool_config.path_config.ontology_directory = Path(
|
||||
config.tool_config.path_config.ontology_directory
|
||||
).expanduser()
|
||||
|
||||
return ToolBox(config)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def live_tools() -> ToolBox:
|
||||
return _create_tools_from_env()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def realistic_text() -> str:
|
||||
return (
|
||||
"ACME Robotics announced that it signed a three-year collaboration with "
|
||||
"North Valley Hospital in Berlin to deploy autonomous delivery carts across "
|
||||
"seven departments. The pilot starts in May 2026 and is co-funded by the "
|
||||
"hospital innovation office and the regional health authority. "
|
||||
"The agreement names Dr. Lena Fischer as clinical lead and ACME CTO "
|
||||
"Rahul Mehta as technical lead. Success metrics include a 20 percent "
|
||||
"reduction in nurse walking distance, fewer late medication rounds, and "
|
||||
"weekly safety audits."
|
||||
)
|
||||
|
||||
|
||||
def _build_seed_ontology() -> Ontology:
|
||||
graph = RDFGraph()
|
||||
graph.parse(
|
||||
data="""
|
||||
@prefix ex: <https://example.com/health#> .
|
||||
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:health a owl:Ontology ;
|
||||
rdfs:label "Healthcare Collaboration Ontology" .
|
||||
|
||||
ex:Organization a rdfs:Class .
|
||||
ex:Hospital a rdfs:Class ; rdfs:subClassOf ex:Organization .
|
||||
ex:Company a rdfs:Class ; rdfs:subClassOf ex:Organization .
|
||||
ex:Person a rdfs:Class .
|
||||
ex:collaboratesWith a owl:ObjectProperty .
|
||||
ex:hasLead a owl:ObjectProperty .
|
||||
ex:locatedIn a owl:ObjectProperty .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
return Ontology(graph=graph, iri="https://example.com/health")
|
||||
|
||||
|
||||
def _build_content_unit(text: str, with_seed_facts: bool = False) -> ContentUnit:
|
||||
unit = ContentUnit(
|
||||
text=text,
|
||||
index=0,
|
||||
doc_iri=URIRef("https://example.com/doc/manual-live"),
|
||||
)
|
||||
if with_seed_facts:
|
||||
unit.graph.parse(
|
||||
data="""
|
||||
@prefix ex: <https://example.com/health#> .
|
||||
@prefix facts: <https://example.com/facts/> .
|
||||
facts:acme ex:collaboratesWith facts:north_valley_hospital .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
return unit
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_facts_live_llm(live_tools: ToolBox, realistic_text: str) -> None:
|
||||
state = UnitFactsState(
|
||||
content_unit=_build_content_unit(realistic_text),
|
||||
ontology_snapshot=_build_seed_ontology(),
|
||||
facts_user_instruction=(
|
||||
"Extract organizations, people, timeline details, and measurable targets."
|
||||
),
|
||||
)
|
||||
|
||||
result = await render_facts(state, live_tools.get_atomic_tools())
|
||||
|
||||
assert result.failure_stage is None
|
||||
assert result.status == Status.SUCCESS
|
||||
assert len(result.content_unit.graph) > 0
|
||||
assert result.budget_tracker.calls_count > 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_criticise_facts_live_llm(
|
||||
live_tools: ToolBox, realistic_text: str
|
||||
) -> None:
|
||||
state = UnitFactsState(
|
||||
content_unit=_build_content_unit(realistic_text),
|
||||
ontology_snapshot=_build_seed_ontology(),
|
||||
facts_user_instruction=(
|
||||
"Prioritize correct entities, relations, and measurable outcomes."
|
||||
),
|
||||
)
|
||||
rendered = await render_facts(state, live_tools.get_atomic_tools())
|
||||
assert len(rendered.content_unit.graph) > 0
|
||||
|
||||
critiqued = await criticise_facts(rendered, live_tools.get_atomic_tools())
|
||||
|
||||
assert (
|
||||
critiqued.failure_stage is None
|
||||
or critiqued.failure_stage.name == "FACTS_CRITIQUE"
|
||||
)
|
||||
assert critiqued.status in (Status.SUCCESS, Status.FAILED)
|
||||
assert critiqued.budget_tracker.calls_count > 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_ontology_live_llm(
|
||||
live_tools: ToolBox, realistic_text: str
|
||||
) -> None:
|
||||
null_ontology = Ontology()
|
||||
state = UnitOntologyState(
|
||||
content_unit=_build_content_unit(realistic_text),
|
||||
ontology_snapshot=null_ontology,
|
||||
ontology_user_instruction=(
|
||||
"Create a compact ontology for healthcare logistics collaboration."
|
||||
),
|
||||
)
|
||||
|
||||
result = await render_ontology(state, live_tools.get_atomic_tools())
|
||||
|
||||
assert result.failure_stage is None
|
||||
assert result.status == Status.SUCCESS
|
||||
assert not result.current_ontology.is_null()
|
||||
assert len(result.current_ontology.graph) > 0
|
||||
assert result.budget_tracker.calls_count > 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_criticise_ontology_live_llm(
|
||||
live_tools: ToolBox, realistic_text: str
|
||||
) -> None:
|
||||
null_ontology = Ontology()
|
||||
state = UnitOntologyState(
|
||||
content_unit=_build_content_unit(realistic_text),
|
||||
ontology_snapshot=null_ontology,
|
||||
ontology_user_instruction=(
|
||||
"Keep class hierarchy minimal and ensure relation naming consistency."
|
||||
),
|
||||
)
|
||||
rendered = await render_ontology(state, live_tools.get_atomic_tools())
|
||||
assert not rendered.current_ontology.is_null()
|
||||
assert len(rendered.current_ontology.graph) > 0
|
||||
|
||||
critiqued = await criticise_ontology(rendered, live_tools.get_atomic_tools())
|
||||
|
||||
assert (
|
||||
critiqued.failure_stage is None
|
||||
or critiqued.failure_stage.name == "ONTOLOGY_CRITIQUE"
|
||||
)
|
||||
assert critiqued.status in (Status.SUCCESS, Status.FAILED)
|
||||
assert critiqued.budget_tracker.calls_count > 0
|
||||
181
ontology_platform/vendored/ontocast/test/test_agent_facts.py
Normal file
181
ontology_platform/vendored/ontocast/test/test_agent_facts.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from rdflib import URIRef
|
||||
|
||||
from ontocast.onto.content_unit import ContentUnit
|
||||
from ontocast.onto.enum import FailureStage, Status
|
||||
from ontocast.onto.model import (
|
||||
FactsCritiqueReport,
|
||||
FactsRenderReport,
|
||||
SemanticTriplesFactsReport,
|
||||
TripleFix,
|
||||
)
|
||||
from ontocast.onto.ontology import Ontology
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.onto.unit_states import UnitFactsState
|
||||
from ontocast.tool.atomic import AtomicToolBox
|
||||
|
||||
criticise_facts_module = importlib.import_module("ontocast.agent.criticise_facts")
|
||||
render_facts_module = importlib.import_module("ontocast.agent.render_facts")
|
||||
|
||||
|
||||
def _build_content_unit(with_graph: bool = False) -> ContentUnit:
|
||||
unit = ContentUnit(
|
||||
text="Alice works for ACME.",
|
||||
index=0,
|
||||
doc_iri=URIRef("https://example.com/doc/d1"),
|
||||
)
|
||||
if with_graph:
|
||||
unit.graph.parse(
|
||||
data="""
|
||||
@prefix ex: <https://example.com/ns#> .
|
||||
ex:alice ex:worksFor ex:acme .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
return unit
|
||||
|
||||
|
||||
def _build_ontology() -> Ontology:
|
||||
ontology_graph = RDFGraph()
|
||||
ontology_graph.parse(
|
||||
data="""
|
||||
@prefix onto: <https://example.com/onto#> .
|
||||
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
||||
onto:CompanyOntology a owl:Ontology .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
return Ontology(graph=ontology_graph, iri="https://example.com/onto")
|
||||
|
||||
|
||||
def _build_tools() -> AtomicToolBox:
|
||||
async def get_llm_tool(_budget_tracker):
|
||||
return object()
|
||||
|
||||
return cast(AtomicToolBox, SimpleNamespace(get_llm_tool=get_llm_tool))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_facts_routes_to_fresh_when_graph_is_empty(monkeypatch) -> None:
|
||||
calls = {"fresh": 0, "update": 0}
|
||||
|
||||
async def fake_fresh(state: UnitFactsState, tools) -> UnitFactsState:
|
||||
calls["fresh"] += 1
|
||||
return state
|
||||
|
||||
async def fake_update(state: UnitFactsState, tools) -> UnitFactsState:
|
||||
calls["update"] += 1
|
||||
return state
|
||||
|
||||
monkeypatch.setattr(render_facts_module, "render_facts_fresh", fake_fresh)
|
||||
monkeypatch.setattr(render_facts_module, "render_facts_update", fake_update)
|
||||
|
||||
state = UnitFactsState(
|
||||
content_unit=_build_content_unit(with_graph=False),
|
||||
ontology_snapshot=_build_ontology(),
|
||||
)
|
||||
result = await render_facts_module.render_facts(state, tools=_build_tools())
|
||||
|
||||
assert result is state
|
||||
assert calls["fresh"] == 1
|
||||
assert calls["update"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_facts_fresh_sets_success_and_budget(monkeypatch) -> None:
|
||||
async def fake_call_llm_with_retry(**kwargs):
|
||||
rendered_graph = RDFGraph()
|
||||
rendered_graph.parse(
|
||||
data="""
|
||||
@prefix ex: <https://example.com/ns#> .
|
||||
ex:alice ex:worksFor ex:acme .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
return FactsRenderReport(
|
||||
facts_report=SemanticTriplesFactsReport(
|
||||
semantic_graph=rendered_graph,
|
||||
ontology_relevance_score=95,
|
||||
triples_generation_score=94,
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
render_facts_module, "call_llm_with_retry", fake_call_llm_with_retry
|
||||
)
|
||||
|
||||
state = UnitFactsState(
|
||||
content_unit=_build_content_unit(with_graph=False),
|
||||
ontology_snapshot=_build_ontology(),
|
||||
)
|
||||
result = await render_facts_module.render_facts_fresh(state, tools=_build_tools())
|
||||
|
||||
assert result.status == Status.SUCCESS
|
||||
assert result.failure_stage is None
|
||||
assert len(result.content_unit.graph) == 1
|
||||
assert result.budget_tracker.facts_operations_count == 1
|
||||
assert result.budget_tracker.facts_triples_generated == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_criticise_facts_marks_failed_and_sets_suggestions(monkeypatch) -> None:
|
||||
async def fake_call_llm_with_retry(**kwargs):
|
||||
return FactsCritiqueReport(
|
||||
success=False,
|
||||
score=35,
|
||||
actionable_triple_fixes=[
|
||||
TripleFix(
|
||||
text_fragment="Alice works for ACME.",
|
||||
action="ADD",
|
||||
severity="important",
|
||||
explanation="Missing employment relation triple.",
|
||||
correct_value="ex:alice ex:worksFor ex:acme .",
|
||||
)
|
||||
],
|
||||
systemic_critique_summary="Misses key relations.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
criticise_facts_module, "call_llm_with_retry", fake_call_llm_with_retry
|
||||
)
|
||||
|
||||
state = UnitFactsState(
|
||||
content_unit=_build_content_unit(with_graph=True),
|
||||
ontology_snapshot=_build_ontology(),
|
||||
)
|
||||
result = await criticise_facts_module.criticise_facts(state, tools=_build_tools())
|
||||
|
||||
assert result.status == Status.FAILED
|
||||
assert result.failure_stage == FailureStage.FACTS_CRITIQUE
|
||||
assert len(result.suggestions.actionable_fixes) == 1
|
||||
assert result.failure_reason == "Facts Critic suggests improvements"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_criticise_facts_accepts_high_score_even_when_success_false(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
async def fake_call_llm_with_retry(**kwargs):
|
||||
return FactsCritiqueReport(
|
||||
success=False,
|
||||
score=95,
|
||||
actionable_triple_fixes=[],
|
||||
systemic_critique_summary="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
criticise_facts_module, "call_llm_with_retry", fake_call_llm_with_retry
|
||||
)
|
||||
|
||||
state = UnitFactsState(
|
||||
content_unit=_build_content_unit(with_graph=True),
|
||||
ontology_snapshot=_build_ontology(),
|
||||
)
|
||||
result = await criticise_facts_module.criticise_facts(state, tools=_build_tools())
|
||||
|
||||
assert result.status == Status.SUCCESS
|
||||
assert result.failure_stage is None
|
||||
@@ -0,0 +1,16 @@
|
||||
from ontocast.config import AggregationConfig, Config
|
||||
|
||||
|
||||
def test_aggregation_config_defaults() -> None:
|
||||
config = AggregationConfig()
|
||||
assert config.embedding_model == "paraphrase-multilingual-MiniLM-L12-v2"
|
||||
assert config.similarity_threshold == 0.80
|
||||
|
||||
|
||||
def test_aggregation_config_reads_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AGG_EMBEDDING_MODEL", "all-MiniLM-L6-v2")
|
||||
monkeypatch.setenv("AGG_SIMILARITY_THRESHOLD", "0.73")
|
||||
|
||||
config = Config()
|
||||
assert config.tool_config.aggregation.embedding_model == "all-MiniLM-L6-v2"
|
||||
assert config.tool_config.aggregation.similarity_threshold == 0.73
|
||||
527
ontology_platform/vendored/ontocast/test/test_graph_update.py
Normal file
527
ontology_platform/vendored/ontocast/test/test_graph_update.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""Test for GraphUpdate SPARQL query generation and execution.
|
||||
|
||||
This test verifies that GraphUpdate.generate_sparql_queries() generates valid SPARQL
|
||||
queries that can be executed on RDFGraph instances using rdflib's update() method.
|
||||
"""
|
||||
|
||||
from rdflib import Literal, URIRef
|
||||
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.onto.sparql_models import (
|
||||
GenericSparqlQuery,
|
||||
GraphUpdate,
|
||||
TripleOp,
|
||||
)
|
||||
|
||||
|
||||
def test_rdfgraph_recovers_dangling_semicolon_at_eof() -> None:
|
||||
"""RDFGraph should recover from common LLM-truncated Turtle at EOF."""
|
||||
ttl = """
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
|
||||
ex:Case85_968 a ex:Appeal ;
|
||||
ex:appealsTo ex:Cassation ;
|
||||
"""
|
||||
|
||||
graph = RDFGraph._from_turtle_str(ttl)
|
||||
|
||||
assert len(graph) == 2
|
||||
assert (
|
||||
URIRef("http://example.org/Case85_968"),
|
||||
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
|
||||
URIRef("http://example.org/Appeal"),
|
||||
) in graph
|
||||
assert (
|
||||
URIRef("http://example.org/Case85_968"),
|
||||
URIRef("http://example.org/appealsTo"),
|
||||
URIRef("http://example.org/Cassation"),
|
||||
) in graph
|
||||
|
||||
|
||||
def test_graph_update_with_language_tags():
|
||||
"""Test GraphUpdate with language-tagged literals."""
|
||||
# Create initial RDFGraph
|
||||
graph = RDFGraph._from_turtle_str(
|
||||
"""
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
@prefix ex: <http://example.org/> .
|
||||
|
||||
ex:Test a rdfs:Class .
|
||||
"""
|
||||
)
|
||||
|
||||
initial_triple_count = len(graph)
|
||||
|
||||
# Create Turtle with language-tagged literals
|
||||
triples = """
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:Test rdfs:label "Test Label"@en ;
|
||||
rdfs:comment "Un commentaire"@fr .
|
||||
"""
|
||||
|
||||
graph_update = GraphUpdate(
|
||||
triple_operations=[
|
||||
TripleOp(
|
||||
type="insert",
|
||||
graph=triples, # type: ignore[arg-type]
|
||||
prefixes={"ex": "http://example.org/"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Generate SPARQL queries
|
||||
queries = graph_update.generate_sparql_queries()
|
||||
|
||||
# Should generate one query
|
||||
assert len(queries) == 1
|
||||
|
||||
# Execute the query on the graph
|
||||
graph.update(queries[0])
|
||||
|
||||
# Verify new triples were added
|
||||
assert len(graph) == initial_triple_count + 2
|
||||
|
||||
|
||||
def test_graph_update_insert_operation():
|
||||
"""Test GraphUpdate with TripleOp insert operations using Turtle format."""
|
||||
# Create initial RDFGraph
|
||||
graph = RDFGraph._from_turtle_str(
|
||||
"""
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:Person a rdfs:Class ;
|
||||
rdfs:label "Person" .
|
||||
"""
|
||||
)
|
||||
|
||||
initial_triple_count = len(graph)
|
||||
|
||||
# Create triples in Turtle format
|
||||
triples = """
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:John a ex:Person ;
|
||||
rdfs:label "John Doe" .
|
||||
"""
|
||||
|
||||
graph_update = GraphUpdate(
|
||||
triple_operations=[
|
||||
TripleOp(
|
||||
type="insert",
|
||||
graph=triples, # type: ignore[arg-type]
|
||||
prefixes={"ex": "http://example.org/"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Generate SPARQL queries
|
||||
queries = graph_update.generate_sparql_queries()
|
||||
|
||||
# Should generate one query
|
||||
assert len(queries) == 1
|
||||
|
||||
# Execute the query on the graph
|
||||
graph.update(queries[0])
|
||||
|
||||
# Verify new triples were added
|
||||
assert len(graph) == initial_triple_count + 2
|
||||
assert (
|
||||
URIRef("http://example.org/John"),
|
||||
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
|
||||
URIRef("http://example.org/Person"),
|
||||
) in graph
|
||||
assert (
|
||||
URIRef("http://example.org/John"),
|
||||
URIRef("http://www.w3.org/2000/01/rdf-schema#label"),
|
||||
Literal("John Doe"),
|
||||
) in graph
|
||||
|
||||
|
||||
def test_graph_update_extract_insert_graph() -> None:
|
||||
"""Test GraphUpdate.extract_insert_graph returns only insert triples."""
|
||||
insert_ttl = """
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
ex:Person a rdfs:Class .
|
||||
ex:Person rdfs:label "Person" .
|
||||
"""
|
||||
delete_ttl = """
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
ex:Obsolete a rdfs:Class .
|
||||
"""
|
||||
gu = GraphUpdate(
|
||||
triple_operations=[
|
||||
TripleOp(type="insert", graph=insert_ttl), # type: ignore[arg-type]
|
||||
TripleOp(type="delete", graph=delete_ttl), # type: ignore[arg-type]
|
||||
]
|
||||
)
|
||||
insert_graph = gu.extract_insert_graph()
|
||||
assert len(insert_graph) == 2
|
||||
person_uri = URIRef("http://example.org/Person")
|
||||
rdf_type = URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type")
|
||||
rdfs_class = URIRef("http://www.w3.org/2000/01/rdf-schema#Class")
|
||||
rdfs_label = URIRef("http://www.w3.org/2000/01/rdf-schema#label")
|
||||
assert (person_uri, rdf_type, rdfs_class) in insert_graph
|
||||
assert (person_uri, rdfs_label, Literal("Person")) in insert_graph
|
||||
obsolete_uri = URIRef("http://example.org/Obsolete")
|
||||
assert (obsolete_uri, rdf_type, rdfs_class) not in insert_graph
|
||||
|
||||
|
||||
def test_graph_update_delete_operation():
|
||||
"""Test GraphUpdate with TripleOp delete operations."""
|
||||
# Create RDFGraph with existing triples
|
||||
graph = RDFGraph._from_turtle_str(
|
||||
"""
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:Person a rdfs:Class ;
|
||||
rdfs:label "Person" .
|
||||
|
||||
ex:John a ex:Person ;
|
||||
rdfs:label "John Doe" .
|
||||
|
||||
ex:Jane a ex:Person ;
|
||||
rdfs:label "Jane Smith" .
|
||||
"""
|
||||
)
|
||||
|
||||
initial_triple_count = len(graph)
|
||||
|
||||
# Create GraphUpdate with TripleOp using Turtle format
|
||||
triples = """
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:John a ex:Person ;
|
||||
rdfs:label "John Doe" .
|
||||
"""
|
||||
|
||||
graph_update = GraphUpdate(
|
||||
triple_operations=[
|
||||
TripleOp(
|
||||
type="delete",
|
||||
graph=triples, # type: ignore[arg-type]
|
||||
prefixes={"ex": "http://example.org/"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Generate SPARQL queries
|
||||
queries = graph_update.generate_sparql_queries()
|
||||
|
||||
# Should generate one query
|
||||
assert len(queries) == 1
|
||||
|
||||
# Execute the query on the graph
|
||||
graph.update(queries[0])
|
||||
|
||||
# Verify triples were removed
|
||||
assert len(graph) == initial_triple_count - 2
|
||||
assert (
|
||||
URIRef("http://example.org/John"),
|
||||
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
|
||||
URIRef("http://example.org/Person"),
|
||||
) not in graph
|
||||
assert (
|
||||
URIRef("http://example.org/John"),
|
||||
URIRef("http://www.w3.org/2000/01/rdf-schema#label"),
|
||||
Literal("John Doe"),
|
||||
) not in graph
|
||||
# Jane should still be there
|
||||
assert (
|
||||
URIRef("http://example.org/Jane"),
|
||||
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
|
||||
URIRef("http://example.org/Person"),
|
||||
) in graph
|
||||
|
||||
|
||||
def test_graph_update_with_prefixes():
|
||||
"""Test GraphUpdate with TripleOp operations that declare custom prefixes."""
|
||||
# Create initial RDFGraph
|
||||
graph = RDFGraph._from_turtle_str(
|
||||
"""
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
|
||||
ex:Person a rdf:Class .
|
||||
"""
|
||||
)
|
||||
|
||||
initial_triple_count = len(graph)
|
||||
|
||||
# Create GraphUpdate with custom prefixes using Turtle format
|
||||
triples = """
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix schema: <https://schema.org/> .
|
||||
|
||||
ex:John a ex:Person ;
|
||||
schema:name "John Doe" .
|
||||
"""
|
||||
|
||||
graph_update = GraphUpdate(
|
||||
triple_operations=[
|
||||
TripleOp(
|
||||
type="insert",
|
||||
graph=triples, # type: ignore[arg-type]
|
||||
prefixes={
|
||||
"ex": "http://example.org/",
|
||||
"schema": "https://schema.org/",
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Generate SPARQL queries
|
||||
queries = graph_update.generate_sparql_queries()
|
||||
|
||||
# Should generate one query
|
||||
assert len(queries) == 1
|
||||
|
||||
# Verify the query includes PREFIX declarations
|
||||
assert "PREFIX schema: <https://schema.org/>" in queries[0]
|
||||
|
||||
# Execute the query on the graph
|
||||
graph.update(queries[0])
|
||||
|
||||
# Verify new triples were added
|
||||
assert len(graph) == initial_triple_count + 2
|
||||
assert (
|
||||
URIRef("http://example.org/John"),
|
||||
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
|
||||
URIRef("http://example.org/Person"),
|
||||
) in graph
|
||||
assert (
|
||||
URIRef("http://example.org/John"),
|
||||
URIRef("https://schema.org/name"),
|
||||
Literal("John Doe"),
|
||||
) in graph
|
||||
|
||||
|
||||
def test_graph_update_mixed_operations_ordered():
|
||||
"""Test GraphUpdate with mixed operations in specific order."""
|
||||
# Create initial RDFGraph
|
||||
graph = RDFGraph._from_turtle_str(
|
||||
"""
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:Person a rdfs:Class ;
|
||||
rdfs:label "Person" .
|
||||
|
||||
ex:John a ex:Person ;
|
||||
rdfs:label "John Doe" .
|
||||
"""
|
||||
)
|
||||
|
||||
initial_triple_count = len(graph)
|
||||
|
||||
# Create GraphUpdate with mixed operations using Turtle format
|
||||
insert_jane = """
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix schema: <https://schema.org/> .
|
||||
|
||||
ex:Jane a ex:Person ;
|
||||
schema:name "Jane Smith" .
|
||||
"""
|
||||
delete_john_label = """
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:John rdfs:label "John Doe" .
|
||||
"""
|
||||
insert_john_label = """
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:John rdfs:label "John Updated" .
|
||||
"""
|
||||
|
||||
graph_update = GraphUpdate(
|
||||
triple_operations=[
|
||||
# First: Insert new person with custom schema prefix
|
||||
TripleOp(
|
||||
type="insert",
|
||||
graph=insert_jane, # type: ignore[arg-type]
|
||||
prefixes={
|
||||
"ex": "http://example.org/",
|
||||
"schema": "https://schema.org/",
|
||||
},
|
||||
),
|
||||
# Second: Delete John's label
|
||||
TripleOp(
|
||||
type="delete",
|
||||
graph=delete_john_label, # type: ignore[arg-type]
|
||||
prefixes={"ex": "http://example.org/"},
|
||||
),
|
||||
# Third: Insert new label for John
|
||||
TripleOp(
|
||||
type="insert",
|
||||
graph=insert_john_label, # type: ignore[arg-type]
|
||||
prefixes={"ex": "http://example.org/"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Generate SPARQL queries
|
||||
queries = graph_update.generate_sparql_queries()
|
||||
|
||||
# Should generate 3 queries (one for each TripleOp)
|
||||
assert len(queries) == 3
|
||||
|
||||
# Execute queries in order
|
||||
for query in queries:
|
||||
graph.update(query)
|
||||
|
||||
# Verify final state
|
||||
# Should have: 4 initial + 2 added (Jane) - 1 deleted (John's old label) + 1 added (John's new label) = 6 triples
|
||||
assert (
|
||||
len(graph) == initial_triple_count + 2
|
||||
) # +2 net change: +2 for Jane, -1 for John's old label, +1 for John's new label
|
||||
|
||||
# Verify John's label was updated
|
||||
assert (
|
||||
URIRef("http://example.org/John"),
|
||||
URIRef("http://www.w3.org/2000/01/rdf-schema#label"),
|
||||
Literal("John Updated"),
|
||||
) in graph
|
||||
assert (
|
||||
URIRef("http://example.org/John"),
|
||||
URIRef("http://www.w3.org/2000/01/rdf-schema#label"),
|
||||
Literal("John Doe"),
|
||||
) not in graph
|
||||
|
||||
# Verify Jane was added
|
||||
assert (
|
||||
URIRef("http://example.org/Jane"),
|
||||
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
|
||||
URIRef("http://example.org/Person"),
|
||||
) in graph
|
||||
assert (
|
||||
URIRef("http://example.org/Jane"),
|
||||
URIRef("https://schema.org/name"),
|
||||
Literal("Jane Smith"),
|
||||
) in graph
|
||||
|
||||
|
||||
def test_graph_update_generic_sparql_query():
|
||||
"""Test GraphUpdate with GenericSparqlQuery operation."""
|
||||
# Create initial RDFGraph
|
||||
graph = RDFGraph._from_turtle_str(
|
||||
"""
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
ex:Person a rdfs:Class ;
|
||||
rdfs:label "Person" .
|
||||
|
||||
ex:John a ex:Person ;
|
||||
rdfs:label "John Doe" .
|
||||
"""
|
||||
)
|
||||
|
||||
initial_triple_count = len(graph)
|
||||
|
||||
# Create GraphUpdate with GenericSparqlQuery
|
||||
# Note: GenericSparqlQuery handles its own prefix declarations
|
||||
graph_update = GraphUpdate(
|
||||
sparql_operations=[
|
||||
GenericSparqlQuery(
|
||||
query="PREFIX ex: <http://example.org/>\nPREFIX schema: <https://schema.org/>\nPREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>\nINSERT { ex:John schema:age 30 } WHERE { ex:John rdf:type ex:Person }"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Generate SPARQL queries
|
||||
queries = graph_update.generate_sparql_queries()
|
||||
|
||||
# Should generate one query
|
||||
assert len(queries) == 1
|
||||
|
||||
# Verify the query includes the custom SPARQL with prefixes
|
||||
assert "INSERT { ex:John schema:age 30 }" in queries[0]
|
||||
assert "WHERE { ex:John rdf:type ex:Person }" in queries[0]
|
||||
|
||||
# Execute the query on the graph
|
||||
graph.update(queries[0])
|
||||
|
||||
# Verify the custom query was executed
|
||||
assert len(graph) == initial_triple_count + 1
|
||||
assert (
|
||||
URIRef("http://example.org/John"),
|
||||
URIRef("https://schema.org/age"),
|
||||
Literal(30),
|
||||
) in graph
|
||||
|
||||
|
||||
def test_graph_update_empty_operations():
|
||||
"""Test GraphUpdate with empty operations list."""
|
||||
graph = RDFGraph._from_turtle_str(
|
||||
"""
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
|
||||
ex:Person a rdf:Class .
|
||||
"""
|
||||
)
|
||||
|
||||
initial_triple_count = len(graph)
|
||||
|
||||
# Create GraphUpdate with empty operations
|
||||
graph_update = GraphUpdate(triple_operations=[])
|
||||
|
||||
# Generate SPARQL queries
|
||||
queries = graph_update.generate_sparql_queries()
|
||||
|
||||
# Should generate no queries
|
||||
assert len(queries) == 0
|
||||
|
||||
# Graph should remain unchanged
|
||||
assert len(graph) == initial_triple_count
|
||||
|
||||
|
||||
def test_graph_update_operations_with_empty_triples():
|
||||
"""Test GraphUpdate with operations that have empty triples lists."""
|
||||
graph = RDFGraph._from_turtle_str(
|
||||
"""
|
||||
@prefix ex: <http://example.org/> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
|
||||
ex:Person a rdf:Class .
|
||||
"""
|
||||
)
|
||||
|
||||
initial_triple_count = len(graph)
|
||||
|
||||
# Create GraphUpdate with operations that have empty triples
|
||||
graph_update = GraphUpdate(
|
||||
triple_operations=[
|
||||
TripleOp(type="insert", graph=RDFGraph()),
|
||||
TripleOp(type="delete", graph=RDFGraph()),
|
||||
]
|
||||
)
|
||||
|
||||
# Generate SPARQL queries
|
||||
queries = graph_update.generate_sparql_queries()
|
||||
|
||||
# Should generate no queries (empty triples are skipped)
|
||||
assert len(queries) == 0
|
||||
|
||||
# Graph should remain unchanged
|
||||
assert len(graph) == initial_triple_count
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Tests for ontology merging functionality."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from rdflib import DCTERMS, OWL, PROV, RDF, RDFS, Literal, URIRef
|
||||
|
||||
from ontocast.onto.ontology import Ontology
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.tool.ontology_manager import OntologyManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ontology_manager():
|
||||
"""Create an ontology manager for testing."""
|
||||
return OntologyManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_ontology():
|
||||
"""Create a base ontology for testing."""
|
||||
graph = RDFGraph()
|
||||
iri = URIRef("http://example.org/test")
|
||||
graph.add((iri, RDF.type, OWL.Ontology))
|
||||
graph.add((iri, RDFS.label, Literal("Test Ontology")))
|
||||
|
||||
# Add some classes
|
||||
class1 = URIRef("http://example.org/test#Class1")
|
||||
graph.add((class1, RDF.type, OWL.Class))
|
||||
graph.add((class1, RDFS.label, Literal("Class 1")))
|
||||
|
||||
ontology = Ontology(
|
||||
graph=graph,
|
||||
iri=str(iri),
|
||||
ontology_id="test",
|
||||
title="Test Ontology",
|
||||
version="1.0.0",
|
||||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
# Ensure hash is computed
|
||||
if not ontology.hash:
|
||||
ontology._compute_and_set_hash()
|
||||
return ontology
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def branch1_ontology(base_ontology):
|
||||
"""Create a branch 1 ontology (child of base)."""
|
||||
# Create a copy of the base graph
|
||||
graph = base_ontology.graph.copy()
|
||||
|
||||
# Add new class
|
||||
class2 = URIRef("http://example.org/test#Class2")
|
||||
graph.add((class2, RDF.type, OWL.Class))
|
||||
graph.add((class2, RDFS.label, Literal("Class 2")))
|
||||
|
||||
ontology = Ontology(
|
||||
graph=graph,
|
||||
iri=base_ontology.iri,
|
||||
ontology_id=base_ontology.ontology_id,
|
||||
title=base_ontology.title,
|
||||
version="1.1.0",
|
||||
parent_hashes=[base_ontology.hash] if base_ontology.hash else [],
|
||||
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
|
||||
)
|
||||
return ontology
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def branch2_ontology(base_ontology):
|
||||
"""Create a branch 2 ontology (child of base)."""
|
||||
# Create a copy of the base graph
|
||||
graph = base_ontology.graph.copy()
|
||||
|
||||
# Add different new class
|
||||
class3 = URIRef("http://example.org/test#Class3")
|
||||
graph.add((class3, RDF.type, OWL.Class))
|
||||
graph.add((class3, RDFS.label, Literal("Class 3")))
|
||||
|
||||
ontology = Ontology(
|
||||
graph=graph,
|
||||
iri=base_ontology.iri,
|
||||
ontology_id=base_ontology.ontology_id,
|
||||
title=base_ontology.title,
|
||||
version="1.2.0",
|
||||
parent_hashes=[base_ontology.hash] if base_ontology.hash else [],
|
||||
created_at=datetime(2024, 1, 3, tzinfo=timezone.utc),
|
||||
)
|
||||
return ontology
|
||||
|
||||
|
||||
def test_merge_ontologies_basic(ontology_manager, branch1_ontology, branch2_ontology):
|
||||
"""Test basic ontology merging."""
|
||||
from ontocast.onto.ontology_operations import merge_ontologies
|
||||
|
||||
# Ensure hashes are computed
|
||||
if not branch1_ontology.hash:
|
||||
branch1_ontology._compute_and_set_hash()
|
||||
if not branch2_ontology.hash:
|
||||
branch2_ontology._compute_and_set_hash()
|
||||
|
||||
# Merge
|
||||
merged = merge_ontologies(branch1_ontology, branch2_ontology)
|
||||
|
||||
# Check that merged ontology has both parents
|
||||
assert merged.parent_hashes == [branch1_ontology.hash, branch2_ontology.hash]
|
||||
assert merged.iri == branch1_ontology.iri
|
||||
assert merged.created_at is not None
|
||||
assert merged.hash is not None
|
||||
|
||||
# Check that merged graph contains content triples from both (excluding metadata)
|
||||
# Metadata (version, title, description, created_at, hash, parent_hash) is not compared
|
||||
# as it may differ in the merged ontology
|
||||
def get_content_triples(graph, onto_iri):
|
||||
"""Get content triples (excluding metadata) from a graph."""
|
||||
content_triples = set()
|
||||
onto_iri_ref = URIRef(onto_iri)
|
||||
for s, p, o in graph:
|
||||
# Skip metadata triples for the ontology IRI
|
||||
if s == onto_iri_ref:
|
||||
if (
|
||||
p == DCTERMS.identifier
|
||||
and isinstance(o, Literal)
|
||||
and str(o).startswith("hash:")
|
||||
):
|
||||
continue
|
||||
if p == PROV.wasDerivedFrom:
|
||||
continue
|
||||
if p == DCTERMS.created:
|
||||
continue
|
||||
if p == OWL.versionInfo:
|
||||
continue
|
||||
if p == RDFS.label:
|
||||
continue
|
||||
if p == DCTERMS.title:
|
||||
continue
|
||||
if p == DCTERMS.description:
|
||||
continue
|
||||
if p == RDFS.comment:
|
||||
continue
|
||||
content_triples.add((s, p, o))
|
||||
return content_triples
|
||||
|
||||
branch1_content = get_content_triples(branch1_ontology.graph, branch1_ontology.iri)
|
||||
branch2_content = get_content_triples(branch2_ontology.graph, branch2_ontology.iri)
|
||||
merged_content = get_content_triples(merged.graph, merged.iri)
|
||||
|
||||
# All content triples from both branches should be in merged
|
||||
assert branch1_content.issubset(merged_content), (
|
||||
f"Missing triples from branch1: {branch1_content - merged_content}"
|
||||
)
|
||||
assert branch2_content.issubset(merged_content), (
|
||||
f"Missing triples from branch2: {branch2_content - merged_content}"
|
||||
)
|
||||
|
||||
|
||||
def test_merge_ontologies_with_contradictions(ontology_manager):
|
||||
"""Test merging ontologies with contradictions."""
|
||||
from ontocast.onto.ontology_operations import merge_ontologies
|
||||
|
||||
# Create two ontologies with conflicting property values
|
||||
graph1 = RDFGraph()
|
||||
iri = URIRef("http://example.org/test")
|
||||
graph1.add((iri, RDF.type, OWL.Ontology))
|
||||
class1 = URIRef("http://example.org/test#Class1")
|
||||
graph1.add((class1, RDF.type, OWL.Class))
|
||||
graph1.add((class1, RDFS.label, Literal("Class One"))) # Different label
|
||||
|
||||
graph2 = RDFGraph()
|
||||
graph2.add((iri, RDF.type, OWL.Ontology))
|
||||
graph2.add((class1, RDF.type, OWL.Class))
|
||||
graph2.add((class1, RDFS.label, Literal("Class 1"))) # Different label
|
||||
|
||||
onto1 = Ontology(
|
||||
graph=graph1,
|
||||
iri=str(iri),
|
||||
ontology_id="test",
|
||||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
onto2 = Ontology(
|
||||
graph=graph2,
|
||||
iri=str(iri),
|
||||
ontology_id="test",
|
||||
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Merge should succeed (both values kept in RDF)
|
||||
merged = merge_ontologies(onto1, onto2)
|
||||
|
||||
# Both label values should be in merged graph
|
||||
labels = [o for _, _, o in merged.graph.triples((class1, RDFS.label, None))]
|
||||
assert len(labels) == 2
|
||||
label_strings = {str(label) for label in labels}
|
||||
assert "Class One" in label_strings or '"Class One"' in label_strings
|
||||
assert "Class 1" in label_strings or '"Class 1"' in label_strings
|
||||
|
||||
|
||||
def test_merge_terminal_ontologies_pairwise(
|
||||
ontology_manager, base_ontology, branch1_ontology, branch2_ontology
|
||||
):
|
||||
"""Test merging terminal ontologies pair-wise."""
|
||||
from ontocast.onto.ontology_operations import merge_ontologies
|
||||
|
||||
# Add all ontologies to manager
|
||||
ontology_manager.add_ontology(base_ontology)
|
||||
ontology_manager.add_ontology(branch1_ontology)
|
||||
ontology_manager.add_ontology(branch2_ontology)
|
||||
|
||||
# Get terminal ontologies (should be branch1 and branch2)
|
||||
terminals = ontology_manager.get_terminal_ontologies_by_iri(base_ontology.iri)
|
||||
assert len(terminals) == 2
|
||||
|
||||
# Sort by created_at
|
||||
terminals.sort(
|
||||
key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
|
||||
# Merge the two terminals
|
||||
merged = merge_ontologies(terminals[0], terminals[1])
|
||||
|
||||
# Add merged to manager
|
||||
ontology_manager.add_ontology(merged)
|
||||
|
||||
# Check that we now have one terminal
|
||||
new_terminals = ontology_manager.get_terminal_ontologies_by_iri(base_ontology.iri)
|
||||
assert len(new_terminals) == 1
|
||||
assert new_terminals[0].hash == merged.hash
|
||||
|
||||
|
||||
def test_merge_ontologies_preserves_namespaces(ontology_manager):
|
||||
"""Test that merging preserves namespace bindings."""
|
||||
from ontocast.onto.ontology_operations import merge_ontologies
|
||||
|
||||
graph1 = RDFGraph()
|
||||
graph1.bind("ex", "http://example.org/")
|
||||
iri = URIRef("http://example.org/test")
|
||||
graph1.add((iri, RDF.type, OWL.Ontology))
|
||||
|
||||
graph2 = RDFGraph()
|
||||
graph2.bind("test", "http://test.org/")
|
||||
graph2.add((iri, RDF.type, OWL.Ontology))
|
||||
|
||||
onto1 = Ontology(graph=graph1, iri=str(iri), created_at=datetime.now(timezone.utc))
|
||||
onto2 = Ontology(graph=graph2, iri=str(iri), created_at=datetime.now(timezone.utc))
|
||||
|
||||
merged = merge_ontologies(onto1, onto2)
|
||||
|
||||
# Check that both namespaces are present
|
||||
namespaces = dict(merged.graph.namespaces())
|
||||
assert "ex" in namespaces
|
||||
assert "test" in namespaces
|
||||
|
||||
|
||||
def test_merge_ontologies_created_at_set(ontology_manager):
|
||||
"""Test that merged ontology has created_at set to merge time."""
|
||||
from ontocast.onto.ontology_operations import merge_ontologies
|
||||
|
||||
graph1 = RDFGraph()
|
||||
iri = URIRef("http://example.org/test")
|
||||
graph1.add((iri, RDF.type, OWL.Ontology))
|
||||
|
||||
graph2 = RDFGraph()
|
||||
graph2.add((iri, RDF.type, OWL.Ontology))
|
||||
|
||||
onto1 = Ontology(
|
||||
graph=graph1,
|
||||
iri=str(iri),
|
||||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
onto2 = Ontology(
|
||||
graph=graph2,
|
||||
iri=str(iri),
|
||||
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
before_merge = datetime.now(timezone.utc)
|
||||
merged = merge_ontologies(onto1, onto2)
|
||||
after_merge = datetime.now(timezone.utc)
|
||||
|
||||
# Created_at should be set to merge time (between before and after)
|
||||
assert merged.created_at is not None
|
||||
assert before_merge <= merged.created_at <= after_merge
|
||||
@@ -0,0 +1,108 @@
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
|
||||
from rdflib import DCTERMS, OWL, RDF, XSD, Literal, URIRef
|
||||
|
||||
from ontocast.agent.normalize_ontology import normalize_ontology_units
|
||||
from ontocast.onto.constants import PROV
|
||||
from ontocast.onto.content_unit import ContentUnit, OutputType
|
||||
from ontocast.onto.ontology import Ontology
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.toolbox import ToolBox
|
||||
|
||||
|
||||
def _make_base_ontology() -> Ontology:
|
||||
base_iri = URIRef("https://example.org/onto")
|
||||
graph = RDFGraph()
|
||||
graph.add((base_iri, RDF.type, OWL.Ontology))
|
||||
graph.add((URIRef(f"{base_iri}#Person"), RDF.type, OWL.Class))
|
||||
return Ontology(graph=graph, iri=str(base_iri))
|
||||
|
||||
|
||||
def test_derive_updated_version_refreshes_lineage_metadata() -> None:
|
||||
base = _make_base_ontology()
|
||||
assert base.hash is not None
|
||||
base_hash = base.hash
|
||||
|
||||
onto_iri = URIRef(base.iri)
|
||||
updated_graph = base.graph.copy()
|
||||
updated_graph.add((URIRef(f"{base.iri}#Organization"), RDF.type, OWL.Class))
|
||||
updated_graph.add((onto_iri, PROV.wasDerivedFrom, URIRef("urn:hash:stale-parent")))
|
||||
updated_graph.add((onto_iri, DCTERMS.identifier, Literal("hash:stale-hash")))
|
||||
updated_graph.add(
|
||||
(
|
||||
onto_iri,
|
||||
DCTERMS.created,
|
||||
Literal("2001-01-01T00:00:00+00:00", datatype=XSD.dateTime),
|
||||
)
|
||||
)
|
||||
|
||||
updated = base.derive_updated_version(updated_graph)
|
||||
|
||||
assert updated.hash is not None
|
||||
assert updated.hash != base_hash
|
||||
assert updated.parent_hashes == [base_hash]
|
||||
assert updated.created_at is not None
|
||||
|
||||
hash_identifiers = {
|
||||
str(obj)
|
||||
for _, _, obj in updated.graph.triples((onto_iri, DCTERMS.identifier, None))
|
||||
if str(obj).startswith("hash:")
|
||||
}
|
||||
parent_uris = {
|
||||
str(obj)
|
||||
for _, _, obj in updated.graph.triples((onto_iri, PROV.wasDerivedFrom, None))
|
||||
}
|
||||
created_values = [
|
||||
str(obj)
|
||||
for _, _, obj in updated.graph.triples((onto_iri, DCTERMS.created, None))
|
||||
]
|
||||
|
||||
assert hash_identifiers == {f"hash:{updated.hash}"}
|
||||
assert "hash:stale-hash" not in hash_identifiers
|
||||
assert parent_uris == {f"urn:hash:{base_hash}"}
|
||||
assert "urn:hash:stale-parent" not in parent_uris
|
||||
assert len(created_values) == 1
|
||||
assert datetime.fromisoformat(created_values[0]) == updated.created_at
|
||||
|
||||
|
||||
class _DummyTools:
|
||||
pass
|
||||
|
||||
|
||||
def test_normalize_ontology_units_refreshes_lineage_for_updated_base() -> None:
|
||||
base = _make_base_ontology()
|
||||
assert base.hash is not None
|
||||
base_hash = base.hash
|
||||
|
||||
doc_iri = URIRef("https://example.org/doc/alpha")
|
||||
delta_graph = RDFGraph()
|
||||
delta_graph.add((URIRef(f"{base.iri}#Case"), RDF.type, OWL.Class))
|
||||
unit = ContentUnit(
|
||||
text="delta",
|
||||
index=0,
|
||||
doc_iri=doc_iri,
|
||||
graph=delta_graph,
|
||||
type=OutputType.ONTOLOGIES,
|
||||
)
|
||||
|
||||
normalized, applied, provenance = normalize_ontology_units(
|
||||
units=[unit],
|
||||
tools=cast(ToolBox, _DummyTools()),
|
||||
base_ontology=base,
|
||||
require_base=True,
|
||||
)
|
||||
|
||||
onto_iri = URIRef(base.iri)
|
||||
assert len(applied) == 1
|
||||
assert normalized.hash is not None
|
||||
assert normalized.hash != base_hash
|
||||
assert normalized.parent_hashes == [base_hash]
|
||||
assert normalized.created_at is not None
|
||||
assert len(provenance) == 0
|
||||
assert (URIRef(f"{base.iri}#Case"), RDF.type, OWL.Class) in normalized.graph
|
||||
assert (
|
||||
onto_iri,
|
||||
PROV.wasDerivedFrom,
|
||||
URIRef(f"urn:hash:{base_hash}"),
|
||||
) in normalized.graph
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Test suite for OntologyManager.
|
||||
|
||||
This test suite ensures that:
|
||||
1. Every ontology in the manager has a created_at field set (not None)
|
||||
2. Version tracking works correctly
|
||||
3. Terminal ontology detection works
|
||||
4. Freshest ontology selection works
|
||||
5. Lineage graphs are built correctly
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from ontocast.onto.ontology import Ontology
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.tool import OntologyManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ontology_manager():
|
||||
"""Create a fresh OntologyManager for each test."""
|
||||
return OntologyManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_ontology():
|
||||
"""Create a sample ontology with minimal required fields."""
|
||||
graph = RDFGraph()
|
||||
graph.parse(
|
||||
data="""
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
<https://example.org/test> a owl:Ontology ;
|
||||
rdfs:label "Test Ontology" .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
ontology = Ontology(
|
||||
graph=graph,
|
||||
ontology_id="test",
|
||||
iri="https://example.org/test",
|
||||
title="Test Ontology",
|
||||
version="1.0.0",
|
||||
)
|
||||
# Compute hash if not set
|
||||
if not ontology.hash:
|
||||
ontology._compute_and_set_hash()
|
||||
return ontology
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ontology_with_parent(sample_ontology):
|
||||
"""Create an ontology that has sample_ontology as parent."""
|
||||
graph = RDFGraph()
|
||||
graph.parse(
|
||||
data="""
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
<https://example.org/test> a owl:Ontology ;
|
||||
rdfs:label "Test Ontology v2" .
|
||||
|
||||
<https://example.org/test#NewClass> a owl:Class ;
|
||||
rdfs:label "New Class" .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
ontology = Ontology(
|
||||
graph=graph,
|
||||
ontology_id="test",
|
||||
iri="https://example.org/test",
|
||||
title="Test Ontology v2",
|
||||
version="2.0.0",
|
||||
parent_hashes=[sample_ontology.hash] if sample_ontology.hash else [],
|
||||
)
|
||||
if not ontology.hash:
|
||||
ontology._compute_and_set_hash()
|
||||
return ontology
|
||||
|
||||
|
||||
class TestOntologyManagerCreatedAt:
|
||||
"""Test that created_at is always set when adding ontologies."""
|
||||
|
||||
def test_add_ontology_sets_created_at_if_missing(
|
||||
self, ontology_manager, sample_ontology
|
||||
):
|
||||
"""Test that add_ontology sets created_at if it's None."""
|
||||
assert sample_ontology.created_at is None
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
# Check that created_at was set
|
||||
assert sample_ontology.created_at is not None
|
||||
assert isinstance(sample_ontology.created_at, datetime)
|
||||
|
||||
# Check that it's in the manager
|
||||
versions = ontology_manager.get_ontology_versions("test")
|
||||
assert len(versions) == 1
|
||||
assert versions[0].created_at is not None
|
||||
|
||||
def test_add_ontology_preserves_existing_created_at(
|
||||
self, ontology_manager, sample_ontology
|
||||
):
|
||||
"""Test that add_ontology preserves existing created_at."""
|
||||
original_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
sample_ontology.created_at = original_time
|
||||
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
# Check that created_at was preserved
|
||||
assert sample_ontology.created_at == original_time
|
||||
|
||||
versions = ontology_manager.get_ontology_versions("test")
|
||||
assert versions[0].created_at == original_time
|
||||
|
||||
def test_all_ontologies_have_created_at(self, ontology_manager, sample_ontology):
|
||||
"""Test that all ontologies in manager have created_at set."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
# Check all ontologies property
|
||||
ontologies = ontology_manager.ontologies
|
||||
assert len(ontologies) > 0
|
||||
for ontology in ontologies:
|
||||
assert ontology.created_at is not None, (
|
||||
f"Ontology {ontology.ontology_id} (hash: {ontology.hash}) "
|
||||
"should have created_at set"
|
||||
)
|
||||
|
||||
def test_get_ontology_versions_all_have_created_at(
|
||||
self, ontology_manager, sample_ontology, ontology_with_parent
|
||||
):
|
||||
"""Test that all versions returned have created_at set."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
ontology_manager.add_ontology(ontology_with_parent)
|
||||
|
||||
versions = ontology_manager.get_ontology_versions("test")
|
||||
assert len(versions) == 2
|
||||
for version in versions:
|
||||
assert version.created_at is not None, (
|
||||
f"Version with hash {version.hash} should have created_at set"
|
||||
)
|
||||
|
||||
|
||||
class TestOntologyManagerVersionTracking:
|
||||
"""Test version tracking functionality."""
|
||||
|
||||
def test_add_ontology_creates_version_tree(self, ontology_manager, sample_ontology):
|
||||
"""Test that adding an ontology creates a version tree."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
assert sample_ontology.iri in ontology_manager.ontology_versions
|
||||
assert len(ontology_manager.ontology_versions[sample_ontology.iri]) == 1
|
||||
|
||||
def test_add_duplicate_hash_not_added(self, ontology_manager, sample_ontology):
|
||||
"""Test that adding the same ontology twice doesn't create duplicates."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
versions = ontology_manager.get_ontology_versions("test")
|
||||
assert len(versions) == 1
|
||||
|
||||
def test_add_ontology_without_hash_rejected(self, ontology_manager):
|
||||
"""Test that adding ontology without hash is rejected."""
|
||||
ontology = Ontology(
|
||||
ontology_id="test",
|
||||
iri="https://example.org/test",
|
||||
)
|
||||
assert ontology.hash is None
|
||||
|
||||
ontology_manager.add_ontology(ontology)
|
||||
|
||||
# Should not be added
|
||||
assert ontology.iri not in ontology_manager.ontology_versions
|
||||
|
||||
def test_add_ontology_without_iri_rejected(self, ontology_manager, sample_ontology):
|
||||
"""Test that adding ontology without valid IRI is rejected."""
|
||||
sample_ontology.iri = None
|
||||
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
# Should not be added
|
||||
assert len(ontology_manager.ontology_versions) == 0
|
||||
|
||||
|
||||
class TestTerminalOntologies:
|
||||
"""Test terminal ontology detection."""
|
||||
|
||||
def test_single_ontology_is_terminal(self, ontology_manager, sample_ontology):
|
||||
"""Test that a single ontology is terminal."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
terminals = ontology_manager.get_terminal_ontologies("test")
|
||||
assert len(terminals) == 1
|
||||
assert terminals[0].hash == sample_ontology.hash
|
||||
|
||||
def test_parent_is_not_terminal_when_child_exists(
|
||||
self, ontology_manager, sample_ontology, ontology_with_parent
|
||||
):
|
||||
"""Test that parent is not terminal when child exists."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
ontology_manager.add_ontology(ontology_with_parent)
|
||||
|
||||
terminals = ontology_manager.get_terminal_ontologies("test")
|
||||
assert len(terminals) == 1
|
||||
assert terminals[0].hash == ontology_with_parent.hash
|
||||
assert sample_ontology.hash not in [t.hash for t in terminals]
|
||||
|
||||
def test_multiple_terminals_for_different_ontology_ids(
|
||||
self, ontology_manager, sample_ontology
|
||||
):
|
||||
"""Test that we can have terminals for different ontology_ids."""
|
||||
# Create second ontology with different ID
|
||||
graph2 = RDFGraph()
|
||||
graph2.parse(
|
||||
data="""
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
<https://example.org/test2> a owl:Ontology ;
|
||||
rdfs:label "Test Ontology 2" .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
ontology2 = Ontology(
|
||||
graph=graph2,
|
||||
ontology_id="test2",
|
||||
iri="https://example.org/test2",
|
||||
)
|
||||
if not ontology2.hash:
|
||||
ontology2._compute_and_set_hash()
|
||||
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
ontology_manager.add_ontology(ontology2)
|
||||
|
||||
terminals = ontology_manager.get_terminal_ontologies()
|
||||
assert len(terminals) == 2
|
||||
assert {t.ontology_id for t in terminals} == {"test", "test2"}
|
||||
|
||||
|
||||
class TestFreshestTerminalOntology:
|
||||
"""Test freshest terminal ontology selection."""
|
||||
|
||||
def test_freshest_single_ontology(self, ontology_manager, sample_ontology):
|
||||
"""Test that freshest returns the only ontology when there's one."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
freshest = ontology_manager.get_freshest_terminal_ontology("test")
|
||||
assert freshest is not None
|
||||
assert freshest.hash == sample_ontology.hash
|
||||
|
||||
def test_freshest_selects_most_recent(
|
||||
self, ontology_manager, sample_ontology, ontology_with_parent
|
||||
):
|
||||
"""Test that freshest selects the most recently created ontology."""
|
||||
# Set explicit timestamps
|
||||
sample_ontology.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
ontology_with_parent.created_at = datetime(
|
||||
2024, 1, 2, 12, 0, 0, tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
ontology_manager.add_ontology(ontology_with_parent)
|
||||
|
||||
freshest = ontology_manager.get_freshest_terminal_ontology("test")
|
||||
assert freshest is not None
|
||||
assert freshest.hash == ontology_with_parent.hash
|
||||
|
||||
def test_freshest_handles_no_timestamps(self, ontology_manager, sample_ontology):
|
||||
"""Test that freshest falls back when no timestamps."""
|
||||
# This shouldn't happen in practice since add_ontology sets created_at,
|
||||
# but test the fallback logic
|
||||
sample_ontology.created_at = None
|
||||
# Manually add to bypass add_ontology's created_at setting
|
||||
if sample_ontology.iri not in ontology_manager.ontology_versions:
|
||||
ontology_manager.ontology_versions[sample_ontology.iri] = []
|
||||
ontology_manager.ontology_versions[sample_ontology.iri].append(sample_ontology)
|
||||
|
||||
freshest = ontology_manager.get_freshest_terminal_ontology("test")
|
||||
# Should still return something (fallback to first)
|
||||
assert freshest is not None
|
||||
|
||||
def test_freshest_returns_none_when_no_ontologies(self, ontology_manager):
|
||||
"""Test that freshest returns None when no ontologies exist."""
|
||||
freshest = ontology_manager.get_freshest_terminal_ontology("nonexistent")
|
||||
assert freshest is None
|
||||
|
||||
|
||||
class TestOntologiesProperty:
|
||||
"""Test the ontologies property."""
|
||||
|
||||
def test_ontologies_returns_freshest_per_ontology_id(
|
||||
self, ontology_manager, sample_ontology, ontology_with_parent
|
||||
):
|
||||
"""Test that ontologies property returns one per ontology_id."""
|
||||
sample_ontology.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
ontology_with_parent.created_at = datetime(
|
||||
2024, 1, 2, 12, 0, 0, tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
ontology_manager.add_ontology(ontology_with_parent)
|
||||
|
||||
ontologies = ontology_manager.ontologies
|
||||
assert len(ontologies) == 1 # One per ontology_id
|
||||
assert ontologies[0].hash == ontology_with_parent.hash
|
||||
|
||||
def test_ontologies_all_have_created_at(self, ontology_manager, sample_ontology):
|
||||
"""Test that all ontologies returned have created_at."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
ontologies = ontology_manager.ontologies
|
||||
for ontology in ontologies:
|
||||
assert ontology.created_at is not None
|
||||
|
||||
def test_ontologies_cache_is_updated_incrementally(
|
||||
self, ontology_manager, sample_ontology
|
||||
):
|
||||
"""Test that cache is updated incrementally when adding ontologies."""
|
||||
# Initially empty
|
||||
assert not ontology_manager.has_ontologies
|
||||
assert len(ontology_manager.ontologies) == 0
|
||||
|
||||
# Add first ontology
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
assert ontology_manager.has_ontologies
|
||||
assert len(ontology_manager.ontologies) == 1
|
||||
assert sample_ontology.iri in ontology_manager._cached_ontologies
|
||||
assert (
|
||||
ontology_manager._cached_ontologies[sample_ontology.iri]
|
||||
== sample_ontology.hash
|
||||
)
|
||||
|
||||
# Add second ontology with different ID
|
||||
graph2 = RDFGraph()
|
||||
graph2.parse(
|
||||
data="""
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
<https://example.org/test2> a owl:Ontology .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
ontology2 = Ontology(
|
||||
graph=graph2,
|
||||
ontology_id="test2",
|
||||
iri="https://example.org/test2",
|
||||
)
|
||||
if not ontology2.hash:
|
||||
ontology2._compute_and_set_hash()
|
||||
|
||||
ontology_manager.add_ontology(ontology2)
|
||||
assert len(ontology_manager.ontologies) == 2
|
||||
assert sample_ontology.iri in ontology_manager._cached_ontologies
|
||||
assert ontology2.iri in ontology_manager._cached_ontologies
|
||||
assert ontology_manager._cached_ontologies[ontology2.iri] == ontology2.hash
|
||||
|
||||
def test_ontologies_cache_updates_when_new_version_added(
|
||||
self, ontology_manager, sample_ontology, ontology_with_parent
|
||||
):
|
||||
"""Test that cache is updated when a new version is added for existing ontology_id."""
|
||||
# Add initial ontology
|
||||
sample_ontology.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
# Check cache has initial hash
|
||||
assert (
|
||||
ontology_manager._cached_ontologies[sample_ontology.iri]
|
||||
== sample_ontology.hash
|
||||
)
|
||||
|
||||
# Add newer version (same IRI)
|
||||
ontology_with_parent.created_at = datetime(
|
||||
2024, 1, 2, 12, 0, 0, tzinfo=timezone.utc
|
||||
)
|
||||
ontology_manager.add_ontology(ontology_with_parent)
|
||||
|
||||
# Cache should be updated to newer hash
|
||||
assert (
|
||||
ontology_manager._cached_ontologies[sample_ontology.iri]
|
||||
== ontology_with_parent.hash
|
||||
)
|
||||
assert (
|
||||
ontology_manager._cached_ontologies[sample_ontology.iri]
|
||||
!= sample_ontology.hash
|
||||
)
|
||||
|
||||
|
||||
class TestHasOntologies:
|
||||
"""Test the has_ontologies property."""
|
||||
|
||||
def test_has_ontologies_false_when_empty(self, ontology_manager):
|
||||
"""Test that has_ontologies returns False when no ontologies."""
|
||||
assert not ontology_manager.has_ontologies
|
||||
|
||||
def test_has_ontologies_true_when_ontologies_exist(
|
||||
self, ontology_manager, sample_ontology
|
||||
):
|
||||
"""Test that has_ontologies returns True when ontologies exist."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
assert ontology_manager.has_ontologies
|
||||
|
||||
def test_has_ontologies_works_with_cache(self, ontology_manager, sample_ontology):
|
||||
"""Test that has_ontologies works correctly with caching."""
|
||||
# Initially false
|
||||
assert not ontology_manager.has_ontologies
|
||||
|
||||
# Add ontology
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
assert ontology_manager.has_ontologies
|
||||
|
||||
# Should still be true after accessing ontologies property
|
||||
_ = ontology_manager.ontologies
|
||||
assert ontology_manager.has_ontologies
|
||||
|
||||
|
||||
class TestLineageGraph:
|
||||
"""Test lineage graph building."""
|
||||
|
||||
def test_get_lineage_graph_creates_graph(
|
||||
self, ontology_manager, sample_ontology, ontology_with_parent
|
||||
):
|
||||
"""Test that lineage graph is created correctly."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
ontology_manager.add_ontology(ontology_with_parent)
|
||||
|
||||
lineage = ontology_manager.get_lineage_graph("test")
|
||||
assert lineage is not None
|
||||
import networkx as nx
|
||||
|
||||
assert isinstance(lineage, nx.DiGraph)
|
||||
|
||||
# Check nodes exist
|
||||
assert sample_ontology.hash in lineage.nodes()
|
||||
assert ontology_with_parent.hash in lineage.nodes()
|
||||
|
||||
# Check edge from child to parent
|
||||
assert lineage.has_edge(ontology_with_parent.hash, sample_ontology.hash)
|
||||
|
||||
def test_get_lineage_graph_returns_none_for_missing_id(self, ontology_manager):
|
||||
"""Test that lineage graph returns None for missing ontology_id."""
|
||||
lineage = ontology_manager.get_lineage_graph("nonexistent")
|
||||
assert lineage is None
|
||||
|
||||
|
||||
class TestGetOntology:
|
||||
"""Test get_ontology method."""
|
||||
|
||||
def test_get_ontology_by_hash(self, ontology_manager, sample_ontology):
|
||||
"""Test getting ontology by hash."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
retrieved = ontology_manager.get_ontology(hash=sample_ontology.hash)
|
||||
assert retrieved.hash == sample_ontology.hash
|
||||
assert retrieved.created_at is not None
|
||||
|
||||
def test_get_ontology_by_ontology_id_returns_terminal(
|
||||
self, ontology_manager, sample_ontology, ontology_with_parent
|
||||
):
|
||||
"""Test that getting by ontology_id returns terminal."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
ontology_manager.add_ontology(ontology_with_parent)
|
||||
|
||||
retrieved = ontology_manager.get_ontology(ontology_id="test")
|
||||
assert retrieved.hash == ontology_with_parent.hash
|
||||
assert retrieved.created_at is not None
|
||||
|
||||
def test_get_ontology_by_iri(self, ontology_manager, sample_ontology):
|
||||
"""Test getting ontology by IRI."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
retrieved = ontology_manager.get_ontology(ontology_iri=sample_ontology.iri)
|
||||
assert retrieved.iri == sample_ontology.iri
|
||||
assert retrieved.created_at is not None
|
||||
|
||||
|
||||
class TestGetOntologyNames:
|
||||
"""Test get_ontology_names method."""
|
||||
|
||||
def test_get_ontology_names_returns_all_ids(
|
||||
self, ontology_manager, sample_ontology
|
||||
):
|
||||
"""Test that get_ontology_names returns all ontology IDs."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
# Create second ontology
|
||||
graph2 = RDFGraph()
|
||||
graph2.parse(
|
||||
data="""
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
|
||||
<https://example.org/test2> a owl:Ontology .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
ontology2 = Ontology(
|
||||
graph=graph2,
|
||||
ontology_id="test2",
|
||||
iri="https://example.org/test2",
|
||||
)
|
||||
if not ontology2.hash:
|
||||
ontology2._compute_and_set_hash()
|
||||
ontology_manager.add_ontology(ontology2)
|
||||
|
||||
names = ontology_manager.get_ontology_names()
|
||||
assert "test" in names
|
||||
assert "test2" in names
|
||||
assert len(names) == 2
|
||||
|
||||
|
||||
class TestContains:
|
||||
"""Test __contains__ method."""
|
||||
|
||||
def test_contains_by_ontology_id(self, ontology_manager, sample_ontology):
|
||||
"""Test checking containment by ontology_id."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
assert "test" in ontology_manager
|
||||
assert "nonexistent" not in ontology_manager
|
||||
|
||||
def test_contains_by_iri(self, ontology_manager, sample_ontology):
|
||||
"""Test checking containment by IRI."""
|
||||
ontology_manager.add_ontology(sample_ontology)
|
||||
|
||||
assert sample_ontology.iri in ontology_manager
|
||||
assert "https://example.org/nonexistent" not in ontology_manager
|
||||
|
||||
|
||||
class TestRecreateFromRDFGraph:
|
||||
"""Test recreating Ontology from RDF graph with parent_hashes and created_at."""
|
||||
|
||||
def test_recreate_ontology_with_parent_hashes_and_created_at(self):
|
||||
"""Test that parent_hashes and created_at are correctly read from RDF graph."""
|
||||
# Create an ontology with parent_hashes and created_at
|
||||
original_ontology = Ontology(
|
||||
graph=RDFGraph(),
|
||||
ontology_id="test",
|
||||
iri="https://example.org/test",
|
||||
title="Test Ontology",
|
||||
version="1.0.0",
|
||||
)
|
||||
if not original_ontology.hash:
|
||||
original_ontology._compute_and_set_hash()
|
||||
|
||||
# Set parent_hashes and created_at
|
||||
parent_hash = "parent1234567890abcdef"
|
||||
original_ontology.parent_hashes = [parent_hash]
|
||||
original_ontology.created_at = datetime(
|
||||
2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
# Sync to graph to add triples
|
||||
original_ontology.sync_properties_to_graph()
|
||||
|
||||
# Now recreate ontology from the graph
|
||||
# This simulates loading from a triple store or file
|
||||
recreated_ontology = Ontology(graph=original_ontology.graph)
|
||||
|
||||
# Verify parent_hashes was read correctly
|
||||
assert len(recreated_ontology.parent_hashes) == 1
|
||||
assert parent_hash in recreated_ontology.parent_hashes
|
||||
|
||||
# Verify created_at was read correctly
|
||||
assert recreated_ontology.created_at is not None
|
||||
assert recreated_ontology.created_at == datetime(
|
||||
2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
def test_recreate_ontology_with_multiple_parent_hashes(self):
|
||||
"""Test that multiple parent_hashes are correctly read from RDF graph."""
|
||||
# Create an ontology with multiple parent_hashes
|
||||
original_ontology = Ontology(
|
||||
graph=RDFGraph(),
|
||||
ontology_id="test",
|
||||
iri="https://example.org/test",
|
||||
title="Test Ontology",
|
||||
version="1.0.0",
|
||||
)
|
||||
if not original_ontology.hash:
|
||||
original_ontology._compute_and_set_hash()
|
||||
|
||||
# Set multiple parent_hashes (simulating a merge)
|
||||
parent_hashes = ["parent1", "parent2", "parent3"]
|
||||
original_ontology.parent_hashes = parent_hashes
|
||||
original_ontology.created_at = datetime(
|
||||
2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
# Sync to graph
|
||||
original_ontology.sync_properties_to_graph()
|
||||
|
||||
# Recreate from graph
|
||||
recreated_ontology = Ontology(graph=original_ontology.graph)
|
||||
|
||||
# Verify all parent_hashes were read
|
||||
assert len(recreated_ontology.parent_hashes) == 3
|
||||
assert set(recreated_ontology.parent_hashes) == set(parent_hashes)
|
||||
|
||||
def test_recreate_ontology_with_empty_parent_hashes(self):
|
||||
"""Test that empty parent_hashes (root ontology) is correctly handled."""
|
||||
# Create a root ontology (no parents)
|
||||
original_ontology = Ontology(
|
||||
graph=RDFGraph(),
|
||||
ontology_id="test",
|
||||
iri="https://example.org/test",
|
||||
title="Test Ontology",
|
||||
version="1.0.0",
|
||||
)
|
||||
if not original_ontology.hash:
|
||||
original_ontology._compute_and_set_hash()
|
||||
|
||||
# Ensure parent_hashes is empty (root ontology)
|
||||
original_ontology.parent_hashes = []
|
||||
original_ontology.created_at = datetime(
|
||||
2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
# Sync to graph
|
||||
original_ontology.sync_properties_to_graph()
|
||||
|
||||
# Recreate from graph
|
||||
recreated_ontology = Ontology(graph=original_ontology.graph)
|
||||
|
||||
# Verify parent_hashes is empty
|
||||
assert recreated_ontology.parent_hashes == []
|
||||
assert len(recreated_ontology.parent_hashes) == 0
|
||||
|
||||
def test_recreate_ontology_preserves_existing_created_at(self):
|
||||
"""Test that existing created_at is preserved when recreating from graph."""
|
||||
# Create ontology with created_at
|
||||
original_ontology = Ontology(
|
||||
graph=RDFGraph(),
|
||||
ontology_id="test",
|
||||
iri="https://example.org/test",
|
||||
title="Test Ontology",
|
||||
version="1.0.0",
|
||||
)
|
||||
if not original_ontology.hash:
|
||||
original_ontology._compute_and_set_hash()
|
||||
|
||||
original_time = datetime(2023, 12, 25, 15, 45, 0, tzinfo=timezone.utc)
|
||||
original_ontology.created_at = original_time
|
||||
|
||||
# Sync to graph
|
||||
original_ontology.sync_properties_to_graph()
|
||||
|
||||
# Recreate from graph
|
||||
recreated_ontology = Ontology(graph=original_ontology.graph)
|
||||
|
||||
# Verify created_at was preserved
|
||||
assert recreated_ontology.created_at is not None
|
||||
assert recreated_ontology.created_at == original_time
|
||||
711
ontology_platform/vendored/ontocast/test/test_pipeline.py
Normal file
711
ontology_platform/vendored/ontocast/test/test_pipeline.py
Normal file
@@ -0,0 +1,711 @@
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from rdflib import OWL, RDF, BNode, Literal, URIRef
|
||||
|
||||
from ontocast.agent.normalize_ontology import normalize_ontology_units
|
||||
from ontocast.onto.constants import ONTOLOGY_NULL_IRI, PROV, RDF_REIFIES, SCHEMA
|
||||
from ontocast.onto.content_unit import ContentUnit, OutputType
|
||||
from ontocast.onto.enum import RenderMode, Status, WorkflowNode
|
||||
from ontocast.onto.model import (
|
||||
ExternalEvidenceCacheEntry,
|
||||
ExternalEvidencePlan,
|
||||
ExternalEvidenceRequest,
|
||||
GraphUpdateRenderReport,
|
||||
OntologyCritiqueReport,
|
||||
)
|
||||
from ontocast.onto.ontology import Ontology
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
from ontocast.onto.sparql_models import GenericSparqlQuery, GraphUpdate
|
||||
from ontocast.onto.state import AgentState
|
||||
from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState
|
||||
from ontocast.stategraph.node_factories import make_normalize_ontology_node
|
||||
from ontocast.stategraph.routing import route_after_ontology_consolidation
|
||||
from ontocast.tool.aggregate import EmbeddingBasedAggregator
|
||||
from ontocast.tool.atomic import AtomicToolBox, SearchHit
|
||||
from ontocast.toolbox import ToolBox
|
||||
|
||||
render_ontology_module = importlib.import_module("ontocast.agent.render_ontology")
|
||||
criticise_ontology_module = importlib.import_module("ontocast.agent.criticise_ontology")
|
||||
select_ontology_module = importlib.import_module("ontocast.agent.select_ontology")
|
||||
unit_loops = importlib.import_module("ontocast.stategraph.atomic")
|
||||
external_evidence_module = importlib.import_module("ontocast.agent.external_evidence")
|
||||
|
||||
|
||||
def _build_content_unit() -> ContentUnit:
|
||||
return ContentUnit(
|
||||
text="Alice works for ACME.",
|
||||
index=0,
|
||||
doc_iri=URIRef("https://example.com/doc/d1"),
|
||||
)
|
||||
|
||||
|
||||
def _build_ontology() -> Ontology:
|
||||
graph = RDFGraph()
|
||||
graph.parse(
|
||||
data="""
|
||||
@prefix onto: <https://example.com/onto#> .
|
||||
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
||||
onto:CompanyOntology a owl:Ontology .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
return Ontology(graph=graph, iri="https://example.com/onto")
|
||||
|
||||
|
||||
def test_unit_facts_loop_isolates_input_state() -> None:
|
||||
"""Unit loop uses model_copy(deep=True), so input state is not mutated."""
|
||||
state = UnitFactsState(
|
||||
content_unit=_build_content_unit(), ontology_snapshot=_build_ontology()
|
||||
)
|
||||
original_text = state.content_unit.text
|
||||
# Simulate what the loop does: it copies before processing
|
||||
copied = state.model_copy(deep=True)
|
||||
copied.content_unit.text = "MUTATED"
|
||||
assert state.content_unit.text == original_text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_unit_facts_loop_uses_dedicated_state(monkeypatch) -> None:
|
||||
async def fake_render(state: UnitFactsState, tools) -> UnitFactsState:
|
||||
state.status = Status.SUCCESS
|
||||
return state
|
||||
|
||||
async def fake_critic(state: UnitFactsState, tools) -> UnitFactsState:
|
||||
state.status = Status.SUCCESS
|
||||
return state
|
||||
|
||||
monkeypatch.setattr(unit_loops, "render_facts", fake_render)
|
||||
monkeypatch.setattr(unit_loops, "criticise_facts", fake_critic)
|
||||
|
||||
state = UnitFactsState(
|
||||
content_unit=_build_content_unit(), ontology_snapshot=_build_ontology()
|
||||
)
|
||||
tools = cast(AtomicToolBox, object())
|
||||
result = await unit_loops.facts_loop(state, tools=tools)
|
||||
|
||||
assert result.status == Status.SUCCESS
|
||||
assert result.content_unit.hid == state.content_unit.hid
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_unit_ontology_loop_emits_updates(monkeypatch) -> None:
|
||||
async def fake_render(state: UnitOntologyState, tools) -> UnitOntologyState:
|
||||
state.status = Status.SUCCESS
|
||||
state.ontology_updates = [GraphUpdate()]
|
||||
state.current_ontology = Ontology(
|
||||
graph=RDFGraph(), iri="https://example.com/onto"
|
||||
)
|
||||
return state
|
||||
|
||||
async def fake_critic(state: UnitOntologyState, tools) -> UnitOntologyState:
|
||||
state.status = Status.SUCCESS
|
||||
return state
|
||||
|
||||
monkeypatch.setattr(unit_loops, "render_ontology", fake_render)
|
||||
monkeypatch.setattr(unit_loops, "criticise_ontology", fake_critic)
|
||||
|
||||
state = UnitOntologyState(
|
||||
content_unit=_build_content_unit(),
|
||||
ontology_snapshot=Ontology(iri=ONTOLOGY_NULL_IRI),
|
||||
)
|
||||
tools = cast(AtomicToolBox, object())
|
||||
result = await unit_loops.ontology_loop(state, tools=tools)
|
||||
|
||||
assert result.status == Status.SUCCESS
|
||||
assert len(result.all_updates) == 1
|
||||
|
||||
|
||||
def test_reduce_ontology_units_returns_ontology_when_no_units() -> None:
|
||||
tools = ToolBox.__new__(ToolBox)
|
||||
tools.aggregator = EmbeddingBasedAggregator()
|
||||
reduced, applied, provenance = normalize_ontology_units(units=[], tools=tools)
|
||||
|
||||
assert reduced is not None
|
||||
assert reduced.iri is not None
|
||||
assert applied == []
|
||||
assert len(provenance) == 0
|
||||
|
||||
|
||||
def test_reduce_ontology_units_merges_unit_graphs_without_aggregator() -> None:
|
||||
tools = ToolBox.__new__(ToolBox)
|
||||
tools.aggregator = EmbeddingBasedAggregator()
|
||||
unit1 = ContentUnit(
|
||||
text="Alice works at ACME",
|
||||
index=0,
|
||||
doc_iri=URIRef("https://example.com/doc/d1"),
|
||||
graph=_build_ontology().graph,
|
||||
type=OutputType.ONTOLOGIES,
|
||||
)
|
||||
reduced, applied, provenance = normalize_ontology_units(units=[unit1], tools=tools)
|
||||
|
||||
assert reduced is not None
|
||||
assert len(reduced.graph) > 0
|
||||
assert len(applied) == 1
|
||||
assert len(applied[0].triple_operations) == 1
|
||||
assert len(provenance) == 0
|
||||
assert isinstance(applied, list)
|
||||
|
||||
|
||||
def test_reduce_ontology_units_creates_base_when_required() -> None:
|
||||
tools = cast(ToolBox, ToolBox.__new__(ToolBox))
|
||||
tools.aggregator = EmbeddingBasedAggregator()
|
||||
delta_graph = RDFGraph()
|
||||
delta_graph.parse(
|
||||
data="""
|
||||
@prefix ex: <https://example.com/onto#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
ex:Company rdf:type rdfs:Class .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
unit = ContentUnit(
|
||||
text="Company ontology snippet",
|
||||
index=0,
|
||||
doc_iri=URIRef("https://example.com/doc/d1"),
|
||||
graph=delta_graph,
|
||||
type=OutputType.ONTOLOGIES,
|
||||
)
|
||||
reduced, applied, provenance = normalize_ontology_units(
|
||||
units=[unit],
|
||||
tools=tools,
|
||||
base_ontology=None,
|
||||
require_base=True,
|
||||
)
|
||||
|
||||
assert not reduced.is_null()
|
||||
assert len(reduced.graph) > 0
|
||||
assert len(provenance) == 0
|
||||
assert isinstance(applied, list)
|
||||
|
||||
|
||||
def test_reduce_ontology_units_strips_provenance_and_stores_artifact() -> None:
|
||||
tools = ToolBox.__new__(ToolBox)
|
||||
tools.aggregator = EmbeddingBasedAggregator()
|
||||
doc_iri = URIRef("https://growgraph.dev/doc/test")
|
||||
court = URIRef("https://growgraph.dev/fcaont#Court")
|
||||
appeal_court = URIRef("https://growgraph.dev/fcaont#AppealCourt")
|
||||
reifier = BNode()
|
||||
source_chunk = URIRef(f"{doc_iri}/chunk-1")
|
||||
|
||||
graph = RDFGraph(store="oxigraph")
|
||||
graph.add((appeal_court, RDF.type, court))
|
||||
graph.add((appeal_court, OWL.sameAs, court))
|
||||
graph.add((source_chunk, RDF.type, PROV.Entity))
|
||||
graph.add((source_chunk, SCHEMA.identifier, Literal("chunk-1")))
|
||||
graph.add((reifier, RDF_REIFIES, Literal("quoted-triple")))
|
||||
graph.add((reifier, PROV.wasDerivedFrom, source_chunk))
|
||||
|
||||
unit = ContentUnit(
|
||||
text="Appeal court ontology unit",
|
||||
index=0,
|
||||
doc_iri=doc_iri,
|
||||
graph=graph,
|
||||
type=OutputType.ONTOLOGIES,
|
||||
)
|
||||
reduced, _, provenance = normalize_ontology_units(units=[unit], tools=tools)
|
||||
|
||||
assert (appeal_court, RDF.type, court) in reduced.graph
|
||||
assert (appeal_court, OWL.sameAs, court) not in reduced.graph
|
||||
assert (source_chunk, SCHEMA.identifier, Literal("chunk-1")) not in reduced.graph
|
||||
|
||||
assert (appeal_court, OWL.sameAs, court) in provenance
|
||||
assert list(provenance.triples((None, RDF_REIFIES, None)))
|
||||
assert list(provenance.triples((None, PROV.wasDerivedFrom, source_chunk)))
|
||||
|
||||
|
||||
def test_normalize_ontology_node_feeds_clean_graph_to_consolidation() -> None:
|
||||
class DummyTools:
|
||||
aggregator = EmbeddingBasedAggregator()
|
||||
|
||||
normalize_node = make_normalize_ontology_node(cast(ToolBox, DummyTools()))
|
||||
|
||||
doc_iri = URIRef("https://growgraph.dev/doc/test-node")
|
||||
class_uri = URIRef("https://growgraph.dev/fcaont#Judgement")
|
||||
source_chunk = URIRef(f"{doc_iri}/chunk-1")
|
||||
graph = RDFGraph()
|
||||
graph.add(
|
||||
(class_uri, RDF.type, URIRef("http://www.w3.org/2000/01/rdf-schema#Class"))
|
||||
)
|
||||
graph.add((source_chunk, RDF.type, PROV.Entity))
|
||||
graph.add((source_chunk, SCHEMA.identifier, Literal("chunk-1")))
|
||||
graph.add((class_uri, OWL.sameAs, URIRef("https://growgraph.dev/fcaont#Judgment")))
|
||||
|
||||
state = AgentState(render_mode=RenderMode.ONTOLOGY)
|
||||
state.current_ontology = _build_ontology()
|
||||
state.ontology_units = [
|
||||
ContentUnit(
|
||||
text="Ontology delta",
|
||||
index=0,
|
||||
doc_iri=doc_iri,
|
||||
graph=graph,
|
||||
type=OutputType.ONTOLOGIES,
|
||||
)
|
||||
]
|
||||
|
||||
updated = normalize_node(state)
|
||||
ontology_ttl = updated.current_ontology.graph.serialize(format="turtle")
|
||||
|
||||
assert "rdf:reifies" not in ontology_ttl
|
||||
assert f"{doc_iri}/chunk-1" not in ontology_ttl
|
||||
assert "owl:sameAs" not in ontology_ttl
|
||||
assert len(updated.ontology_provenance_artifact) > 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_select_ontology_none_keeps_success_status(monkeypatch) -> None:
|
||||
class SelectorResult:
|
||||
answer_index = 0
|
||||
|
||||
async def fake_call_llm_with_retry(**kwargs):
|
||||
return SelectorResult()
|
||||
|
||||
monkeypatch.setattr(
|
||||
select_ontology_module, "call_llm_with_retry", fake_call_llm_with_retry
|
||||
)
|
||||
|
||||
state = AgentState()
|
||||
state.content_units = [_build_content_unit()]
|
||||
tools = SimpleNamespace(
|
||||
llm=object(),
|
||||
ontology_manager=SimpleNamespace(
|
||||
has_ontologies=True, ontologies=[_build_ontology()]
|
||||
),
|
||||
)
|
||||
result = await select_ontology_module.select_ontology(state, tools) # type: ignore[arg-type]
|
||||
|
||||
assert result.status == Status.SUCCESS
|
||||
assert result.current_ontology.is_null()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_ontology_uses_update_when_snapshot_exists(monkeypatch) -> None:
|
||||
calls = {"fresh": 0, "update": 0}
|
||||
|
||||
async def fake_fresh(state: UnitOntologyState, tools) -> UnitOntologyState:
|
||||
calls["fresh"] += 1
|
||||
return state
|
||||
|
||||
async def fake_update(state: UnitOntologyState, tools) -> UnitOntologyState:
|
||||
calls["update"] += 1
|
||||
return state
|
||||
|
||||
monkeypatch.setattr(render_ontology_module, "render_ontology_fresh", fake_fresh)
|
||||
monkeypatch.setattr(render_ontology_module, "render_ontology_update", fake_update)
|
||||
|
||||
state = UnitOntologyState(
|
||||
content_unit=_build_content_unit(),
|
||||
ontology_snapshot=_build_ontology(),
|
||||
)
|
||||
# Simulate accidental null current ontology while a valid snapshot exists.
|
||||
state.current_ontology = Ontology(iri=ONTOLOGY_NULL_IRI)
|
||||
result = await render_ontology_module.render_ontology(
|
||||
state, tools=cast(AtomicToolBox, object())
|
||||
)
|
||||
|
||||
assert result is state
|
||||
assert calls["update"] == 1
|
||||
assert calls["fresh"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_ontology_update_adds_external_evidence_when_enabled(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
captured_prompt_kwargs: dict[str, object] = {}
|
||||
|
||||
async def fake_call_llm_with_retry(**kwargs):
|
||||
captured_prompt_kwargs.update(kwargs["prompt_kwargs"])
|
||||
return GraphUpdateRenderReport(graph_update=GraphUpdate())
|
||||
|
||||
async def fake_get_llm_tool(_budget_tracker):
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
render_ontology_module, "call_llm_with_retry", fake_call_llm_with_retry
|
||||
)
|
||||
tools = cast(
|
||||
AtomicToolBox,
|
||||
SimpleNamespace(
|
||||
get_llm_tool=fake_get_llm_tool,
|
||||
),
|
||||
)
|
||||
state = UnitOntologyState(
|
||||
content_unit=_build_content_unit(),
|
||||
ontology_snapshot=_build_ontology(),
|
||||
)
|
||||
state.external_evidence_text = (
|
||||
"### EXTERNAL EVIDENCE (WEB SEARCH)\n"
|
||||
"1. Ontology engineering patterns | https://example.org/ontology\n"
|
||||
" Use consistent subclass hierarchies and explicit domains."
|
||||
)
|
||||
|
||||
await render_ontology_module.render_ontology_update(state, tools=tools)
|
||||
|
||||
external_evidence = str(captured_prompt_kwargs.get("external_evidence", ""))
|
||||
assert "EXTERNAL EVIDENCE" in external_evidence
|
||||
assert "https://example.org/ontology" in external_evidence
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_criticise_ontology_skips_external_evidence_when_disabled(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
captured_prompt_kwargs: dict[str, object] = {}
|
||||
|
||||
async def fake_call_llm_with_retry(**kwargs):
|
||||
captured_prompt_kwargs.update(kwargs["prompt_kwargs"])
|
||||
return OntologyCritiqueReport(
|
||||
success=True,
|
||||
score=95,
|
||||
systemic_critique_summary="Looks good.",
|
||||
actionable_ontology_fixes=[],
|
||||
)
|
||||
|
||||
async def fake_get_llm_tool(_budget_tracker):
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
criticise_ontology_module, "call_llm_with_retry", fake_call_llm_with_retry
|
||||
)
|
||||
tools = cast(
|
||||
AtomicToolBox,
|
||||
SimpleNamespace(
|
||||
get_llm_tool=fake_get_llm_tool,
|
||||
),
|
||||
)
|
||||
state = UnitOntologyState(
|
||||
content_unit=_build_content_unit(),
|
||||
ontology_snapshot=_build_ontology(),
|
||||
)
|
||||
|
||||
await criticise_ontology_module.criticise_ontology(state, tools=tools)
|
||||
|
||||
assert captured_prompt_kwargs.get("external_evidence") == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_plan_external_evidence_uses_fallback_when_planner_disabled() -> None:
|
||||
tools = cast(
|
||||
AtomicToolBox,
|
||||
SimpleNamespace(
|
||||
web_grounding_enabled_for_node=lambda _node: True,
|
||||
web_search_reuse_evidence_across_attempt=False,
|
||||
web_search_planner_enabled=False,
|
||||
web_search_planner_min_query_chars=8,
|
||||
web_search_planner_max_queries=3,
|
||||
web_search_planner_min_confidence=0.35,
|
||||
),
|
||||
)
|
||||
state = UnitOntologyState(
|
||||
content_unit=_build_content_unit(),
|
||||
ontology_snapshot=_build_ontology(),
|
||||
ontology_user_instruction="Clarify company ontology terms.",
|
||||
)
|
||||
state.set_external_evidence_request(
|
||||
WorkflowNode.TEXT_TO_ONTOLOGY,
|
||||
ExternalEvidenceRequest(
|
||||
initiate_search=True,
|
||||
rationale="Need targeted terminology lookup for ontology refinement.",
|
||||
),
|
||||
)
|
||||
|
||||
planned = await external_evidence_module.plan_external_evidence_for_node(
|
||||
state, tools, WorkflowNode.TEXT_TO_ONTOLOGY
|
||||
)
|
||||
|
||||
assert planned.external_evidence_plan.should_search is True
|
||||
assert planned.external_evidence_plan.queries
|
||||
assert planned.external_evidence_planned_at_node == WorkflowNode.TEXT_TO_ONTOLOGY
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fetch_external_evidence_filters_domains_and_dedupes() -> None:
|
||||
async def fake_search(query: str, max_results: int | None = None):
|
||||
_ = query, max_results
|
||||
return [
|
||||
SearchHit(
|
||||
title="Good result",
|
||||
url="https://example.org/ontology",
|
||||
snippet="This is a sufficiently detailed snippet for ontology guidance.",
|
||||
),
|
||||
SearchHit(
|
||||
title="Duplicate URL",
|
||||
url="https://example.org/ontology",
|
||||
snippet="Different text but same URL should be deduped.",
|
||||
),
|
||||
SearchHit(
|
||||
title="Other domain",
|
||||
url="https://noise.test/entry",
|
||||
snippet="This snippet is long enough but should be filtered by allowlist.",
|
||||
),
|
||||
]
|
||||
|
||||
tools = cast(
|
||||
AtomicToolBox,
|
||||
SimpleNamespace(
|
||||
web_grounding_enabled_for_node=lambda _node: True,
|
||||
search=fake_search,
|
||||
web_search_allowed_domains={"example.org"},
|
||||
web_search_blocked_domains=set(),
|
||||
web_search_min_snippet_chars=20,
|
||||
web_search_max_snippet_chars=180,
|
||||
web_search_max_total_chars=1200,
|
||||
),
|
||||
)
|
||||
state = UnitOntologyState(
|
||||
content_unit=_build_content_unit(),
|
||||
ontology_snapshot=_build_ontology(),
|
||||
)
|
||||
state.set_external_evidence_request(
|
||||
WorkflowNode.TEXT_TO_ONTOLOGY,
|
||||
ExternalEvidenceRequest(
|
||||
initiate_search=True,
|
||||
rationale="Need clarification",
|
||||
query_hints=["ontology engineering patterns"],
|
||||
confidence=0.9,
|
||||
),
|
||||
)
|
||||
state.set_external_evidence_cache_entry(
|
||||
WorkflowNode.TEXT_TO_ONTOLOGY,
|
||||
ExternalEvidenceCacheEntry(
|
||||
plan=ExternalEvidencePlan(
|
||||
should_search=True,
|
||||
rationale="Need clarification",
|
||||
intent="definition",
|
||||
confidence=0.9,
|
||||
queries=["ontology engineering patterns"],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fetched = await external_evidence_module.fetch_external_evidence_for_node(
|
||||
state, tools, WorkflowNode.TEXT_TO_ONTOLOGY
|
||||
)
|
||||
|
||||
assert fetched.external_evidence_source_count == 1
|
||||
assert fetched.external_evidence_domains == ["example.org"]
|
||||
assert "https://example.org/ontology" in fetched.external_evidence_text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ontology_loop_runs_external_evidence_nodes(monkeypatch) -> None:
|
||||
called_nodes: list[WorkflowNode] = []
|
||||
|
||||
async def fake_plan(state: UnitOntologyState, tools, target_node: WorkflowNode):
|
||||
_ = tools
|
||||
called_nodes.append(target_node)
|
||||
return state
|
||||
|
||||
async def fake_fetch(state: UnitOntologyState, tools, target_node: WorkflowNode):
|
||||
_ = tools, target_node
|
||||
return state
|
||||
|
||||
async def fake_render(state: UnitOntologyState, tools) -> UnitOntologyState:
|
||||
_ = tools
|
||||
state.status = Status.SUCCESS
|
||||
return state
|
||||
|
||||
async def fake_critic(state: UnitOntologyState, tools) -> UnitOntologyState:
|
||||
_ = tools
|
||||
state.status = Status.SUCCESS
|
||||
return state
|
||||
|
||||
monkeypatch.setattr(unit_loops, "plan_external_evidence_for_node", fake_plan)
|
||||
monkeypatch.setattr(unit_loops, "fetch_external_evidence_for_node", fake_fetch)
|
||||
monkeypatch.setattr(unit_loops, "render_ontology", fake_render)
|
||||
monkeypatch.setattr(unit_loops, "criticise_ontology", fake_critic)
|
||||
|
||||
state = UnitOntologyState(
|
||||
content_unit=_build_content_unit(),
|
||||
ontology_snapshot=Ontology(iri=ONTOLOGY_NULL_IRI),
|
||||
)
|
||||
tools = cast(AtomicToolBox, object())
|
||||
result = await unit_loops.ontology_loop(state, tools=tools)
|
||||
|
||||
assert result.status == Status.SUCCESS
|
||||
assert called_nodes == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ontology_loop_plans_search_when_critic_requests_it(monkeypatch) -> None:
|
||||
called_nodes: list[WorkflowNode] = []
|
||||
|
||||
async def fake_plan(state: UnitOntologyState, tools, target_node: WorkflowNode):
|
||||
_ = tools
|
||||
called_nodes.append(target_node)
|
||||
return state
|
||||
|
||||
async def fake_fetch(state: UnitOntologyState, tools, target_node: WorkflowNode):
|
||||
_ = tools
|
||||
called_nodes.append(target_node)
|
||||
return state
|
||||
|
||||
async def fake_render(state: UnitOntologyState, tools) -> UnitOntologyState:
|
||||
_ = tools
|
||||
state.status = Status.SUCCESS
|
||||
return state
|
||||
|
||||
critic_calls = {"count": 0}
|
||||
|
||||
async def fake_critic(state: UnitOntologyState, tools) -> UnitOntologyState:
|
||||
_ = tools
|
||||
critic_calls["count"] += 1
|
||||
if critic_calls["count"] == 1:
|
||||
state.status = Status.FAILED
|
||||
state.set_external_evidence_request(
|
||||
WorkflowNode.CRITICISE_ONTOLOGY,
|
||||
ExternalEvidenceRequest(
|
||||
initiate_search=True,
|
||||
rationale="Need domain standard disambiguation.",
|
||||
query_hints=["ontology modeling standard pattern"],
|
||||
),
|
||||
)
|
||||
return state
|
||||
state.status = Status.SUCCESS
|
||||
return state
|
||||
|
||||
monkeypatch.setattr(unit_loops, "plan_external_evidence_for_node", fake_plan)
|
||||
monkeypatch.setattr(unit_loops, "fetch_external_evidence_for_node", fake_fetch)
|
||||
monkeypatch.setattr(unit_loops, "render_ontology", fake_render)
|
||||
monkeypatch.setattr(unit_loops, "criticise_ontology", fake_critic)
|
||||
|
||||
state = UnitOntologyState(
|
||||
content_unit=_build_content_unit(),
|
||||
ontology_snapshot=Ontology(iri=ONTOLOGY_NULL_IRI),
|
||||
)
|
||||
tools = cast(AtomicToolBox, object())
|
||||
result = await unit_loops.ontology_loop(state, tools=tools)
|
||||
|
||||
assert result.status == Status.SUCCESS
|
||||
assert called_nodes == [
|
||||
WorkflowNode.CRITICISE_ONTOLOGY,
|
||||
WorkflowNode.CRITICISE_ONTOLOGY,
|
||||
]
|
||||
|
||||
|
||||
def test_agent_state_render_mode_properties() -> None:
|
||||
facts_only = AgentState(render_mode=RenderMode.FACTS)
|
||||
assert facts_only.render_mode == RenderMode.FACTS
|
||||
assert facts_only.render_facts is True
|
||||
assert facts_only.render_ontology is False
|
||||
|
||||
ontology_only = AgentState(render_mode=RenderMode.ONTOLOGY)
|
||||
assert ontology_only.render_mode == RenderMode.ONTOLOGY
|
||||
assert ontology_only.render_facts is False
|
||||
assert ontology_only.render_ontology is True
|
||||
|
||||
both = AgentState(render_mode=RenderMode.ONTOLOGY_AND_FACTS)
|
||||
assert both.render_mode == RenderMode.ONTOLOGY_AND_FACTS
|
||||
assert both.render_facts is True
|
||||
assert both.render_ontology is True
|
||||
|
||||
|
||||
def test_route_after_ontology_consolidation_respects_ontology_only_mode() -> None:
|
||||
ontology_only = AgentState(render_mode=RenderMode.ONTOLOGY)
|
||||
assert route_after_ontology_consolidation(ontology_only) == WorkflowNode.SERIALIZE
|
||||
|
||||
ontology_and_facts = AgentState(render_mode=RenderMode.ONTOLOGY_AND_FACTS)
|
||||
assert (
|
||||
route_after_ontology_consolidation(ontology_and_facts)
|
||||
== WorkflowNode.RENDER_FACTS
|
||||
)
|
||||
|
||||
|
||||
def test_toolbox_serialize_skips_facts_in_ontology_only_mode() -> None:
|
||||
class RecordingOntologyManager:
|
||||
def __init__(self) -> None:
|
||||
self.added = 0
|
||||
|
||||
def add_ontology(self, ontology: Ontology) -> None:
|
||||
self.added += 1
|
||||
|
||||
class RecordingStore:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[object, str | None]] = []
|
||||
|
||||
def serialize(self, payload: object, graph_uri: str | None = None) -> None:
|
||||
self.calls.append((payload, graph_uri))
|
||||
|
||||
state = AgentState(render_mode=RenderMode.ONTOLOGY)
|
||||
state.current_ontology = _build_ontology()
|
||||
store = RecordingStore()
|
||||
toolbox = SimpleNamespace(
|
||||
ontology_manager=RecordingOntologyManager(),
|
||||
filesystem_manager=store,
|
||||
triple_store_manager=None,
|
||||
)
|
||||
|
||||
ToolBox.serialize(cast(ToolBox, toolbox), state)
|
||||
|
||||
assert len(store.calls) == 1
|
||||
assert isinstance(store.calls[0][0], Ontology)
|
||||
assert store.calls[0][1] is None
|
||||
|
||||
|
||||
def test_toolbox_serialize_includes_facts_when_render_facts_enabled() -> None:
|
||||
class RecordingOntologyManager:
|
||||
def add_ontology(self, ontology: Ontology) -> None:
|
||||
return None
|
||||
|
||||
class RecordingStore:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[object, str | None]] = []
|
||||
|
||||
def serialize(self, payload: object, graph_uri: str | None = None) -> None:
|
||||
self.calls.append((payload, graph_uri))
|
||||
|
||||
state = AgentState(render_mode=RenderMode.ONTOLOGY_AND_FACTS)
|
||||
state.current_ontology = _build_ontology()
|
||||
store = RecordingStore()
|
||||
toolbox = SimpleNamespace(
|
||||
ontology_manager=RecordingOntologyManager(),
|
||||
filesystem_manager=store,
|
||||
triple_store_manager=None,
|
||||
)
|
||||
|
||||
ToolBox.serialize(cast(ToolBox, toolbox), state)
|
||||
|
||||
assert len(store.calls) == 2
|
||||
assert isinstance(store.calls[0][0], Ontology)
|
||||
assert isinstance(store.calls[1][0], RDFGraph)
|
||||
assert store.calls[1][1] == state.graph_uri
|
||||
|
||||
|
||||
def test_render_updated_graph_splits_compound_sparql_insert_updates() -> None:
|
||||
graph = RDFGraph()
|
||||
graph.parse(
|
||||
data="""
|
||||
@prefix ex: <http://example.org/> .
|
||||
ex:Existing ex:kept ex:Value .
|
||||
""",
|
||||
format="turtle",
|
||||
)
|
||||
update = GraphUpdate(
|
||||
sparql_operations=[
|
||||
GenericSparqlQuery(
|
||||
query=(
|
||||
"PREFIX ex: <http://example.org/>\n"
|
||||
"INSERT DATA { ex:Person ex:label ex:Alice }\n"
|
||||
"INSERT DATA { ex:Person ex:status ex:Active }"
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
updated_graph, was_applied = AgentState.render_updated_graph(graph, [update])
|
||||
|
||||
assert was_applied is True
|
||||
assert (
|
||||
URIRef("http://example.org/Person"),
|
||||
URIRef("http://example.org/label"),
|
||||
URIRef("http://example.org/Alice"),
|
||||
) in updated_graph
|
||||
assert (
|
||||
URIRef("http://example.org/Person"),
|
||||
URIRef("http://example.org/status"),
|
||||
URIRef("http://example.org/Active"),
|
||||
) in updated_graph
|
||||
148
ontology_platform/vendored/ontocast/test/test_rdfgraph_iadd.py
Normal file
148
ontology_platform/vendored/ontocast/test/test_rdfgraph_iadd.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Test for RDFGraph __iadd__ method.
|
||||
|
||||
This test verifies that the __iadd__ method properly reuses __add__ and binds prefixes.
|
||||
"""
|
||||
|
||||
from rdflib import Graph, Literal, Namespace, URIRef
|
||||
|
||||
from ontocast.onto.rdfgraph import RDFGraph
|
||||
|
||||
|
||||
def test_rdfgraph_iadd_reuses_add_and_binds_prefixes():
|
||||
"""Test that __iadd__ reuses __add__ and properly binds prefixes."""
|
||||
|
||||
# Create two RDFGraph instances with different namespaces
|
||||
graph1 = RDFGraph()
|
||||
graph2 = RDFGraph()
|
||||
|
||||
# Define namespacesЙ
|
||||
ns1 = Namespace("http://example.org/ns1/")
|
||||
ns2 = Namespace("http://example.org/ns2/")
|
||||
|
||||
# Add some triples to graph1 with ns1 namespace
|
||||
graph1.add((ns1.subject1, ns1.predicate1, Literal("value1")))
|
||||
graph1.add((ns1.subject2, ns1.predicate2, Literal("value2")))
|
||||
graph1.bind("ns1", ns1)
|
||||
|
||||
# Add some triples to graph2 with ns2 namespace
|
||||
graph2.add((ns2.subject1, ns2.predicate1, Literal("value3")))
|
||||
graph2.add((ns2.subject2, ns2.predicate2, Literal("value4")))
|
||||
graph2.bind("ns2", ns2)
|
||||
|
||||
# Test __iadd__ method
|
||||
graph1 += graph2
|
||||
|
||||
# Verify that all triples are present
|
||||
assert len(graph1) == 4
|
||||
assert (ns1.subject1, ns1.predicate1, Literal("value1")) in graph1
|
||||
assert (ns1.subject2, ns1.predicate2, Literal("value2")) in graph1
|
||||
assert (ns2.subject1, ns2.predicate1, Literal("value3")) in graph1
|
||||
assert (ns2.subject2, ns2.predicate2, Literal("value4")) in graph1
|
||||
|
||||
# Verify that namespace bindings are preserved
|
||||
namespaces = dict(graph1.namespaces())
|
||||
assert "ns1" in namespaces
|
||||
assert "ns2" in namespaces
|
||||
assert str(namespaces["ns1"]) == "http://example.org/ns1/"
|
||||
assert str(namespaces["ns2"]) == "http://example.org/ns2/"
|
||||
|
||||
|
||||
def test_rdfgraph_iadd_with_regular_graph():
|
||||
"""Test that __iadd__ works with regular rdflib.Graph objects."""
|
||||
|
||||
# Create RDFGraph and regular Graph
|
||||
rdf_graph = RDFGraph()
|
||||
regular_graph = Graph()
|
||||
|
||||
# Define namespace
|
||||
ns = Namespace("http://example.org/test/")
|
||||
|
||||
# Add triples to both graphs
|
||||
rdf_graph.add((ns.subject1, ns.predicate1, Literal("value1")))
|
||||
rdf_graph.bind("test", ns)
|
||||
|
||||
regular_graph.add((ns.subject2, ns.predicate2, Literal("value2")))
|
||||
|
||||
# Test __iadd__ method
|
||||
rdf_graph += regular_graph
|
||||
|
||||
# Verify that all triples are present
|
||||
assert len(rdf_graph) == 2
|
||||
assert (ns.subject1, ns.predicate1, Literal("value1")) in rdf_graph
|
||||
assert (ns.subject2, ns.predicate2, Literal("value2")) in rdf_graph
|
||||
|
||||
# Verify that namespace binding is preserved
|
||||
namespaces = dict(rdf_graph.namespaces())
|
||||
assert "test" in namespaces
|
||||
assert str(namespaces["test"]) == "http://example.org/test/"
|
||||
|
||||
|
||||
def test_rdfgraph_iadd_returns_self():
|
||||
"""Test that __iadd__ returns self for chaining."""
|
||||
|
||||
graph1 = RDFGraph()
|
||||
graph2 = RDFGraph()
|
||||
|
||||
# Add some triples
|
||||
graph1.add(
|
||||
(
|
||||
URIRef("http://example.org/subject1"),
|
||||
URIRef("http://example.org/predicate1"),
|
||||
Literal("value1"),
|
||||
)
|
||||
)
|
||||
graph2.add(
|
||||
(
|
||||
URIRef("http://example.org/subject2"),
|
||||
URIRef("http://example.org/predicate2"),
|
||||
Literal("value2"),
|
||||
)
|
||||
)
|
||||
|
||||
# Test that __iadd__ returns self
|
||||
result = graph1.__iadd__(graph2)
|
||||
|
||||
# Verify that result is the same object as graph1
|
||||
assert result is graph1
|
||||
assert len(graph1) == 2
|
||||
|
||||
|
||||
def test_rdfgraph_iadd_equivalent_to_add():
|
||||
"""Test that __iadd__ produces the same result as __add__."""
|
||||
|
||||
# Create two graphs
|
||||
graph1 = RDFGraph()
|
||||
graph2 = RDFGraph()
|
||||
|
||||
# Define namespaces
|
||||
ns1 = Namespace("http://example.org/ns1/")
|
||||
ns2 = Namespace("http://example.org/ns2/")
|
||||
|
||||
# Add triples and bind namespaces
|
||||
graph1.add((ns1.subject1, ns1.predicate1, Literal("value1")))
|
||||
graph1.bind("ns1", ns1)
|
||||
|
||||
graph2.add((ns2.subject1, ns2.predicate1, Literal("value2")))
|
||||
graph2.bind("ns2", ns2)
|
||||
|
||||
# Create copies for comparison
|
||||
graph1_copy = RDFGraph()
|
||||
for triple in graph1:
|
||||
graph1_copy.add(triple)
|
||||
for prefix, uri in graph1.namespaces():
|
||||
graph1_copy.bind(prefix, uri)
|
||||
|
||||
# Test __add__ method
|
||||
result_add = graph1_copy + graph2
|
||||
|
||||
# Test __iadd__ method
|
||||
graph1 += graph2
|
||||
|
||||
# Verify that both methods produce the same result
|
||||
assert len(graph1) == len(result_add)
|
||||
assert set(graph1) == set(result_add)
|
||||
|
||||
# Verify namespace bindings are the same
|
||||
namespaces1 = dict(graph1.namespaces())
|
||||
namespaces_add = dict(result_add.namespaces())
|
||||
assert namespaces1 == namespaces_add
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Test suite for SemanticChunker.
|
||||
|
||||
This test suite ensures that:
|
||||
1. Chunks, when joined, reproduce the original text (length and content)
|
||||
2. If max_size and min_size are provided, all chunks are >= min_size and <= max_size
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from langchain_core.embeddings import Embeddings
|
||||
|
||||
from ontocast.config import ChunkConfig
|
||||
from ontocast.tool.chunk.util import SENTENCE_SPLIT_REGEX, SemanticChunker
|
||||
|
||||
|
||||
class TestSemanticChunker:
|
||||
"""Core tests for SemanticChunker focusing on text reconstruction and size constraints."""
|
||||
|
||||
def test_chunks_reproduce_original_text_when_joined(
|
||||
self, embeddings: Embeddings, sample_text: str
|
||||
):
|
||||
"""Test that chunks, when joined, reproduce the original text."""
|
||||
chunk_config = ChunkConfig(
|
||||
min_size=1, # Very small min_size to allow any chunk size
|
||||
max_size=100000, # Very large max_size to allow any chunk size
|
||||
)
|
||||
chunker = SemanticChunker(
|
||||
embeddings=embeddings,
|
||||
chunk_config=chunk_config,
|
||||
sentence_split_regex=SENTENCE_SPLIT_REGEX,
|
||||
)
|
||||
|
||||
chunks = chunker.split_text(sample_text)
|
||||
joined_text = "".join(chunks)
|
||||
|
||||
# Verify length is approximately the same
|
||||
length_diff = abs(len(joined_text) - len(sample_text))
|
||||
assert length_diff <= len(chunks), (
|
||||
f"Joined text length difference ({length_diff}) is too large. "
|
||||
f"Original: {len(sample_text)}, Joined: {len(joined_text)}"
|
||||
)
|
||||
|
||||
# Verify content is preserved (normalize whitespace for comparison)
|
||||
original_normalized = re.sub(r"\s+", " ", sample_text.strip())
|
||||
joined_normalized = re.sub(r"\s+", " ", joined_text.strip())
|
||||
|
||||
# Check word coverage
|
||||
original_words = set(re.findall(r"\b\w+\b", original_normalized.lower()))
|
||||
joined_words = set(re.findall(r"\b\w+\b", joined_normalized.lower()))
|
||||
missing_words = original_words - joined_words
|
||||
coverage = (
|
||||
1 - (len(missing_words) / len(original_words)) if original_words else 1
|
||||
)
|
||||
|
||||
assert coverage >= 0.95, (
|
||||
f"Word coverage too low: {coverage:.1%}. "
|
||||
f"Missing {len(missing_words)} words: {list(missing_words)[:10]}"
|
||||
)
|
||||
|
||||
def test_chunks_respect_min_and_max_size(
|
||||
self, embeddings: Embeddings, long_text: str
|
||||
):
|
||||
"""Test that chunks respect both min_size and max_size constraints."""
|
||||
min_size = 200
|
||||
max_size = 1000
|
||||
chunk_config = ChunkConfig(
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
)
|
||||
chunker = SemanticChunker(
|
||||
embeddings=embeddings,
|
||||
chunk_config=chunk_config,
|
||||
sentence_split_regex=SENTENCE_SPLIT_REGEX,
|
||||
)
|
||||
|
||||
chunks = chunker.split_text(long_text)
|
||||
|
||||
assert len(chunks) > 0, "Should produce at least one chunk"
|
||||
for i, chunk in enumerate(chunks):
|
||||
# All chunks must respect max_size
|
||||
assert len(chunk) <= max_size, (
|
||||
f"Chunk {i} has length {len(chunk)} which exceeds max_size {max_size}"
|
||||
)
|
||||
# All but last chunk should meet min_size
|
||||
if i < len(chunks) - 1:
|
||||
assert len(chunk) >= min_size, (
|
||||
f"Chunk {i} has length {len(chunk)} which is less than min_size {min_size}"
|
||||
)
|
||||
|
||||
# Verify joined text exactly reproduces original
|
||||
joined_text = "".join(chunks)
|
||||
assert joined_text == long_text, (
|
||||
f"Joined text does not exactly match original text. "
|
||||
f"Length difference: {abs(len(joined_text) - len(long_text))} characters. "
|
||||
f"Original length: {len(long_text)}, Joined length: {len(joined_text)}. "
|
||||
f"First difference at position: {next((i for i, (a, b) in enumerate(zip(long_text, joined_text)) if a != b), min(len(long_text), len(joined_text)))}"
|
||||
)
|
||||
|
||||
def test_chunker_test_json_with_strict_size_constraints(
|
||||
self, embeddings: Embeddings
|
||||
):
|
||||
"""Test with chunker.test.json using strict size constraints (min_size=2000, max_size=4000).
|
||||
|
||||
This test reproduces a bug where:
|
||||
1. Chunks smaller than min_size are produced
|
||||
2. Chunks are almost exactly max_size (suggesting brute force cutting)
|
||||
"""
|
||||
# Load test data
|
||||
json_file = Path(__file__).parent / "data" / "chunker.test.json"
|
||||
if not json_file.exists():
|
||||
pytest.skip(f"Test data file not found: {json_file}")
|
||||
|
||||
data = json.load(open(json_file))
|
||||
text = data.get("text", "")
|
||||
if not text:
|
||||
pytest.skip("No text found in test data")
|
||||
|
||||
min_size = 2000
|
||||
max_size = 4000
|
||||
chunk_config = ChunkConfig(
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
)
|
||||
chunker = SemanticChunker(
|
||||
embeddings=embeddings,
|
||||
chunk_config=chunk_config,
|
||||
sentence_split_regex=SENTENCE_SPLIT_REGEX,
|
||||
)
|
||||
|
||||
chunks = chunker.split_text(text)
|
||||
chunk_sizes = [len(c) for c in chunks]
|
||||
|
||||
# Verify all chunks respect max_size
|
||||
for i, chunk in enumerate(chunks):
|
||||
assert len(chunk) <= max_size, (
|
||||
f"Chunk {i} has length {len(chunk)} which exceeds max_size {max_size}. "
|
||||
f"Chunk sizes: {chunk_sizes}"
|
||||
)
|
||||
|
||||
# Verify chunks meet min_size (except possibly the last one)
|
||||
# All but the last chunk should meet min_size
|
||||
# The last chunk may be smaller if remaining text is less than min_size
|
||||
if len(chunks) > 1:
|
||||
for i in range(len(chunks) - 1):
|
||||
assert len(chunks[i]) >= min_size, (
|
||||
f"Chunk {i} (not last) has length {len(chunks[i])} which is less than "
|
||||
f"min_size {min_size}. Chunk sizes: {chunk_sizes}"
|
||||
)
|
||||
|
||||
# Even the last chunk should be reasonably sized (at least 50% of min_size)
|
||||
# unless the total remaining text is very small
|
||||
if len(chunks) > 0:
|
||||
last_chunk_size = len(chunks[-1])
|
||||
if last_chunk_size < min_size * 0.5 and len(chunks) > 1:
|
||||
# Check if this is really the last chunk or if there's a problem
|
||||
total_remaining = sum(len(c) for c in chunks if len(c) < min_size)
|
||||
if total_remaining >= min_size:
|
||||
pytest.fail(
|
||||
f"Last chunk has length {last_chunk_size} which is too small. "
|
||||
f"Total size of small chunks: {total_remaining} >= {min_size}, "
|
||||
f"so they should have been merged. Chunk sizes: {chunk_sizes}"
|
||||
)
|
||||
|
||||
# Check for brute force cutting - chunks should not all be clustered near max_size
|
||||
chunks_near_max = sum(1 for size in chunk_sizes if size >= max_size * 0.98)
|
||||
ratio_near_max = chunks_near_max / len(chunks) if chunks else 0
|
||||
|
||||
# If more than 60% of chunks are near max_size, it suggests brute force cutting
|
||||
assert ratio_near_max < 0.6, (
|
||||
f"Too many chunks ({chunks_near_max}/{len(chunks)} = {ratio_near_max:.1%}) "
|
||||
f"are near max_size ({max_size * 0.98:.0f}), suggesting brute force cutting. "
|
||||
f"Chunk sizes: {chunk_sizes}"
|
||||
)
|
||||
|
||||
# Verify joined text exactly reproduces original
|
||||
joined_text = "".join(chunks)
|
||||
assert joined_text == text, (
|
||||
f"Joined text does not exactly match original text. "
|
||||
f"Length difference: {abs(len(joined_text) - len(text))} characters. "
|
||||
f"Original length: {len(text)}, Joined length: {len(joined_text)}. "
|
||||
f"First difference at position: {next((i for i, (a, b) in enumerate(zip(text, joined_text)) if a != b), min(len(text), len(joined_text)))}"
|
||||
)
|
||||
Reference in New Issue
Block a user