Skip to content

Commit a24f0e3

Browse files
authored
Add metadata fields to graph structure (#532)
* Add metadata fields to graph structure for deduplication and traceability Implements #460 - adds metadata at both graph and node levels: Graph-level metadata: - provider: LLM provider used (e.g., openai, ollama) - model: Model name used (e.g., gpt-4o) - temperature: Temperature setting for generation - created_at: ISO 8601 timestamp when graph was created Node-level metadata (auto-generated in metadata dict): - uuid: UUID4 for stable node identification across graph modifications - topic_hash: SHA256 hash of topic string for duplicate detection Backward compatible - old graph JSON files without metadata still load correctly, with uuid/topic_hash auto-generated on load. Signed-off-by: Luke Hinds <lukehinds@gmail.com> * Remove incorrectly included graph * Preserve timestamp for generation time of graphs Signed-off-by: Luke Hinds <lukehinds@gmail.com> --------- Signed-off-by: Luke Hinds <lukehinds@gmail.com>
1 parent 7a45c64 commit a24f0e3

3 files changed

Lines changed: 185 additions & 2 deletions

File tree

deepfabric/config.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,7 @@ def validate_configuration(self):
161161
"""Validate that configuration combinations are consistent."""
162162
if self.reasoning_style is not None and self.type != "cot":
163163
raise ValueError(
164-
f"reasoning_style can only be set when type='cot', "
165-
f"got type='{self.type}'"
164+
f"reasoning_style can only be set when type='cot', got type='{self.type}'"
166165
)
167166

168167
if self.type == "cot" and self.reasoning_style is None:

deepfabric/graph.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import asyncio
2+
import hashlib
23
import json
34
import textwrap
5+
import uuid
46

7+
from datetime import datetime, timezone
58
from typing import TYPE_CHECKING, Any
69

710
from pydantic import BaseModel, ConfigDict, Field
@@ -69,6 +72,15 @@ class GraphConfig(BaseModel):
6972
)
7073

7174

75+
class GraphMetadata(BaseModel):
76+
"""Metadata for the entire graph for provenance tracking."""
77+
78+
provider: str = Field(..., description="LLM provider used (e.g., openai, ollama)")
79+
model: str = Field(..., description="Model name used (e.g., gpt-4o)")
80+
temperature: float = Field(..., description="Temperature setting used for generation")
81+
created_at: str = Field(..., description="ISO 8601 timestamp when graph was created")
82+
83+
7284
class NodeModel(BaseModel):
7385
"""Pydantic model for a node in the graph."""
7486

@@ -84,6 +96,9 @@ class GraphModel(BaseModel):
8496

8597
nodes: dict[int, NodeModel]
8698
root_id: int
99+
metadata: GraphMetadata | None = Field(
100+
default=None, description="Graph-level metadata for provenance tracking"
101+
)
87102

88103

89104
class Node:
@@ -96,6 +111,14 @@ def __init__(self, topic: str, node_id: int, metadata: dict[str, Any] | None = N
96111
self.parents: list[Node] = []
97112
self.metadata: dict[str, Any] = metadata.copy() if metadata is not None else {}
98113

114+
# Auto-generate uuid if not present (stable node identification)
115+
if "uuid" not in self.metadata:
116+
self.metadata["uuid"] = str(uuid.uuid4())
117+
118+
# Auto-generate topic_hash if not present (duplicate detection via SHA256)
119+
if "topic_hash" not in self.metadata:
120+
self.metadata["topic_hash"] = hashlib.sha256(topic.encode("utf-8")).hexdigest()
121+
99122
def to_pydantic(self) -> NodeModel:
100123
"""Converts the runtime Node to its Pydantic model representation."""
101124
return NodeModel(
@@ -140,6 +163,9 @@ def __init__(self, **kwargs):
140163
# Progress reporter for streaming feedback (set by topic_manager)
141164
self.progress_reporter: ProgressReporter | None = None
142165

166+
# Store creation timestamp for provenance tracking
167+
self.created_at: datetime = datetime.now(timezone.utc)
168+
143169
trace(
144170
"graph_created",
145171
{
@@ -181,6 +207,12 @@ def to_pydantic(self) -> GraphModel:
181207
return GraphModel(
182208
nodes={node_id: node.to_pydantic() for node_id, node in self.nodes.items()},
183209
root_id=self.root.id,
210+
metadata=GraphMetadata(
211+
provider=self.provider,
212+
model=self.model_name,
213+
temperature=self.temperature,
214+
created_at=self.created_at.isoformat(),
215+
),
184216
)
185217

186218
def to_json(self) -> str:
@@ -203,6 +235,12 @@ def from_json(cls, json_path: str, params: dict) -> "Graph":
203235
graph = cls(**params)
204236
graph.nodes = {}
205237

238+
# Restore original creation timestamp if present in the loaded graph
239+
if graph_model.metadata and graph_model.metadata.created_at:
240+
# Handle 'Z' suffix for Python < 3.11 compatibility
241+
created_at_str = graph_model.metadata.created_at.replace("Z", "+00:00")
242+
graph.created_at = datetime.fromisoformat(created_at_str)
243+
206244
# Create nodes
207245
for node_model in graph_model.nodes.values():
208246
node = Node(node_model.topic, node_model.id, node_model.metadata)

tests/unit/test_topic_graph.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import asyncio
2+
import hashlib
23
import json
34
import tempfile
5+
import uuid as uuid_module
46

57
from pathlib import Path
68
from unittest.mock import AsyncMock, patch
@@ -10,6 +12,7 @@
1012
from deepfabric.graph import (
1113
Graph,
1214
GraphConfig,
15+
GraphMetadata,
1316
GraphModel,
1417
Node,
1518
NodeModel,
@@ -72,6 +75,51 @@ def test_node_to_pydantic(self):
7275
assert child_model.children == []
7376
assert child_model.parents == [1]
7477

78+
def test_node_uuid_generation(self):
79+
"""Test that nodes automatically get a UUID4 in metadata."""
80+
node = Node("Test topic", 42)
81+
assert "uuid" in node.metadata
82+
# Verify it's a valid UUID4 format
83+
parsed_uuid = uuid_module.UUID(node.metadata["uuid"])
84+
assert parsed_uuid.version == 4 # noqa: PLR2004
85+
86+
def test_node_topic_hash_generation(self):
87+
"""Test that nodes automatically get a SHA256 topic_hash in metadata."""
88+
topic = "Test topic for hashing"
89+
node = Node(topic, 42)
90+
assert "topic_hash" in node.metadata
91+
# Verify it matches the expected SHA256 hash
92+
expected_hash = hashlib.sha256(topic.encode("utf-8")).hexdigest()
93+
assert node.metadata["topic_hash"] == expected_hash
94+
# SHA256 hex digest is 64 characters
95+
assert len(node.metadata["topic_hash"]) == 64 # noqa: PLR2004
96+
97+
def test_node_preserves_existing_metadata(self):
98+
"""Test that existing metadata is not overwritten during node creation."""
99+
existing_uuid = "custom-uuid-value"
100+
existing_hash = "custom-hash-value"
101+
existing_metadata = {
102+
"uuid": existing_uuid,
103+
"topic_hash": existing_hash,
104+
"custom_field": "custom_value",
105+
}
106+
node = Node("Test topic", 42, metadata=existing_metadata)
107+
108+
# Existing values should be preserved
109+
assert node.metadata["uuid"] == existing_uuid
110+
assert node.metadata["topic_hash"] == existing_hash
111+
assert node.metadata["custom_field"] == "custom_value"
112+
113+
def test_node_metadata_in_pydantic(self):
114+
"""Test that uuid and topic_hash are included in Pydantic conversion."""
115+
node = Node("Test topic", 42)
116+
pydantic_model = node.to_pydantic()
117+
118+
assert "uuid" in pydantic_model.metadata
119+
assert "topic_hash" in pydantic_model.metadata
120+
assert pydantic_model.metadata["uuid"] == node.metadata["uuid"]
121+
assert pydantic_model.metadata["topic_hash"] == node.metadata["topic_hash"]
122+
75123

76124
class TestGraphConfig:
77125
"""Tests for GraphConfig model."""
@@ -194,6 +242,49 @@ def test_to_pydantic(self, topic_graph):
194242
assert pydantic_model.nodes[0].topic == "Test root topic"
195243
assert pydantic_model.nodes[1].topic == "Child"
196244

245+
def test_graph_metadata_serialization(self, topic_graph):
246+
"""Test that graph-level metadata is included in Pydantic conversion."""
247+
pydantic_model = topic_graph.to_pydantic()
248+
249+
# Verify metadata is present
250+
assert pydantic_model.metadata is not None
251+
assert isinstance(pydantic_model.metadata, GraphMetadata)
252+
253+
# Verify all required fields are present
254+
assert pydantic_model.metadata.provider == "openai"
255+
assert pydantic_model.metadata.model == "test-model"
256+
assert pydantic_model.metadata.temperature == 0.7 # noqa: PLR2004
257+
assert pydantic_model.metadata.created_at is not None
258+
259+
# Verify created_at is ISO 8601 format (contains 'T' separator)
260+
assert "T" in pydantic_model.metadata.created_at
261+
262+
def test_graph_metadata_in_json(self, topic_graph):
263+
"""Test that graph-level metadata appears in JSON output."""
264+
json_str = topic_graph.to_json()
265+
data = json.loads(json_str)
266+
267+
assert "metadata" in data
268+
assert data["metadata"]["provider"] == "openai"
269+
assert data["metadata"]["model"] == "test-model"
270+
assert data["metadata"]["temperature"] == 0.7 # noqa: PLR2004
271+
assert "created_at" in data["metadata"]
272+
273+
def test_node_metadata_in_json(self, topic_graph):
274+
"""Test that node-level metadata (uuid, topic_hash) appears in JSON output."""
275+
json_str = topic_graph.to_json()
276+
data = json.loads(json_str)
277+
278+
# Check root node metadata
279+
root_node = data["nodes"]["0"]
280+
assert "metadata" in root_node
281+
assert "uuid" in root_node["metadata"]
282+
assert "topic_hash" in root_node["metadata"]
283+
284+
# Verify topic_hash is correct SHA256
285+
expected_hash = hashlib.sha256(b"Test root topic").hexdigest()
286+
assert root_node["metadata"]["topic_hash"] == expected_hash
287+
197288
def test_to_json(self, topic_graph):
198289
"""Test JSON serialization."""
199290
node1 = topic_graph.add_node("Child")
@@ -488,3 +579,58 @@ def test_graph_persistence_roundtrip(self):
488579
assert any(child.topic == "Biology" for child in loaded_chemistry.children)
489580
finally:
490581
Path(temp_path).unlink()
582+
583+
def test_backward_compatibility_no_metadata(self):
584+
"""Test loading old graph JSON files that don't have metadata fields."""
585+
# Simulate an old graph JSON without metadata
586+
old_graph_json = {
587+
"nodes": {
588+
"0": {
589+
"id": 0,
590+
"topic": "Root Topic",
591+
"children": [1],
592+
"parents": [],
593+
"metadata": {}, # Old format: empty metadata
594+
},
595+
"1": {
596+
"id": 1,
597+
"topic": "Child Topic",
598+
"children": [],
599+
"parents": [0],
600+
"metadata": {},
601+
},
602+
},
603+
"root_id": 0,
604+
# No "metadata" field at graph level
605+
}
606+
607+
graph_params = {
608+
"topic_prompt": "Root Topic",
609+
"model_name": "test-model",
610+
"temperature": 0.5,
611+
"degree": 2,
612+
"depth": 2,
613+
}
614+
615+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
616+
json.dump(old_graph_json, f)
617+
temp_path = f.name
618+
619+
try:
620+
# Load should succeed without errors
621+
loaded = Graph.from_json(temp_path, graph_params)
622+
623+
# Verify structure was loaded correctly
624+
assert len(loaded.nodes) == 2 # noqa: PLR2004
625+
assert loaded.root.topic == "Root Topic"
626+
assert len(loaded.root.children) == 1
627+
628+
# Verify nodes get uuid and topic_hash auto-generated on load
629+
assert "uuid" in loaded.root.metadata
630+
assert "topic_hash" in loaded.root.metadata
631+
632+
# Verify topic_hash is correct for the loaded topic
633+
expected_hash = hashlib.sha256(b"Root Topic").hexdigest()
634+
assert loaded.root.metadata["topic_hash"] == expected_hash
635+
finally:
636+
Path(temp_path).unlink()

0 commit comments

Comments
 (0)