stage4: graph_outbox на AGE (Cypher, fixed password+syntax)

This commit is contained in:
neo committed 2026-09-14 13:59:10 +05:00
1 parent cae85627fe
commit a93aa24b54
4 files changed
+469 -23

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""
MVP stage 3: compositions — сборка выходов из атомов.
Собирает composition (пост/рассылку) из атомов по рецепту:
- hook (цепляющий заголовок) → stat (доказательство) → cta (призыв к действию)
- порядок атомов задаётся recipe (JSONB)
Запись в compositions + atom_ids.
Запуск:
python3 stage3/compositions.py --channel telegram --project rabota-ufa
python3 stage3/compositions.py --channel newsletter --project rabota-ufa
"""
import argparse
import json
import os
import sys
import time
import uuid
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"),
}
def get_conn():
return psycopg2.connect(**POSTGRES)
def get_project_id(conn, project: str) -> str:
with conn.cursor() as cur:
cur.execute("SELECT id FROM projects WHERE slug=%s", (project,))
row = cur.fetchone()
if row:
return row[0]
cur.execute(
"INSERT INTO projects (slug, name, kind, role, status) VALUES (%s, %s, 'site', 'satellite', 'active') RETURNING id",
(project, project),
)
return cur.fetchone()[0]
def get_atoms(conn, project_id: str) -> list[dict]:
"""Атомы по типам для сборки (hook → stat → cta)."""
with conn.cursor() as cur:
cur.execute("""
SELECT id, type, plain_text, word_count
FROM atoms
WHERE project_id = %s AND plain_text IS NOT NULL
ORDER BY created_at
""", (project_id,))
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def build_composition(atoms: list[dict], channel: str, project: str) -> dict:
"""Сборка: hook → stat → cta (в порядке recipe)."""
# recipe: порядок типов
recipe_order = ["hook", "stat", "comparison", "cta", "claim"]
ordered = []
for t in recipe_order:
for atom in atoms:
if atom["type"] == t:
ordered.append(atom)
# дополняем оставшимися
for atom in atoms:
if atom["type"] not in recipe_order:
ordered.append(atom)
atom_ids = [str(a["id"]) for a in ordered]
# текст композиции
if channel == "telegram":
content = _build_telegram_post(ordered)
else:
content = _build_newsletter(ordered)
return {
"slug": f"{project}_{channel}",
"title": f"{channel} — {project}",
"recipe": {"order": recipe_order, "types": list({a["type"] for a in ordered})},
"atom_ids": atom_ids,
"content": content,
}
def _build_telegram_post(atoms: list[dict]) -> str:
"""Telegram-пост: hook + stat + cta."""
lines = []
for atom in atoms[:8]: # не более 8 атомов в пост
text = atom["plain_text"]
if len(text) > 200:
text = text[:200].rstrip() + "…"
t = atom["type"]
if t == "hook":
lines.append(f"🔥 {text}")
elif t == "stat":
lines.append(f"📊 {text}")
elif t == "cta":
lines.append(f"👉 {text}")
else:
lines.append(text)
return "\n\n".join(lines)
def _build_newsletter(atoms: list[dict]) -> str:
"""Рассылка: hook (заголовок) + body."""
lines = []
for atom in atoms[:10]:
text = atom["plain_text"]
if len(text) > 300:
text = text[:300].rstrip() + "…"
lines.append(text)
return "\n\n".join(lines)
def insert_composition(conn, project_id: str, comp: dict, channel: str) -> str:
"""Вставить composition. Возвращает composition_id."""
composition_id = str(uuid.uuid4())
with conn.cursor() as cur:
cur.execute("""
INSERT INTO compositions
(id, project_id, channel, slug, title, status, recipe, atom_ids, content)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::uuid[], %s)
ON CONFLICT (project_id, channel, slug) DO UPDATE
SET content = EXCLUDED.content, atom_ids = EXCLUDED.atom_ids
""", (
composition_id, project_id, channel, comp["slug"], comp["title"],
"ready", json.dumps(comp["recipe"]), comp["atom_ids"], comp["content"],
))
conn.commit()
return composition_id
def main():
ap = argparse.ArgumentParser(description="MVP stage 3: compositions")
ap.add_argument("--channel", default="telegram", help="канал")
ap.add_argument("--project", default="rabota-ufa", help="project slug")
args = ap.parse_args()
conn = get_conn()
project_id = get_project_id(conn, args.project)
atoms = get_atoms(conn, project_id)
print(f" • {len(atoms)} атомов для {args.channel}")
start = time.time()
comp = build_composition(atoms, args.channel, args.project)
comp_id = insert_composition(conn, project_id, comp, args.channel)
conn.close()
print(f" ✓ composition: {comp['slug']} за {time.time()-start:.1f}с")
print(f" • atom_ids: {len(comp['atom_ids'])}")
print(f"\nMVP stage 3 compositions OK: {comp['slug']} → {comp_id}")
return 0
if __name__ == "__main__":
sys.exit(main())