224 lines
7.5 KiB
Python
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())
|