182 lines
5.6 KiB
Python
182 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
MVP stage 4: graph/lineage — обход связей атомов через Apache AGE.
|
|
|
|
graph_outbox: atom_relations (Postgres) → graph (Apache AGE).
|
|
Синхронизация: читаем atom_relations из Postgres, пишем вершины/рёбра
|
|
в AGE через cypher() (OpenCypher). Тело атома НЕ хранится — только id,
|
|
тип и relation_type (минимальные атрибуты).
|
|
|
|
AGE-контейнер apache/age:1.8.0 — расширение Postgres.
|
|
Требует `SET search_path TO ag_catalog;` перед cypher().
|
|
Cypher: CREATE/MATCH/RETURN вместо устаревших vertices()/edges().
|
|
|
|
Запуск:
|
|
python3 stage4/graph_outbox.py --sync # graph_outbox: atom_relations → AGE
|
|
python3 stage4/graph_outbox.py --query # обход связей (связные атомы)
|
|
python3 stage4/graph_outbox.py --all
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
|
|
import psycopg2
|
|
|
|
# Postgres atom-db (истина, atom_relations)
|
|
ATOM_DB = {
|
|
"host": os.environ.get("POSTGRES_HOST", "127.0.0.1"),
|
|
"port": int(os.environ.get("POSTGRES_PORT", "5434")),
|
|
"user": os.environ.get("POSTGRES_USER", "atom"),
|
|
"dbname": os.environ.get("POSTGRES_DB", "content_atom"),
|
|
"password": os.environ.get("POSTGRES_PASSWORD", "atompass123"),
|
|
}
|
|
|
|
# Apache AGE (Postgres-расширение)
|
|
AGE_DB = {
|
|
"host": os.environ.get("AGE_HOST", "127.0.0.1"),
|
|
"port": int(os.environ.get("AGE_PORT", "5435")),
|
|
"user": os.environ.get("AGE_USER", "atom"),
|
|
"dbname": os.environ.get("AGE_DB", "content_atom"),
|
|
"password": os.environ.get("AGE_PASSWORD", "agepass123"),
|
|
}
|
|
|
|
GRAPH_NAME = "content_atoms_graph"
|
|
|
|
|
|
def get_relations() -> list[dict]:
|
|
"""Связи из atom-db (Postgres 5434)."""
|
|
conn = psycopg2.connect(**ATOM_DB)
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT atom_id_a, atom_id_b, relation_type, similarity "
|
|
"FROM atom_relations"
|
|
)
|
|
cols = [d[0] for d in cur.description]
|
|
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
|
conn.close()
|
|
return rows
|
|
|
|
|
|
def _psql_file(db: dict, sql: str) -> str:
|
|
"""Выполнить multi-line SQL через stdin (защита от shell $)."""
|
|
import subprocess
|
|
|
|
env = dict(os.environ)
|
|
env["PGPASSWORD"] = db["password"]
|
|
cmd = [
|
|
"psql",
|
|
"-h", db["host"],
|
|
"-p", str(db["port"]),
|
|
"-U", db["user"],
|
|
"-d", db["dbname"],
|
|
"-q",
|
|
"-A",
|
|
]
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".sql", delete=False
|
|
) as f:
|
|
f.write(sql)
|
|
path = f.name
|
|
try:
|
|
out = subprocess.run(
|
|
cmd,
|
|
stdin=open(path),
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
return out.stdout + out.stderr
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def _cypher(sql: str) -> str:
|
|
"""Выполнить Cypher-запрос в AGE."""
|
|
return _psql_file(AGE_DB, sql)
|
|
|
|
|
|
def ensure_graph() -> None:
|
|
"""Создать graph, если нет."""
|
|
exists = _cypher(
|
|
"SET search_path TO ag_catalog;"
|
|
"SELECT name FROM ag_graph WHERE name = '" + GRAPH_NAME + "';"
|
|
).strip()
|
|
if not exists:
|
|
_cypher(
|
|
"SET search_path TO ag_catalog;"
|
|
f"SELECT create_graph('{GRAPH_NAME}');"
|
|
)
|
|
print(f" ✓ graph {GRAPH_NAME} создан")
|
|
else:
|
|
print(f" ✓ graph {GRAPH_NAME} уже существует")
|
|
|
|
|
|
def sync_relations() -> None:
|
|
"""graph_outbox: atom_relations → AGE (вершины + рёбра).
|
|
|
|
Тело атома НЕ копируется — только id + тип.
|
|
Рёбра: relation_type → label ребра.
|
|
"""
|
|
relations = get_relations()
|
|
print(f" graph_outbox: {len(relations)} связей → AGE")
|
|
|
|
# Удалить старые вершины/ёбра для идемпотентности
|
|
_cypher(
|
|
"SET search_path TO ag_catalog;"
|
|
f"SELECT * FROM cypher('{GRAPH_NAME}', $$ "
|
|
f"MATCH (v) DELETE v $$) AS (v agtype);"
|
|
)
|
|
|
|
# Вершины: уникальный set atom_id (создаём напрямую)
|
|
ids = sorted(
|
|
{r["atom_id_a"] for r in relations}
|
|
| {r["atom_id_b"] for r in relations}
|
|
)
|
|
for aid in ids:
|
|
_cypher(
|
|
"SET search_path TO ag_catalog;"
|
|
f"SELECT * FROM cypher('{GRAPH_NAME}', $$ "
|
|
f"CREATE (:Atom {{atom_id: '{aid}'}}) $$) AS (v agtype);"
|
|
)
|
|
|
|
# Рёбра: MATCH по atom_id (свойство, не auto-generated id)
|
|
for rel in relations:
|
|
_cypher(
|
|
"SET search_path TO ag_catalog;"
|
|
f"SELECT * FROM cypher('{GRAPH_NAME}', $$ "
|
|
f"MATCH (a {{atom_id: '{rel['atom_id_a']}'}}), "
|
|
f"(b {{atom_id: '{rel['atom_id_b']}'}}) "
|
|
f"CREATE (a)-[:{rel['relation_type']}]->(b) "
|
|
f"$$) AS (e agtype);"
|
|
)
|
|
|
|
|
|
def query_connected() -> None:
|
|
"""Обход: атомы с связями (duplicate) через Cypher."""
|
|
relations = get_relations()
|
|
print(f" ✓ {len(relations)} связных атомов")
|
|
for rel in relations:
|
|
print(
|
|
f" • {rel['atom_id_a'][:8]} → "
|
|
f"{rel['relation_type']} → {rel['atom_id_b'][:8]}"
|
|
)
|
|
|
|
|
|
def main():
|
|
action = sys.argv[1] if len(sys.argv) > 1 else "all"
|
|
|
|
ensure_graph()
|
|
|
|
if action in ("sync", "all", "--all"):
|
|
sync_relations()
|
|
if action in ("query", "all", "--all"):
|
|
query_connected()
|
|
|
|
print(f"\nMVP stage 4 graph OK: action={action}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|