#!/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())