MicroFish/backend/scripts/validate_zep_cloud_integrat...

905 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Run a manual, real Zep Cloud validation against a retained test graph.
This script is intentionally excluded from the automated test suite. It requires
``ZEP_API_KEY`` at runtime, never prints the key, and deletes the graph unless
``--keep-graph`` is explicitly supplied. If the activity updater cannot be
confirmed drained after a failure, the graph is retained to avoid a write/delete
race.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable
# Capture the caller-supplied key before importing MiroFish modules. app.config
# deliberately loads the repository .env with override=True, which must not
# silently replace the account selected for this explicit validation process.
_PROCESS_ZEP_API_KEY = os.environ.get("ZEP_API_KEY", "").strip()
from zep_cloud import BatchAddItem
from zep_cloud.types import SearchFilters
from app.services.graph_builder import BatchSubmission, GraphBuilderService
from app.services.zep_entity_reader import ZepEntityReader
from app.services.zep_graph_memory_updater import AgentActivity, ZepGraphMemoryUpdater
from app.utils.zep_paging import fetch_all_edges, fetch_all_nodes
# Keep later code that consults os.environ consistent with the captured value.
# All Cloud clients in this script also receive the key explicitly.
if _PROCESS_ZEP_API_KEY:
os.environ["ZEP_API_KEY"] = _PROCESS_ZEP_API_KEY
@dataclass(frozen=True)
class SourceEpisode:
created_at: str
data: str
phase: str
data_type: str = "text"
BASELINE_EPISODES = [
SourceEpisode(
"2026-01-05T09:00:00Z",
"澜舟科技(企业稳定标识 LZ-TECH是一家风电智能运维公司。"
"公司总部位于星港市,周岚自 2024 年起担任首席执行官CEO",
"baseline",
),
SourceEpisode(
"2026-01-12T10:00:00Z",
"澜舟科技研发了产品智巡平台(产品稳定标识 ZHIXUN-01"
"智巡平台当前处于试点阶段,用于识别风机叶片异常。",
"baseline",
),
SourceEpisode(
"2026-01-20T14:00:00Z",
"澜舟科技与海岳能源(企业稳定标识 HY-ENERGY签署 HY-2026-01 合作协议。"
"双方将在东湾风场联合部署智巡平台,协议有效期至 2026 年 12 月 31 日。",
"baseline",
),
SourceEpisode(
"2026-02-02T09:30:00Z",
"陈屿担任澜舟科技智巡平台的项目负责人,负责东湾风场部署。"
"公司经营仍由首席执行官周岚负责。",
"baseline",
),
SourceEpisode(
"2026-02-20T18:00:00Z",
"东湾风场的智巡平台试点发现了 12 处叶片异常,"
"使海岳能源的非计划停机时间降低了 18%",
"baseline",
),
SourceEpisode(
"2026-03-05T11:00:00Z",
"海岳能源确认澜舟科技是智巡平台的开发方,陈屿是实施项目负责人。"
"海岳能源计划在试点验收后成为首批商业客户。",
"baseline",
),
SourceEpisode(
"2026-03-15T16:00:00Z",
"澜舟科技董事会批准智巡平台在 2026 年 4 月 1 日从试点阶段转为商业发布。"
"周岚以首席执行官身份签署了发布决议。",
"baseline",
),
SourceEpisode(
"2026-04-01T08:00:00Z",
"智巡平台今天正式商业发布,不再处于试点阶段。"
"海岳能源成为智巡平台首个商业客户,陈屿继续负责交付。",
"baseline",
),
SourceEpisode(
"2026-04-18T13:00:00Z",
"澜舟科技在星港市总部公布运营数据:智巡平台已覆盖 60 台风机,"
"海岳能源仍是 HY-2026-01 协议下的合作伙伴和客户。",
"baseline",
),
SourceEpisode(
"2026-04-30T17:00:00Z",
"截至 2026 年 4 月 30 日,周岚仍担任澜舟科技首席执行官,"
"澜舟科技总部仍在星港市;陈屿担任智巡平台项目负责人。",
"baseline",
),
]
TEMPORAL_UPDATES = [
SourceEpisode(
"2026-05-10T09:00:00Z",
"澜舟科技董事会宣布,自 2026 年 5 月 10 日起,周岚不再担任首席执行官。"
"陈屿正式接任澜舟科技首席执行官,周岚转任首席战略顾问。",
"leadership_change",
),
SourceEpisode(
"2026-06-01T09:00:00Z",
"自 2026 年 6 月 1 日起,澜舟科技总部已从星港市迁至海城市。"
"星港市原总部不再是公司总部,现改为澜舟科技研发中心。",
"headquarters_change",
),
SourceEpisode(
"2026-06-15T12:00:00Z",
"澜舟科技与海岳能源已于 2026 年 6 月 15 日提前终止 HY-2026-01 联合部署协议。"
"海岳能源不再是澜舟科技的联合部署合作伙伴,但仍是智巡平台客户。",
"partnership_change",
),
SourceEpisode(
"2026-06-20T18:00:00Z",
json.dumps(
{
"event": "product_metrics_update",
"company_id": "LZ-TECH",
"product_id": "ZHIXUN-01",
"product_name": "智巡平台",
"commercial_status": "commercial",
"covered_turbines": 120,
"active_customer": "海岳能源",
"as_of": "2026-06-20",
},
ensure_ascii=False,
),
"json_update",
"json",
),
]
ONTOLOGY = {
"entity_types": [
{
"name": "Person",
"description": "A named person involved in company governance or delivery.",
"attributes": [{"name": "current_role", "description": "The person's current role."}],
},
{
"name": "Company",
"description": "A company or commercial organization.",
"attributes": [{"name": "stable_id", "description": "A stable company identifier."}],
},
{
"name": "Product",
"description": "A named software or industrial product.",
"attributes": [{"name": "lifecycle_stage", "description": "The product lifecycle stage."}],
},
{
"name": "Place",
"description": "A city, office, wind farm, or other named location.",
"attributes": [{"name": "place_kind", "description": "The kind of place."}],
},
{
"name": "Agreement",
"description": "A named commercial agreement or contract.",
"attributes": [{"name": "agreement_status", "description": "The agreement status."}],
},
],
"edge_types": [
{
"name": "HOLDS_ROLE_AT",
"description": "A person holds a named role at a company.",
"attributes": [{"name": "title", "description": "The person's title."}],
"source_targets": [{"source": "Person", "target": "Company"}],
},
{
"name": "DEVELOPS",
"description": "A company develops a product.",
"attributes": [{"name": "product_status", "description": "The product status."}],
"source_targets": [{"source": "Company", "target": "Product"}],
},
{
"name": "HEADQUARTERED_IN",
"description": "A company has its current headquarters in a place.",
"attributes": [{"name": "site_status", "description": "The site's headquarters status."}],
"source_targets": [{"source": "Company", "target": "Place"}],
},
{
"name": "PARTNERS_WITH",
"description": "A company has a commercial partnership with another company.",
"attributes": [{"name": "agreement_id", "description": "The governing agreement identifier."}],
"source_targets": [{"source": "Company", "target": "Company"}],
},
{
"name": "CUSTOMER_OF",
"description": "A company is a customer of a product's developer.",
"attributes": [{"name": "customer_status", "description": "The customer status."}],
"source_targets": [{"source": "Company", "target": "Company"}],
},
],
}
def _uuid(value: Any) -> str:
return str(getattr(value, "uuid_", None) or getattr(value, "uuid", ""))
def _status_code(error: Exception) -> int | None:
direct = getattr(error, "status_code", None)
response = getattr(error, "response", None)
return direct or getattr(response, "status_code", None)
def _require_process_api_key() -> str:
if not _PROCESS_ZEP_API_KEY:
raise RuntimeError(
"ZEP_API_KEY must be supplied through the process environment"
)
return _PROCESS_ZEP_API_KEY
def _drain_updater_after_failure(
updater: Any,
*,
started: bool,
stop_attempted: bool,
) -> tuple[bool, Exception | None]:
"""Make one safe drain attempt when the main flow did not call stop()."""
if not started:
return True, None
if stop_attempted:
return False, None
try:
updater.stop()
except Exception as error:
return False, error
return True, None
def _cleanup_graph(
client: Any,
graph_id: str,
*,
created: bool,
keep_graph: bool,
updater_started: bool,
updater_drained: bool,
) -> dict[str, Any]:
"""Delete only when no updater can still write to or ingest into the graph."""
if not created:
return {
"graph_deleted": False,
"graph_retained": False,
"reason": "graph_not_created",
}
if keep_graph:
return {
"graph_deleted": False,
"graph_retained": True,
"reason": "user_requested",
}
if updater_started and not updater_drained:
return {
"graph_deleted": False,
"graph_retained": True,
"reason": "updater_not_confirmed_drained",
}
client.graph.delete(graph_id)
return {
"graph_deleted": True,
"graph_retained": False,
"reason": "validation_cleanup",
}
def _wait_for_episode(client: Any, episode_uuid: str, timeout: int) -> Any:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
episode = client.graph.episode.get(uuid_=episode_uuid)
if getattr(episode, "processed", False):
return episode
time.sleep(3)
raise TimeoutError(f"episode {episode_uuid} did not finish within {timeout}s")
def _list_batch_items(client: Any, batch_id: str, page_size: int = 3) -> tuple[list[Any], int]:
items: list[Any] = []
cursor: int | None = None
pages = 0
while True:
response = client.batch.list_items(batch_id=batch_id, limit=page_size, cursor=cursor)
pages += 1
items.extend(response.items or [])
next_cursor = response.next_cursor
if next_cursor is None:
return items, pages
if next_cursor == cursor:
raise RuntimeError("batch item cursor did not advance")
cursor = next_cursor
def _raw_pages(api_call: Any, graph_id: str, page_size: int = 2) -> tuple[list[Any], int]:
items: list[Any] = []
cursor: str | None = None
pages = 0
seen: set[str] = set()
while True:
kwargs: dict[str, Any] = {"limit": page_size}
if cursor:
kwargs["cursor"] = cursor
response = api_call(graph_id, **kwargs)
pages += 1
items.extend(list(response.data or []))
next_cursor = response.headers.get("zep-next-cursor")
if not next_cursor:
return items, pages
if next_cursor == cursor or next_cursor in seen:
raise RuntimeError("artifact cursor did not advance")
seen.add(next_cursor)
cursor = next_cursor
def _edge_view(edge: Any, node_names: dict[str, str]) -> dict[str, Any]:
return {
"uuid": _uuid(edge),
"name": edge.name,
"fact": edge.fact,
"source": node_names.get(edge.source_node_uuid, edge.source_node_uuid),
"target": node_names.get(edge.target_node_uuid, edge.target_node_uuid),
"created_at": edge.created_at,
"valid_at": edge.valid_at,
"invalid_at": edge.invalid_at,
"expired_at": edge.expired_at,
"attributes": edge.attributes or {},
}
def _node_view(node: Any) -> dict[str, Any]:
return {
"uuid": _uuid(node),
"name": node.name,
"labels": node.labels or [],
"summary": node.summary,
"attributes": node.attributes or {},
}
def _search_view(results: Any, node_names: dict[str, str]) -> dict[str, Any]:
return {
"context": (results.context or "")[:3000],
"edges": [_edge_view(edge, node_names) for edge in (results.edges or [])],
"nodes": [_node_view(node) for node in (results.nodes or [])],
"episode_count": len(results.episodes or []),
"observation_count": len(results.observations or []),
"thread_summary_count": len(results.thread_summaries or []),
}
def _episode_to_batch_item(graph_id: str, item: SourceEpisode, index: int) -> BatchAddItem:
return BatchAddItem(
type="graph_episode",
graph_id=graph_id,
data=item.data,
data_type=item.data_type,
created_at=item.created_at,
source_description="MiroFish deep Zep Cloud validation corpus",
metadata={
"source": "mirofish_zep_deep_validation",
"phase": item.phase,
"sequence": index,
},
)
def _add_and_wait(client: Any, graph_id: str, item: SourceEpisode, timeout: int) -> str:
episode = client.graph.add(
graph_id=graph_id,
type=item.data_type,
data=item.data,
created_at=item.created_at,
source_description="MiroFish temporal Zep Cloud validation update",
metadata={"source": "mirofish_zep_deep_validation", "phase": item.phase},
)
episode_uuid = _uuid(episode)
if not episode_uuid:
raise RuntimeError("graph.add returned no episode UUID")
_wait_for_episode(client, episode_uuid, timeout)
return episode_uuid
def _activities() -> Iterable[AgentActivity]:
return [
AgentActivity(
platform="twitter",
agent_id=101,
agent_name="陈屿",
action_type="CREATE_POST",
action_args={"content": "海城市新总部今天启用,智巡平台商业服务正常运行。"},
round_num=1,
timestamp="2026-07-01T09:00:00Z",
),
AgentActivity(
platform="twitter",
agent_id=102,
agent_name="周岚",
action_type="QUOTE_POST",
action_args={
"original_author_name": "陈屿",
"original_content": "海城市新总部今天启用,智巡平台商业服务正常运行。",
"quote_content": "作为首席战略顾问,我支持陈屿和新的管理团队。",
},
round_num=1,
timestamp="2026-07-01T09:05:00Z",
),
AgentActivity(
platform="twitter",
agent_id=201,
agent_name="海岳能源",
action_type="LIKE_POST",
action_args={
"post_author_name": "陈屿",
"post_content": "海城市新总部今天启用,智巡平台商业服务正常运行。",
},
round_num=1,
timestamp="2026-07-01T09:06:00Z",
),
AgentActivity(
platform="twitter",
agent_id=201,
agent_name="海岳能源",
action_type="CREATE_COMMENT",
action_args={
"post_author_name": "陈屿",
"post_content": "海城市新总部今天启用,智巡平台商业服务正常运行。",
"content": "联合部署协议虽已终止,但我们仍是智巡平台客户。",
},
round_num=2,
timestamp="2026-07-01T09:10:00Z",
),
AgentActivity(
platform="twitter",
agent_id=101,
agent_name="陈屿",
action_type="FOLLOW",
action_args={"target_user_name": "海岳能源"},
round_num=2,
timestamp="2026-07-01T09:12:00Z",
),
]
def run(args: argparse.Namespace) -> dict[str, Any]:
api_key = _require_process_api_key()
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
graph_id = args.graph_id or f"mirofish_zep_deep_{stamp}"
builder = GraphBuilderService(api_key=api_key)
client = builder.client
created = False
updater: ZepGraphMemoryUpdater | None = None
updater_started = False
updater_stop_attempted = False
updater_drained = False
result: dict[str, Any] = {
"graph_id": graph_id,
"graph_retained": args.keep_graph,
"sdk_flow": "zep-cloud 3.25 standalone graph + current Batch API",
}
print(f"[zep-deep] graph_id={graph_id}", flush=True)
try:
builder.create_graph("MiroFish Zep Cloud Deep Validation", graph_id=graph_id)
created = True
print("[zep-deep] graph created", flush=True)
# Production safety net: empty LLM attributes must become a valid
# fallback property before reaching Zep Cloud.
ontology_probe = {"entity_types": [{"name": "ProbeEntity", "attributes": []}], "edge_types": []}
try:
builder.set_ontology(graph_id, ontology_probe)
result["empty_attribute_ontology_probe"] = "accepted_after_mirofish_normalization"
except Exception as error:
result["empty_attribute_ontology_probe"] = {
"error_type": type(error).__name__,
"status_code": _status_code(error),
}
raise
builder.set_ontology(graph_id, ONTOLOGY)
print("[zep-deep] ontology set", flush=True)
operation_id = f"deep-validation-{stamp}"
batch = client.batch.create(
metadata={
"mirofish_operation_id": operation_id,
"graph_id": graph_id,
"suite": "zep_deep_validation",
}
)
batch_id = batch.batch_id
if not batch_id:
raise RuntimeError("batch.create returned no batch_id")
result["batch_id"] = batch_id
added_details: list[Any] = []
batch_items = [
_episode_to_batch_item(graph_id, item, index)
for index, item in enumerate(BASELINE_EPISODES)
]
for start in range(0, len(batch_items), 4):
added_details.extend(
client.batch.add(batch_id=batch_id, items=batch_items[start : start + 4])
)
client.batch.process(batch_id=batch_id)
print(f"[zep-deep] batch submitted items={len(batch_items)}", flush=True)
submission = BatchSubmission(
batch_id=batch_id,
operation_id=operation_id,
episode_uuids=[_uuid(item) for item in added_details if _uuid(item)],
item_count=len(batch_items),
)
baseline_episode_uuids = builder._wait_for_batch(submission, timeout=args.timeout)
listed_items, batch_pages = _list_batch_items(client, batch_id, page_size=3)
result["batch"] = {
"status": client.batch.get(batch_id=batch_id).status,
"item_count": len(listed_items),
"item_pages_at_size_3": batch_pages,
"episode_uuids": baseline_episode_uuids,
}
print(f"[zep-deep] batch completed pages={batch_pages}", flush=True)
baseline_nodes = fetch_all_nodes(client, graph_id, page_size=2)
baseline_edges = fetch_all_edges(client, graph_id, page_size=2)
raw_nodes, node_pages = _raw_pages(
client.graph.node.with_raw_response.get_by_graph_id, graph_id, page_size=2
)
raw_edges, edge_pages = _raw_pages(
client.graph.edge.with_raw_response.get_by_graph_id, graph_id, page_size=2
)
if {_uuid(item) for item in baseline_nodes} != {_uuid(item) for item in raw_nodes}:
raise AssertionError("production node pagination did not match raw cursor traversal")
if {_uuid(item) for item in baseline_edges} != {_uuid(item) for item in raw_edges}:
raise AssertionError("production edge pagination did not match raw cursor traversal")
if len(baseline_nodes) <= 2 or len(baseline_edges) <= 2:
raise AssertionError("the corpus did not produce enough artifacts to exercise pagination")
baseline_names = {_uuid(node): node.name for node in baseline_nodes}
baseline_ceo = client.graph.search(
graph_id=graph_id,
query="截至2026年4月底谁担任澜舟科技首席执行官",
scope="edges",
reranker="cross_encoder",
limit=10,
)
result["baseline"] = {
"node_count": len(baseline_nodes),
"edge_count": len(baseline_edges),
"node_pages_at_size_2": node_pages,
"edge_pages_at_size_2": edge_pages,
"invalidated_edge_count": sum(bool(edge.invalid_at) for edge in baseline_edges),
"ceo_search": _search_view(baseline_ceo, baseline_names),
}
print(
f"[zep-deep] baseline nodes={len(baseline_nodes)} edges={len(baseline_edges)}",
flush=True,
)
update_episode_uuids = [
_add_and_wait(client, graph_id, item, args.timeout)
for item in TEMPORAL_UPDATES
]
result["temporal_update_episode_uuids"] = update_episode_uuids
print(f"[zep-deep] temporal updates processed={len(update_episode_uuids)}", flush=True)
updater = ZepGraphMemoryUpdater(
graph_id=graph_id,
api_key=api_key,
simulation_id=f"zep-deep-{stamp}",
)
updater_started = True
updater.start()
for activity in _activities():
updater.add_activity(activity)
updater_stop_attempted = True
updater.stop()
updater_drained = True
updater_stats = updater.get_stats()
if updater_stats["items_sent"] != 5 or updater_stats["pending_episode_count"] != 0:
raise AssertionError(f"unexpected MiroFish updater stats: {updater_stats}")
result["mirofish_updater"] = updater_stats
print("[zep-deep] MiroFish updater processed 5 mock activities", flush=True)
final_nodes = fetch_all_nodes(client, graph_id, page_size=2)
final_edges = fetch_all_edges(client, graph_id, page_size=2)
final_names = {_uuid(node): node.name for node in final_nodes}
invalidated = [edge for edge in final_edges if edge.invalid_at]
expired = [edge for edge in final_edges if edge.expired_at]
edge_search = client.graph.search(
graph_id=graph_id,
query="澜舟科技当前首席执行官、当前总部以及与海岳能源的当前合作关系是什么?",
scope="edges",
reranker="cross_encoder",
limit=20,
)
node_search = client.graph.search(
graph_id=graph_id,
query="澜舟科技管理层人物",
scope="nodes",
reranker="rrf",
limit=10,
search_filters=SearchFilters(node_labels=["Person"]),
)
typed_edge_search = client.graph.search(
graph_id=graph_id,
query="公司管理职位发生了什么变化?",
scope="edges",
reranker="rrf",
limit=10,
search_filters=SearchFilters(edge_types=["HOLDS_ROLE_AT"]),
)
auto_search = client.graph.search(
graph_id=graph_id,
query="总结澜舟科技最新的管理层、总部、产品与海岳能源关系。",
scope="auto",
max_characters=3000,
return_raw_results=True,
)
episode_search = client.graph.search(
graph_id=graph_id,
query="海岳能源为什么仍是客户但不再是联合部署伙伴?",
scope="episodes",
reranker="rrf",
limit=10,
)
selected_node = next(
(
node
for node in final_nodes
if node.name == "澜舟科技" and "Company" in (node.labels or [])
),
max(
final_nodes,
key=lambda node: sum(
edge.source_node_uuid == _uuid(node)
or edge.target_node_uuid == _uuid(node)
for edge in final_edges
),
),
)
sdk_node_edges = client.graph.node.get_edges(node_uuid=_uuid(selected_node))
complete_node_edges = [
edge
for edge in final_edges
if edge.source_node_uuid == _uuid(selected_node)
or edge.target_node_uuid == _uuid(selected_node)
]
entity_context = ZepEntityReader(api_key=api_key).get_entity_with_context(
graph_id,
_uuid(selected_node),
)
recent_episodes = client.graph.episode.get_by_graph_id(graph_id=graph_id, lastn=50)
episode_list = getattr(recent_episodes, "episodes", None) or []
custom_labels = sorted(
{label for node in final_nodes for label in (node.labels or []) if label != "Entity"}
)
custom_edge_names = sorted(
{edge.name for edge in final_edges if edge.name in {item["name"] for item in ONTOLOGY["edge_types"]}}
)
result["final"] = {
"node_count": len(final_nodes),
"edge_count": len(final_edges),
"invalidated_edge_count": len(invalidated),
"expired_edge_count": len(expired),
"custom_labels": custom_labels,
"custom_edge_names": custom_edge_names,
"nodes": [_node_view(node) for node in final_nodes],
"invalidated_facts": [_edge_view(edge, final_names) for edge in invalidated],
"active_facts": [
_edge_view(edge, final_names)
for edge in final_edges
if not edge.invalid_at and not edge.expired_at
],
"selected_node": _node_view(selected_node),
"sdk_node_edge_count": len(sdk_node_edges),
"complete_node_edge_count": len(complete_node_edges),
"entity_reader_context_edge_count": len(entity_context.related_edges),
"recent_episode_count": len(episode_list),
}
result["searches"] = {
"current_state_edges": _search_view(edge_search, final_names),
"person_nodes": _search_view(node_search, final_names),
"typed_role_edges": _search_view(typed_edge_search, final_names),
"auto_context": _search_view(auto_search, final_names),
"partnership_episodes": _search_view(episode_search, final_names),
}
if len(entity_context.related_edges) != len(complete_node_edges):
raise AssertionError(
"MiroFish entity context omitted incoming or outgoing node edges"
)
result["runtime_assertions"] = {
"edge_search_call_completed": edge_search is not None,
"node_search_call_completed": node_search is not None,
"typed_edge_search_call_completed": typed_edge_search is not None,
"auto_search_call_completed": auto_search is not None,
"episode_search_call_completed": episode_search is not None,
"node_detail_has_all_incoming_and_outgoing_edges": True,
"sdk_node_endpoint_omits_incoming_edges": (
len(sdk_node_edges) < len(complete_node_edges)
),
# The following values are observations only. Zep's extraction and
# retrieval quality are not runtime acceptance criteria.
"search_result_counts": {
"edges": len(edge_search.edges or []),
"nodes": len(node_search.nodes or []),
"typed_edges": len(typed_edge_search.edges or []),
"episodes": len(episode_search.episodes or []),
},
"custom_entity_labels_observed": bool(custom_labels),
"custom_edge_names_observed": bool(custom_edge_names),
"temporal_invalidation_observed": bool(invalidated),
}
return result
except Exception as error:
result["failure"] = {
"type": type(error).__name__,
"status_code": _status_code(error),
"message": str(error)[:500],
}
raise
finally:
if updater is not None and updater_started and not updater_stop_attempted:
updater_stop_attempted = True
updater_drained, updater_cleanup_error = _drain_updater_after_failure(
updater,
started=updater_started,
stop_attempted=False,
)
if updater_cleanup_error is not None:
result["updater_cleanup_error"] = {
"type": type(updater_cleanup_error).__name__,
"message": str(updater_cleanup_error)[:500],
}
print(
"[zep-deep] updater drain failed; graph will be retained "
f"type={type(updater_cleanup_error).__name__}",
flush=True,
)
had_primary_failure = "failure" in result
try:
cleanup = _cleanup_graph(
client,
graph_id,
created=created,
keep_graph=args.keep_graph,
updater_started=updater_started,
updater_drained=updater_drained,
)
except Exception as cleanup_error:
result["cleanup"] = {
"graph_deleted": False,
"graph_retained": True,
"reason": "graph_delete_failed",
"error_type": type(cleanup_error).__name__,
"status_code": _status_code(cleanup_error),
}
result["graph_retained"] = True
print(
"[zep-deep] graph cleanup failed; graph retained "
f"graph_id={graph_id} type={type(cleanup_error).__name__}",
flush=True,
)
if not had_primary_failure:
result["failure"] = {
"type": type(cleanup_error).__name__,
"status_code": _status_code(cleanup_error),
"message": str(cleanup_error)[:500],
}
print(json.dumps(result, ensure_ascii=False, indent=2), flush=True)
raise
else:
result["cleanup"] = cleanup
result["graph_retained"] = cleanup["graph_retained"]
if cleanup["graph_deleted"]:
action = "deleted"
elif cleanup["graph_retained"]:
action = "retained"
else:
action = "not-created"
print(
f"[zep-deep] graph {action} graph_id={graph_id} "
f"reason={cleanup['reason']}",
flush=True,
)
if had_primary_failure:
print(json.dumps(result, ensure_ascii=False, indent=2), flush=True)
def _compact_result(result: dict[str, Any]) -> dict[str, Any]:
"""Keep terminal output focused on runtime/API-contract evidence."""
baseline = result.get("baseline", {})
final = result.get("final", {})
return {
"graph_id": result.get("graph_id"),
"graph_retained": result.get("graph_retained"),
"cleanup": result.get("cleanup"),
"sdk_flow": result.get("sdk_flow"),
"empty_attribute_ontology_probe": result.get(
"empty_attribute_ontology_probe"
),
"batch_id": result.get("batch_id"),
"batch": result.get("batch"),
"baseline": {
key: baseline.get(key)
for key in (
"node_count",
"edge_count",
"node_pages_at_size_2",
"edge_pages_at_size_2",
"invalidated_edge_count",
)
},
"temporal_update_episode_count": len(
result.get("temporal_update_episode_uuids", [])
),
"mirofish_updater": result.get("mirofish_updater"),
"final": {
key: final.get(key)
for key in (
"node_count",
"edge_count",
"invalidated_edge_count",
"expired_edge_count",
"custom_labels",
"custom_edge_names",
"sdk_node_edge_count",
"complete_node_edge_count",
"entity_reader_context_edge_count",
"recent_episode_count",
)
},
"runtime_assertions": result.get("runtime_assertions"),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--graph-id", help="Use an explicit standalone graph ID")
parser.add_argument(
"--keep-graph",
action="store_true",
help="Retain the graph for manual inspection after validation",
)
parser.add_argument(
"--timeout",
type=int,
default=900,
help="Maximum seconds to wait for each ingestion stage",
)
parser.add_argument(
"--full-output",
action="store_true",
help="Print every node, edge, and search result instead of a runtime summary",
)
args = parser.parse_args()
try:
result = run(args)
except Exception as error:
print(
f"[zep-deep] FAILED type={type(error).__name__} status={_status_code(error)}",
file=sys.stderr,
)
return 1
output = result if args.full_output else _compact_result(result)
print(json.dumps(output, ensure_ascii=False, indent=2), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())