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:
parent
ba9c8d1f42
commit
6bce05dca2
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}))
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue