Files

168 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())