fix(graphiti): flatten nested attribute dicts before Neo4j write; hide summary from properties panel

Graphiti's attribute extraction LLM sometimes returns values wrapped in
nested dicts ({"value": "CTTI"}) instead of plain strings. Neo4j rejects
these with TypeError: "Property values can only be of primitive types".

Fix: after _AzureGenericClient gets the LLM response, validate it through
the Pydantic response_model and call _flatten_attributes() so every value
reaching Neo4j is a scalar. Also add _flatten_attributes() helper.

UI: exclude 'summary' from the Properties section of the node detail panel
since it already appears in the dedicated Summary section below.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-04-25 20:08:39 +00:00
parent ba9c8d1f42
commit 6bce05dca2
2 changed files with 55 additions and 12 deletions

View File

@ -25,6 +25,28 @@ def _neo4j_val(v: Any) -> Any:
return v
def _flatten_attributes(attrs: dict) -> dict:
"""Flatten node.attributes so every value is a Neo4j-safe primitive.
Graphiti extracts entity attributes via a Pydantic model, but the raw LLM
response sometimes wraps each value in a nested dict (e.g. {"value": "CTTI"}).
Neo4j only accepts primitive types or arrays thereof, so we coerce any
dict/list value to its string representation.
"""
result = {}
for k, v in attrs.items():
if v is None:
continue
if isinstance(v, dict):
# Unwrap {"value": "..."} pattern emitted by some LLMs; fall back to str()
result[k] = v.get("value") or v.get("text") or str(v)
elif isinstance(v, list):
result[k] = ", ".join(str(i) for i in v)
else:
result[k] = v
return result
def _neo4j_props(node_or_rel: Any) -> Dict[str, Any]:
"""Return a JSON-safe dict of a Neo4j node or relationship's properties."""
return {k: _neo4j_val(v) for k, v in dict(node_or_rel).items()}
@ -69,7 +91,19 @@ def _make_azure_generic_client(config, client):
max_completion_tokens=max_tokens,
response_format=response_format,
)
return json.loads(response.choices[0].message.content or '{}')
raw = json.loads(response.choices[0].message.content or '{}')
if response_model is not None:
# Validate through the Pydantic model and dump back to a flat dict
# so that Neo4j never receives nested Maps as property values.
try:
raw = response_model.model_validate(raw).model_dump(
mode='python', exclude_none=True
)
# Coerce any remaining non-primitive values to str
raw = _flatten_attributes(raw)
except Exception:
pass
return raw
except _openai.RateLimitError as e:
raise _RateLimitError from e
@ -176,7 +210,7 @@ class GraphitiBackend(GraphBackend):
client=async_embed_client,
)
cross_encoder = OpenAIRerankerClient(config=llm_config, client=async_small_client)
return Graphiti(
client = Graphiti(
uri=self._uri,
user=self._user,
password=self._password,
@ -184,6 +218,7 @@ class GraphitiBackend(GraphBackend):
embedder=embedder,
cross_encoder=cross_encoder,
)
return client
def create_graph(self, graph_id: str, name: str, description: str = "") -> None:
logger.info(f"Graphiti graph namespace ready: {graph_id}")

View File

@ -77,10 +77,12 @@
<div class="detail-section" v-if="selectedItem.data.attributes && Object.keys(selectedItem.data.attributes).length > 0">
<div class="section-title">Properties:</div>
<div class="properties-list">
<div v-for="(value, key) in selectedItem.data.attributes" :key="key" class="property-item">
<span class="property-key">{{ key }}:</span>
<span class="property-value">{{ value || 'None' }}</span>
</div>
<template v-for="(value, key) in selectedItem.data.attributes" :key="key">
<div class="property-item" v-if="!String(key).endsWith('_embedding') && !Array.isArray(value) && key !== 'summary'">
<span class="property-key">{{ key }}:</span>
<span class="property-value">{{ value || 'None' }}</span>
</div>
</template>
</div>
</div>
@ -288,8 +290,10 @@ const entityTypes = computed(() => {
//
const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#3498db', '#9b59b6', '#27ae60', '#f39c12']
props.graphData.nodes.forEach(node => {
const type = node.labels?.find(l => l !== 'Entity') || 'Entity'
const HIDDEN = new Set(['Episodic', 'Community', 'EpisodicEdge'])
const SKIP = new Set(['Entity', 'Episodic', 'Community'])
props.graphData.nodes.filter(n => !n.labels?.some(l => HIDDEN.has(l))).forEach(node => {
const type = node.labels?.find(l => !SKIP.has(l)) || 'Entity'
if (!typeMap[type]) {
typeMap[type] = { name: type, count: 0, color: colors[Object.keys(typeMap).length % colors.length] }
}
@ -344,19 +348,23 @@ const renderGraph = () => {
svg.selectAll('*').remove()
const nodesData = props.graphData.nodes || []
const HIDDEN_LABELS = new Set(['Episodic', 'Community', 'EpisodicEdge'])
const nodesData = (props.graphData.nodes || []).filter(
n => !n.labels?.some(l => HIDDEN_LABELS.has(l))
)
const edgesData = props.graphData.edges || []
if (nodesData.length === 0) return
// Prep data
const nodeMap = {}
nodesData.forEach(n => nodeMap[n.uuid] = n)
const SKIP_TYPE_LABELS = new Set(['Entity', 'Episodic', 'Community'])
const nodes = nodesData.map(n => ({
id: n.uuid,
name: n.name || 'Unnamed',
type: n.labels?.find(l => l !== 'Entity') || 'Entity',
type: n.labels?.find(l => !SKIP_TYPE_LABELS.has(l)) || 'Entity',
rawData: n
}))