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
|
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]:
|
def _neo4j_props(node_or_rel: Any) -> Dict[str, Any]:
|
||||||
"""Return a JSON-safe dict of a Neo4j node or relationship's properties."""
|
"""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()}
|
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,
|
max_completion_tokens=max_tokens,
|
||||||
response_format=response_format,
|
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:
|
except _openai.RateLimitError as e:
|
||||||
raise _RateLimitError from e
|
raise _RateLimitError from e
|
||||||
|
|
||||||
|
|
@ -176,7 +210,7 @@ class GraphitiBackend(GraphBackend):
|
||||||
client=async_embed_client,
|
client=async_embed_client,
|
||||||
)
|
)
|
||||||
cross_encoder = OpenAIRerankerClient(config=llm_config, client=async_small_client)
|
cross_encoder = OpenAIRerankerClient(config=llm_config, client=async_small_client)
|
||||||
return Graphiti(
|
client = Graphiti(
|
||||||
uri=self._uri,
|
uri=self._uri,
|
||||||
user=self._user,
|
user=self._user,
|
||||||
password=self._password,
|
password=self._password,
|
||||||
|
|
@ -184,6 +218,7 @@ class GraphitiBackend(GraphBackend):
|
||||||
embedder=embedder,
|
embedder=embedder,
|
||||||
cross_encoder=cross_encoder,
|
cross_encoder=cross_encoder,
|
||||||
)
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
def create_graph(self, graph_id: str, name: str, description: str = "") -> None:
|
def create_graph(self, graph_id: str, name: str, description: str = "") -> None:
|
||||||
logger.info(f"Graphiti graph namespace ready: {graph_id}")
|
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="detail-section" v-if="selectedItem.data.attributes && Object.keys(selectedItem.data.attributes).length > 0">
|
||||||
<div class="section-title">Properties:</div>
|
<div class="section-title">Properties:</div>
|
||||||
<div class="properties-list">
|
<div class="properties-list">
|
||||||
<div v-for="(value, key) in selectedItem.data.attributes" :key="key" class="property-item">
|
<template v-for="(value, key) in selectedItem.data.attributes" :key="key">
|
||||||
<span class="property-key">{{ key }}:</span>
|
<div class="property-item" v-if="!String(key).endsWith('_embedding') && !Array.isArray(value) && key !== 'summary'">
|
||||||
<span class="property-value">{{ value || 'None' }}</span>
|
<span class="property-key">{{ key }}:</span>
|
||||||
</div>
|
<span class="property-value">{{ value || 'None' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -288,8 +290,10 @@ const entityTypes = computed(() => {
|
||||||
// 美观的颜色调色板
|
// 美观的颜色调色板
|
||||||
const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#3498db', '#9b59b6', '#27ae60', '#f39c12']
|
const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#3498db', '#9b59b6', '#27ae60', '#f39c12']
|
||||||
|
|
||||||
props.graphData.nodes.forEach(node => {
|
const HIDDEN = new Set(['Episodic', 'Community', 'EpisodicEdge'])
|
||||||
const type = node.labels?.find(l => l !== 'Entity') || 'Entity'
|
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]) {
|
if (!typeMap[type]) {
|
||||||
typeMap[type] = { name: type, count: 0, color: colors[Object.keys(typeMap).length % colors.length] }
|
typeMap[type] = { name: type, count: 0, color: colors[Object.keys(typeMap).length % colors.length] }
|
||||||
}
|
}
|
||||||
|
|
@ -344,19 +348,23 @@ const renderGraph = () => {
|
||||||
|
|
||||||
svg.selectAll('*').remove()
|
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 || []
|
const edgesData = props.graphData.edges || []
|
||||||
|
|
||||||
if (nodesData.length === 0) return
|
if (nodesData.length === 0) return
|
||||||
|
|
||||||
// Prep data
|
// Prep data
|
||||||
const nodeMap = {}
|
const nodeMap = {}
|
||||||
nodesData.forEach(n => nodeMap[n.uuid] = n)
|
nodesData.forEach(n => nodeMap[n.uuid] = n)
|
||||||
|
|
||||||
|
const SKIP_TYPE_LABELS = new Set(['Entity', 'Episodic', 'Community'])
|
||||||
const nodes = nodesData.map(n => ({
|
const nodes = nodesData.map(n => ({
|
||||||
id: n.uuid,
|
id: n.uuid,
|
||||||
name: n.name || 'Unnamed',
|
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
|
rawData: n
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue