Skip to content

Commit ba044b0

Browse files
Declare identity properties, and require descriptions
EntityType carries identity_properties, which is what deduplication compares when two nodes of the same type are candidates for merging. The DSL had no way to say a property is one, so an ontology declared through it could not use the feature at all. A property annotated Annotated[EntityText, Identity] is now listed in the type's identity_properties, in declaration order. Edge types have no identity properties, since only nodes are deduplicated. A missing description is now an error rather than an empty string. Both a type description and a property description go into the extraction prompt as the account of what belongs to the type, and the ontology write path does not reject an empty one, so an undescribed property degraded extraction silently. This is a break for a declaration that left one out, which is worth taking inside the alpha: the Go and TypeScript DSLs reject the same thing, and the error names the type and property so the fix is mechanical. The name in each message is the ontology type name the caller wrote, which is what the API sees, rather than the Python class name.
1 parent 48d8217 commit ba044b0

2 files changed

Lines changed: 148 additions & 19 deletions

File tree

src/zep_cloud/ontology.py

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,29 @@
55
property's name, type and description as data. This module lets an ontology be
66
declared once, as classes, and derives the payload from them::
77
8-
from zep_cloud.ontology import EdgeModel, EntityModel, EntityText, build_ontology
8+
from pydantic import Field
9+
from typing_extensions import Annotated
10+
11+
from zep_cloud.ontology import (
12+
EdgeModel,
13+
EntityModel,
14+
EntityText,
15+
Identity,
16+
build_ontology,
17+
)
918
from zep_cloud.types import EdgeSourceTarget
1019
1120
class Traveler(EntityModel):
1221
\"\"\"Someone who takes trips.\"\"\"
13-
home_city: EntityText = None
22+
23+
home_city: Annotated[EntityText, Identity] = Field(
24+
default=None, description="The city they live in"
25+
)
1426
1527
class TraveledTo(EdgeModel):
1628
\"\"\"A traveler visiting a destination.\"\"\"
17-
purpose: EntityText = None
29+
30+
purpose: EntityText = Field(default=None, description="Why they went")
1831
1932
entity_types, edge_types = build_ontology(
2033
entities={"Traveler": Traveler},
@@ -48,6 +61,7 @@ class TraveledTo(EdgeModel):
4861
"EntityInt",
4962
"EntityFloat",
5063
"EntityBoolean",
64+
"Identity",
5165
"PropertyType",
5266
"build_ontology",
5367
]
@@ -64,6 +78,16 @@ def __init__(self, wire_type: str) -> None:
6478
self.wire_type = wire_type
6579

6680

81+
class _Identity:
82+
"""Marks a property as one that tells two nodes of the same type apart."""
83+
84+
85+
# Annotate a property with this to list it in the type's identity properties,
86+
# which is what deduplication compares. Annotated flattens, so
87+
# ``Annotated[EntityText, Identity]`` carries both markers.
88+
Identity = _Identity()
89+
90+
6791
# The four property types the API accepts. Declared once: a change to the wire
6892
# spelling is a change here and nowhere else.
6993
EntityText = Annotated[typing.Optional[str], PropertyType("text")]
@@ -86,13 +110,27 @@ class EdgeModel(BaseModel):
86110
]
87111

88112

89-
def _description(model: type) -> str:
90-
"""A type's description is its docstring, which is where a reader looks."""
91-
return (model.__doc__ or "").strip()
113+
def _description(model: type, label: str) -> str:
114+
"""A type's description is its docstring, which is where a reader looks.
115+
116+
An empty description is rejected rather than sent: it goes into the
117+
extraction prompt as the account of what belongs to this type, and the write
118+
path does not reject an empty one.
119+
"""
120+
description = (model.__doc__ or "").strip()
121+
if not description:
122+
raise ValueError(
123+
f"{label} needs a docstring: it is the type's description, which the "
124+
f"extraction model reads to decide what belongs to this type"
125+
)
126+
return description
92127

93128

94-
def _properties(model: typing.Type[BaseModel], label: str) -> typing.List[EntityProperty]:
95-
out: typing.List[EntityProperty] = []
129+
def _properties(
130+
model: typing.Type[BaseModel], label: str
131+
) -> typing.Tuple[typing.List[EntityProperty], typing.List[str]]:
132+
properties: typing.List[EntityProperty] = []
133+
identity_properties: typing.List[str] = []
96134
for name, field in model.model_fields.items():
97135
marker = next(
98136
(m for m in field.metadata if isinstance(m, PropertyType)),
@@ -103,11 +141,18 @@ def _properties(model: typing.Type[BaseModel], label: str) -> typing.List[Entity
103141
f"{label}.{name} is not an ontology property: annotate it with "
104142
f"EntityText, EntityInt, EntityFloat or EntityBoolean"
105143
)
106-
description = field.description or ""
107-
out.append(
144+
description = (field.description or "").strip()
145+
if not description:
146+
raise ValueError(
147+
f"{label}.{name} needs a description: pass "
148+
f'Field(default=None, description="...")'
149+
)
150+
properties.append(
108151
EntityProperty(name=name, type=marker.wire_type, description=description)
109152
)
110-
return out
153+
if any(isinstance(m, _Identity) for m in field.metadata):
154+
identity_properties.append(name)
155+
return properties, identity_properties
111156

112157

113158
def build_ontology(
@@ -123,11 +168,13 @@ def build_ontology(
123168
"""
124169
entity_types: typing.List[EntityType] = []
125170
for name, model in (entities or {}).items():
171+
properties, identity_properties = _properties(model, name)
126172
entity_types.append(
127173
EntityType(
128174
name=name,
129-
description=_description(model),
130-
properties=_properties(model, name),
175+
description=_description(model, name),
176+
properties=properties,
177+
identity_properties=identity_properties or None,
131178
)
132179
)
133180

@@ -137,11 +184,13 @@ def build_ontology(
137184
edge_model, source_targets = spec
138185
else:
139186
edge_model, source_targets = spec, None
187+
# An edge has no identity properties: only nodes are deduplicated.
188+
properties, _ = _properties(edge_model, name)
140189
edge_types.append(
141190
EdgeType(
142191
name=name,
143-
description=_description(edge_model),
144-
properties=_properties(edge_model, name),
192+
description=_description(edge_model, name),
193+
properties=properties,
145194
source_targets=list(source_targets) if source_targets else None,
146195
)
147196
)

tests/ontology/test_build_ontology.py

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
from pydantic import Field
3+
from typing_extensions import Annotated
34

45
from zep_cloud.ontology import (
56
EdgeModel,
@@ -8,6 +9,7 @@
89
EntityInt,
910
EntityModel,
1011
EntityText,
12+
Identity,
1113
build_ontology,
1214
)
1315
from zep_cloud.types import EdgeSourceTarget
@@ -16,10 +18,12 @@
1618
class Traveler(EntityModel):
1719
"""Someone who takes trips."""
1820

19-
home_city: EntityText = None
20-
trips_taken: EntityInt = None
21-
loyalty_points: EntityFloat = None
22-
is_member: EntityBoolean = None
21+
home_city: Annotated[EntityText, Identity] = Field(
22+
default=None, description="The city they live in"
23+
)
24+
trips_taken: EntityInt = Field(default=None, description="How many trips they took")
25+
loyalty_points: EntityFloat = Field(default=None, description="Points earned")
26+
is_member: EntityBoolean = Field(default=None, description="Whether they joined")
2327

2428

2529
class TraveledTo(EdgeModel):
@@ -59,6 +63,52 @@ def test_field_description_is_carried_through():
5963
assert prop.description == "Why they went"
6064

6165

66+
def test_an_identity_annotated_property_is_listed_as_one():
67+
entity_types, _ = build_ontology(entities={"Traveler": Traveler})
68+
assert entity_types[0].identity_properties == ["home_city"]
69+
70+
71+
def test_identity_properties_are_listed_in_declaration_order():
72+
class Place(EntityModel):
73+
"""A place."""
74+
75+
country: Annotated[EntityText, Identity] = Field(
76+
default=None, description="Its country"
77+
)
78+
region: EntityText = Field(default=None, description="Its region")
79+
city: Annotated[EntityText, Identity] = Field(
80+
default=None, description="Its city"
81+
)
82+
83+
entity_types, _ = build_ontology(entities={"Place": Place})
84+
assert entity_types[0].identity_properties == ["country", "city"]
85+
86+
87+
def test_a_type_with_no_identity_properties_omits_them():
88+
class Place(EntityModel):
89+
"""A place."""
90+
91+
country: EntityText = Field(default=None, description="Its country")
92+
93+
entity_types, _ = build_ontology(entities={"Place": Place})
94+
assert entity_types[0].identity_properties is None
95+
96+
97+
def test_an_edge_property_is_never_an_identity_property():
98+
# Only nodes are deduplicated, and EdgeType has no identity_properties to
99+
# carry one, so an Identity annotation on an edge is dropped rather than
100+
# failing to serialize.
101+
class Mentions(EdgeModel):
102+
"""A mention."""
103+
104+
note: Annotated[EntityText, Identity] = Field(
105+
default=None, description="The note"
106+
)
107+
108+
_, edge_types = build_ontology(edges={"MENTIONS": Mentions})
109+
assert not hasattr(edge_types[0], "identity_properties")
110+
111+
62112
def test_edge_source_targets_are_passed_through():
63113
_, edge_types = build_ontology(
64114
edges={
@@ -95,5 +145,35 @@ class Bad(EntityModel):
95145
build_ontology(entities={"Bad": Bad})
96146

97147

148+
def test_a_property_with_no_description_is_rejected_by_name():
149+
# The description goes into the extraction prompt; an empty one is accepted
150+
# by the write path and quietly degrades extraction.
151+
class Bad(EntityModel):
152+
"""Has a property with no description."""
153+
154+
country: EntityText = None
155+
156+
with pytest.raises(ValueError, match="Bad.country needs a description"):
157+
build_ontology(entities={"Bad": Bad})
158+
159+
160+
def test_a_type_with_no_docstring_is_rejected_by_name():
161+
class Bad(EntityModel):
162+
country: EntityText = Field(default=None, description="Its country")
163+
164+
with pytest.raises(ValueError, match="Bad needs a docstring"):
165+
build_ontology(entities={"Bad": Bad})
166+
167+
168+
def test_an_edge_with_no_docstring_is_rejected_by_name():
169+
# The name in the message is the ontology type name, which is what the
170+
# caller wrote and what the API will see, not the Python class name.
171+
class Bad(EdgeModel):
172+
note: EntityText = Field(default=None, description="The note")
173+
174+
with pytest.raises(ValueError, match="BAD needs a docstring"):
175+
build_ontology(edges={"BAD": Bad})
176+
177+
98178
def test_empty_input_builds_empty_lists():
99179
assert build_ontology() == ([], [])

0 commit comments

Comments
 (0)