From d3f7124290c6db91a6eca5228e8ce368d008a301 Mon Sep 17 00:00:00 2001 From: neo Date: Mon, 14 Sep 2026 16:01:53 +0500 Subject: [PATCH] =?UTF-8?q?stage5:=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=20(CLI-=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4)=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D1=8B,=20cost,=20AGE-?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stage5/README.md | 28 ++++++ stage5/panel.py | 223 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 stage5/README.md create mode 100644 stage5/panel.py diff --git a/stage5/README.md b/stage5/README.md new file mode 100644 index 0000000..f48930a --- /dev/null +++ b/stage5/README.md @@ -0,0 +1,28 @@ +# Этап 5 — панель (дашборд смысла) + +**Статус:** ✅ РЕАЛИЗОВАНО (2026-09-14) +**Цель:** единая сводка по атомам, версиям, каналам, доходу и графу. + +## Запуск + +```bash +python3 stage5/panel.py --all # всё: проекты + cost + граф +python3 stage5/panel.py --project rabota-ufa # только проект +python3 stage5/panel.py --graph # только граф (AGE) +``` + +## Что показывает + +- **projects** — атомы по проектам (типы: claim/instruction/stat/qa), версии под каналы (web_article/telegram), активные каналы (newsletter/podcast/youtube). +- **cost** — стоимость запусков (`runs`, stage=embed) + доход по каналам (`publications.metrics.revenue`). +- **graph** — AGE-граф: вершины/рёбра (подсчёт из AGE), типы связей (duplicate), connected components (Union-Find из Postgres). + +## Дизайн + +- **Минимальная версия** — один Python-скрипт, без FastAPI/React (это отдельный V5). +- **Источник истины** — Postgres (atom_relations для связей/компонентов); AGE — только подсчёт вершин/рёбер (agtype не выводится в psql). +- **Идемпотентно** — только чтение, никаких записей. + +## Ключевой факт + +agtype-колонки AGE **не отображаются** в `psql`-выводе (известная проблема). Поэтому типы связей и connected components читаются из Postgres, а из AGE — только `count(*)` вершин/рёбер. diff --git a/stage5/panel.py b/stage5/panel.py new file mode 100644 index 0000000..1bc740b --- /dev/null +++ b/stage5/panel.py @@ -0,0 +1,223 @@ +#!/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())