Files

224 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""
MVP stage 5: panel — CLI-дашборд смысла (atom-db + AGE).
Сводит в одну сводку: атомы по доменам, типы, версии под каналы,
композиции/публикации (доход), стоимость (runs), граф (AGE).
Запуск:
python3 stage5/panel.py --all
python3 stage5/panel.py --project rabota-ufa
python3 stage5/panel.py --graph # только граф (AGE)
"""
import os
import sys
import argparse
import psycopg2
POSTGRES = {
"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"),
}
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 _query(conn, sql, params=None):
with conn.cursor() as cur:
cur.execute(sql, params)
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
return rows
def _psql_file(db, sql):
import subprocess
import tempfile
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 _age(sql):
return _psql_file(AGE_DB, sql)
def _age_count(sql):
out = _age(sql).strip().split("\n")
vals = [l.strip() for l in out if l.strip() and l.strip() != "SET"
and not l.strip().startswith("(")]
if not vals:
return 0
try:
return int(vals[-1])
except ValueError:
return 0
def show_projects(conn, only=None):
"""Атомы + версии по проектам."""
projects = _query(conn, "SELECT slug, role FROM projects ORDER BY slug")
print("\n=== projects (%d) ===" % len(projects))
for p in projects:
if only and p["slug"] != only:
continue
atoms = _query(conn,
"SELECT type, count(*) FROM atoms WHERE project_id="
"(SELECT id FROM projects WHERE slug=%s) GROUP BY type ORDER BY count(*) DESC",
(p["slug"],))
versions = _query(conn,
"SELECT channel, count(*) FROM atom_versions "
"WHERE atom_id IN (SELECT id FROM atoms WHERE project_id="
"(SELECT id FROM projects WHERE slug=%s)) GROUP BY channel ORDER BY count(*) DESC",
(p["slug"],))
pubs = _query(conn,
"SELECT c.channel, count(*) FROM compositions c "
"JOIN publications pub ON pub.composition_id=c.id "
"WHERE c.project_id=(SELECT id FROM projects WHERE slug=%s) "
"GROUP BY c.channel ORDER BY c.channel", (p["slug"],))
atom_str = ", ".join("%s:%d" % (a["type"], a["count"]) for a in atoms[:5])
version_str = ", ".join("%s:%d" % (v["channel"], v["count"]) for v in versions)
channel_str = ", ".join(p["channel"] for p in pubs)
print("\n - %s [%s]" % (p["slug"], p["role"]))
print(" atoms: %d (%s)" % (sum(a["count"] for a in atoms), atom_str))
print(" versions: %s" % version_str)
print(" channels: %s" % channel_str)
def show_cost(conn, only=None):
"""Стоимость (runs) + доход (publications)."""
runs = _query(conn,
"SELECT stage, count(*) AS n, sum(cost_usd) AS c FROM runs GROUP BY stage ORDER BY stage")
total_runs = sum(r["c"] for r in runs)
print("\n=== cost ===")
print(" runs: %d | spent: $%.4f" % (sum(r["n"] for r in runs), total_runs))
for r in runs:
print(" %s: %d | $%.4f" % (r["stage"], r["n"], r["c"]))
pubs = _query(conn,
"SELECT c.channel, COALESCE((pub.metrics->>'revenue')::numeric,0)::float AS revenue "
"FROM publications pub JOIN compositions c ON pub.composition_id=c.id")
by_channel = {}
for ch in pubs:
by_channel.setdefault(ch["channel"], 0.0)
by_channel[ch["channel"]] += ch["revenue"]
if by_channel:
print(" revenue: $%.2f" % sum(by_channel.values()))
for ch, rev in sorted(by_channel.items()):
print(" %s: $%.2f" % (ch, rev))
def show_graph(conn):
"""AGE-граф: вершины/рёбра/связность.
Связи/типы читаются из Postgres (atom_relations — источник истины),
из AGE — только подсчёт вершин/рёбер (agtype не выводится в psql).
"""
print("\n=== graph (AGE) ===")
vertices = _age_count(
"SET search_path TO ag_catalog;"
"SELECT count(*) FROM cypher('%s', $$ MATCH (v) RETURN v $$) AS (v agtype);" % GRAPH_NAME)
edges = _age_count(
"SET search_path TO ag_catalog;"
"SELECT count(*) FROM cypher('%s', $$ MATCH (a)-[]->(b) RETURN a $$) AS (a agtype);" % GRAPH_NAME)
print(" vertices: %d" % vertices)
print(" edges: %d" % edges)
# Типы связей (Postgres)
rel_types = _query(conn,
"SELECT relation_type, count(*) AS n FROM atom_relations "
"GROUP BY relation_type ORDER BY count(*) DESC")
if rel_types:
print(" edge types: %s" % ", ".join("%s:%d" % (r["relation_type"], r["n"]) for r in rel_types))
# Connected components (Union-Find) — из Postgres
rows = _query(conn,
"SELECT atom_id_a AS a, relation_type AS rel, atom_id_b AS b "
"FROM atom_relations ORDER BY atom_id_a")
pairs = [(r["a"], r["rel"], r["b"]) for r in rows]
parent = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
parent[find(a)] = find(b)
all_nodes = set()
for a, _, b in pairs:
all_nodes.add(a)
all_nodes.add(b)
union(a, b)
comp_map = {}
for n in all_nodes:
root = find(n)
comp_map.setdefault(root, [])
comp_map[root].append(n)
for root, members in sorted(comp_map.items(),
key=lambda kv: len(kv[1]), reverse=True):
print(" component: %d atoms" % len(members))
if len(members) <= 4:
for m in members:
print(" - %s" % m[:8])
print(" total components: %d" % len(comp_map))
def main():
ap = argparse.ArgumentParser(description="MVP stage 5: panel")
ap.add_argument("--project", default=None, help="project slug")
ap.add_argument("--all", dest="all", action="store_true", help="всё")
ap.add_argument("--graph", action="store_true", help="только граф (AGE)")
args = ap.parse_args()
conn = psycopg2.connect(**POSTGRES)
if args.graph:
show_graph(conn)
else:
show_projects(conn, args.project)
show_cost(conn, args.project)
if args.all or not args.project:
show_graph(conn)
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())