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