۲۹ خرداد ۲۵۸۵ · 12 دقیقه مطالعه
بلاگم الان خودکار توی اینستاگرام پست میذاره. اینم pipeline ای که پشتشه.
ماه پیش از اون فاصلهای که بین نوشتن یه پست فنی و اینکه کسی واقعاً بخوندش وجود داشت خسته شدم. پست وجود داره. مخاطب جای دیگهست. پر کردن این فاصله به صورت دستی یعنی ریفرمت کردن، ریسایز کردن، بازنویسی برای یه مدیوم دیگه، و هیچکس این کارو پیوسته نمیکنه.
پس اتوماتیکش کردم. نتیجه یه pipelineایه که RSS Feed من رو میخونه، هر پست رو به یه photo carousel اینستاگرامی تبدیل میکنه، و طبق یه schedule روزانه آپلودش میکنه بدون اینکه دست بزنم. چیز جالب خود اتوماسیون نیست؛ اینه که چی یاد گرفتم وقتی با AI Agent ها به عنوان co-developer ساختمش، و اینکه code review هاشون چی واقعاً پیدا کرد.
شکل Pipeline
سیستم یه ورودی میگیره و دو خروجی تولید میکنه. برای یه URL بلاگپست، یه YouTube Short و یه Instagram carousel میسازه.
Short مسیر سادهتری داره: یه LLM یه اسکریپت ۶۰ ثانیهای مینویسه، Google Cloud TTS صداگذاریاش میکنه، Pillow اسلایدهای متن ساده رو رندر میکنه، و MoviePy اونها رو به یه MP4 نُهشانزدهای سر هم میکنه. یه پست تبدیل به یه ویدیوی عمودی میشه.
Carousel خروجی جدیدتر و سختتره. فرمت photo carousel اینستاگرام اساساً با ویدیو فرق داره: چگالی اطلاعات رو نسبت به روایت خطی پاداش میده. هشت کارت swipeای که هر کدوم به تنهایی معنا داره با یه اسکریپت ۶۰ ثانیهای که بلند خونده میشه یکی نیست. این تفاوت یه طراحی متفاوت رو اجباری کرد.
Pipeline روی یه launchd agent اجرا میشه، از SQLite به عنوان دیتابیس checkpoint استفاده میکنه تا هر stage بعد از یه crash ادامه پیدا کنه، و روزانه یه پست پردازش میکنه. هر stage idempotentه. اگه یه TTS call شکست بخوره، اجرای بعدی از همون نقطه ادامه میده بدون اینکه اسکریپت رو دوباره بسازه.
چرا فرمت Carousel مجبور به تجدیدنظر کرد
قدم آشکار برای اتوماتیک کردن اینستاگرام این بود که Short رو به عنوان Reel export کنم. همون محتوا، پلتفرم دیگه.
اما Reel هایی که برای reach بهینه شدن در واقع محتوای سرگرمیان. پستهای فنی که به ۶۰ ثانیه فشرده میشن تبدیل به نویز میشن. خوانندهای که میخواد چیزی بفهمه نمیخواد یه narrator خلاصهای رو روی footage اداری بخونه.
Instagram carousel فرق داره. خواننده با سرعت خودش swipe میکنه. هر کارت میتونه یه دیاگرام، یه جدول مقایسهای، یه یافته مشخص، یا یه آمار واحد که وزن داره رو حمل کنه. فرمت سلسلهمراتب اطلاعاتی رو پاداش میده، نه ریتم.
این طراحی رو به سمت یه سیستم typed card هل داد. Carousel deck یه واژگان مشخص داره:
CARD_TYPES = {
"cover": "عنوان و زیرعنوان پست",
"concept": "یه ایده با زبان ساده توضیح داده شده",
"comparison": "مقایسه رو به رو: A در برابر B",
"flow": "فرآیند با مراحل شمارهگذاریشده",
"bar": "تصویرسازی نمودار افقی از دادههای مقاله",
"stat": "یه عدد که اهمیت داره",
"figure": "تصویری که مستقیم از مقاله scrape شده",
"takeaway": "یه چیزی که باید یادت بمونه",
}
یه LLM call اختصاصی برای deck متن کامل مقاله رو میگیره و یه آرایه JSON ساختاریافته از ۶ تا ۹ کارت خروجی میده. هر کارت یه نوع، یه عنوان، و فیلدهای محتوای مخصوص به نوعش داره. کارت takeaway همیشه آخره. مدل آزادانه خلاصه نمیکنه؛ محتوا رو از مقاله انتخاب میکنه و داخل container های تعریفشده جا میده.
این محدودیت مهمتر از اونیه که به نظر میرسه.
قانون: هیچ دادهای اختراع نمیکنی
سادهترین حالت شکست یه pipeline محتوای LLM-محور، hallucinationه. یه کارت bar که اعداد benchmark ساختگی نشون میده، یه کارت stat با عددی که مقاله هرگز نقل نکرده: اینا اعتماد رو سریعتر از اینکه اصلاً پست نزنی خراب میکنن.
Pipeline دو قانون سخت رو اجرا میکنه. کارتهای bar و stat قبل از رندر در برابر متن مقاله اعتبارسنجی میشن. اگه یه عدد روی کارت ظاهر بشه اما در مقاله منبع نباشه، کارت به جای حذف شدن به نوع concept تنزل رتبه پیدا میکنه تا طول deck ثابت بمونه.
def _validate_deck(deck: list[dict], article_text: str) -> list[dict]:
clean = []
for card in deck:
if card["type"] in ("bar", "stat"):
value = str(card.get("value", ""))
if value and value not in article_text:
card["type"] = "concept" # تنزل رتبه، حذف نه
if card["type"] == "figure":
idx = card.get("figure_index", -1)
if idx < 0 or idx >= len(available_figures):
continue # رفرنسهای تصویر نامعتبر رو کاملاً بنداز
clean.append(card)
return clean
کارتهای figure همون اصل رو دارن ولی سختتر: فقط میتونن تصاویری رو ارجاع بدن که از خود مقاله scrape شدن. Scraper HTML مقاله رو میاره، همه تگهای <img> رو پیدا میکنه، هر چیزی که شبیه UI chrome به نظر میرسه رو فیلتر میکنه (آیکونها، عناصر navigation، تصاویر زیر یه ابعاد حداقل)، بقیه رو دانلود میکنه، و اونها رو به صورت یه لیست شمارهگذاریشده به deck prompt میده. مدل میتونه figure 0، figure 1، figure 2 رو ارجاع بده. نمیتونه یکی اختراع کنه.
این یه تصمیم معماری معنادارِه: LLM یه selector و formatterه، نه یه content generator. مقاله منبع حقیقته.
Stack
OpenRouter همه LLM call ها رو مدیریت میکنه. دلیل عملی انعطاف routingه: وظایف مختلف در pipeline از مدلهای مختلف استفاده میکنن. اسکریپت narration برای YouTube (کوتاه، تاثیرگذار، ۶۰ ثانیه) از openai/gpt-4o-mini برای سرعت و هزینه استفاده میکنه. Carousel deck (reasoning طولانیتر، خروجی JSON ساختاریافته، الزامات صداقت محتوایی) میتونه بدون تغییر در بقیه زیرساخت، یه مدل قویتر رو هدف بگیره. یه endpoint، یه API key، swap مدل به ازای هر task.
response = openrouter_client.chat.completions.create(
model="openai/gpt-4o-mini",
messages=[
{"role": "system", "content": DECK_SYSTEM_PROMPT},
{"role": "user", "content": article_text},
],
response_format={"type": "json_object"},
)
deck = json.loads(response.choices[0].message.content)["cards"]
همین pattern برای هر LLM call در pipeline تکرار میشه. API یکپارچه OpenRouter به این معناست که انتخاب مدل یه موضوع configurationه، نه معماری. وقتی یه مدل جدید عرضه میشه که structured JSON output رو بهتر handle میکنه، یه خط تغییر میکنه.
Google Cloud TTS narration یوتیوب رو صداگذاری میکنه. انتخاب صدا context-awareه: پستهای بلاگ خودم از یه صدای neural مردانه استفاده میکنن؛ پستهای URL های خارجی در queue از صدای زنانه. جزئیات کوچکیه، ولی وقتی هر روز audio تولید میکنی و خروجی در طول ماهها انباشته میشه، اهمیت پیدا میکنه.
voice = texttospeech.VoiceSelectionParams(
language_code="en-US",
name=TTS_VOICE_NAME if is_own_post else TTS_VOICE_NAME_FEMALE,
)
response = tts_client.synthesize_speech(
input=texttospeech.SynthesisInput(text=script),
voice=voice,
audio_config=texttospeech.AudioConfig(
audio_encoding=texttospeech.AudioEncoding.MP3
),
)
Neural TTS به اندازهای خوب شده که یه pipeline محتوای روزانه میتونه بدون اینکه رباتوار به نظر برسه روش حساب کنه. سقف کیفیت برای محتوای فنی narrateشده دیگه صدا نیست؛ اسکریپته.
Pillow هر کارت carousel رو به عنوان یه JPEG 1080x1350 رندر میکنه. هیچ browser، هیچ HTML-to-image، هیچ headless Chrome. رندرینگ مستقیمه: load کردن font، محاسبه layout متن، کشیدن primitive ها، paste کردن تصاویر، نوشتن فایل. این از روشهای مبتنی بر screenshot کدنویسی بیشتری داره، ولی خروجی deterministic بدون وابستگیهای runtime خارجی تولید میکنه و در چند صد میلیثانیه به ازای هر کارت اجرا میشه.
Renderer بر اساس نوع کارت dispatch میکنه:
def render_card(card: dict, out_path: str, figures: list[Path]) -> None:
img = Image.new("RGB", (1080, 1350), color=BACKGROUND)
draw = ImageDraw.Draw(img)
match card["type"]:
case "cover": _render_cover(draw, img, card)
case "concept": _render_concept(draw, img, card)
case "comparison": _render_comparison(draw, img, card)
case "bar": _render_bar(draw, img, card)
case "figure": _render_figure(draw, img, card, figures)
case "takeaway": _render_takeaway(draw, img, card)
case _: _render_concept(draw, img, card)
img.save(out_path, "JPEG", quality=92)
هر renderer یه تابع مستقله. اضافه کردن یه نوع کارت جدید یه تابع و یه entry در CARD_TYPESه. brand primitive ها، font ها، رنگها، و ثابتهای فاصله در یه ماژول زندگی میکنن. card schema رابط پایدار بین لایه LLM و لایه بصریه.
Instagrapi آپلود اینستاگرام رو مدیریت میکنه. Carousel upload یه لیست از مسیرهای تصویر و یه caption میگیره. انتخاب موزیک یه priority chain اجرا میکنه: اول Trending Audio امتحان کن، در صورت شکست به یه track ثابت برگرد، در صورت شکست هر دو بدون موزیک آپلود کن. یه موزیک lookup شکستخورده نباید یه آپلود رو بلاک کنه.
چطور Agent ها واقعاً این رو ساختن
اینجاست که میخوام کندتر برم، چون فرآیند توسعه حداقل به اندازه محصول جالبه.
Feature carousel از طریق یه workflow subagent-driven طراحی و پیادهسازی شد. فرآیند اینجوریه: یه spec بنویس، یه implementation plan به taskهای مجزا تقسیم کن، بعد هر task رو به یه implementer subagent تازه delegate کن که هیچ اطلاعی از task های دیگه نداره، بعد بلافاصله یه reviewer subagent مستقل که فقط پیادهسازی رو میبینه و هیچ contextای از قصد و نیت من نداره.
Reviewer یه مرحله polish نیست. Reviewer adversarialه: کارش پیدا کردن مشکله، نه بهبود style.
هفت task از این حلقه گذشتن. Review ها پنج bug واقعی قبل از اینکه یه test اجرا بشه پیدا کردن:
همپوشانی فاصله subtitle روی Cover. Cover renderer زیرعنوان رو با یه Y offset ثابت از عنوان قرار میداد. عنوانهای طولانی توی zone زیرعنوان میرفتن و متن روی هم میافتاد. Reviewer این رو flag کرد. Fix: اندازهگیری ارتفاع bounding box عنوان و استخراج دینامیک Y position زیرعنوان ازش.
IndexError با Bullet خالی. Flow card renderer روی step bullet ها iterate میکرد و فرض میکرد حداقل یکی وجود داره. Reviewer ذهنی ترسیم کرد چی میشه وقتی deck LLM یه flow card با لیست خالی steps خروجی میده و crash path رو flag کرد. یه guard اضافه شد.
Test توخالی. Test مربوط به figure card renderer بررسی میکرد که تابع بدون exception اجرا میشه ولی هیچ چیزی درباره خروجی assert نمیکرد. Reviewer flag کرد که به دلایل اشتباه سبز بود. Assertion هایی روی اندازه فایل JPEG و وجودش اضافه شدن.
Stage Key Collision. Pipeline هر stage رو در SQLite با یه string key checkpoint میکنه. Carousel stage کلید "instagram" رو از کد قدیمی Reels به ارث برده بود. Reviewer تشخیص داد که اگه هر دو مسیر روی یه پست اجرا بشن، یه checkpoint row مشترک دارن و pipeline یکی رو انجامشده تلقی میکنه. کلید به "carousel" تغییر نام داده شد.
Article Fetch بدون محافظ در stage نهایی. Integration stage برای ساخت carousel، HTML مقاله رو fetch میکرد. اگه این fetch یه exception میانداخت، منتشر میشد و پست رو به عنوان error mark میکرد. یه status error پست رو از retry های آینده قفل میکنه. تا اون موقع در sequence، آپلود YouTube قبلاً موفق شده بود. Reviewer این رو تشخیص داد: یه خطای شبکه گذرا در article fetch پست رو به طور دائمی از queue گم میکرد حتی اگه ویدیو قبلاً روی یوتیوب لند کرده باشه. یه try/except الان carousel stage رو از نتیجه YouTube ایزوله میکنه.
هیچ کدوم از این bug ها رو با اجرای کد روی ورودیهای happy-path نمیشد پیدا کرد. اینا همون چیزاییه که شش هفته بعد از production میبینی، معمولاً سر وقت بدی.
Pattern ارزش تعمیم داشتن: یه reviewer بدون اطلاعات از قصد و نیت تو که تنها کارش پیدا کردن مشکله با هر code review که reviewer قبلاً context نویسنده رو داره فرق میکنه. Context مشترک دقیقاً همون دلیلیه که code review bug هایی که miss میکنه رو miss میکنه.
چیزی که اجرای روزانه یاد میده
خطاهای گذرا بدون یه retry gate صریح دائمیان. Google TTS گاهی ۵۰۴ برمیگردونه. وقتی یه stage row به عنوان error mark میشه، بررسی “آیا این رو قبلاً دیدم؟” در pipeline هر row موجودی رو به عنوان دیدهشده تلقی میکنه و پست رو برای همیشه skip میکنه. یه قطع شبکه گذرا یه post slot رو میسوزونه. Fix یه دستور retry دستیه که error row رو پاک میکنه؛ idempotency حلقه اصلی به این invariant وابستهست و نباید تضعیفش کنی.
دیتابیس checkpoint SQLite انتخاب درستیه برای یه solo pipeline که بدون نظارت اجرا میشه. هر stage خروجیشو مینویسه و خودشو به عنوان کاملشده mark میکنه قبل از اینکه stage بعدی شروع بشه. Pipeline رو میشه در هر لحظهای kill کرد و از همونجا ادامه داد بدون اینکه کار موفقیتآمیز قبلی رو دوباره اجرا کنه. برای یه cron روزانه که کسی نگاهش نمیکنه، این مهمتر از چیزیه که به نظر میرسه.
Routing مدل OpenRouter ارزشش رو با زمان نشون میده. اسکریپت narration و carousel deck میتونن از مدلهای کاملاً متفاوتی استفاده کنن بدون هیچ تغییر ساختاری در codebase. وقتی یه مدل عرضه میشه که long-context structured output رو بهتر handle میکنه، یه config key تغییر میکنه. این انعطاف انگیزه اصلی برای استفاده از OpenRouter نبود؛ فقط بعد از اینکه pipeline به اندازه کافی اجرا شد تا بخوام با مدلهای مختلف برای وظایف مختلف آزمایش کنم آشکار شد.
چی باید ازش برداشت کرد
Pipelineای که توصیف کردم مخصوص setup منه. رویکرد طراحی همون چیزیه که انتقال پیدا میکنه.
LLM ها selector و formatter هستن، نه content generator. متن مقاله منبع حقیقته؛ مدل تصمیم میگیره چی رو highlight کنه و چطور ساختار بده. Validation بررسی میکنه که مدل در مرز مشخصشده توسط منبع موند. این خروجی رو به شکلی قابل اعتماد میکنه که generation خالص نمیتونه، و خطاها رو قابل تشخیص میکنه چون ورودی همیشه قابل ردیابیه.
واژگان typed card تصمیمات محتوایی رو از تصمیمات rendering جدا میکنن. وقتی یه نوع comparison تعریف کردی، میتونی بدون دست زدن به LLM call، برای اینستاگرام، LinkedIn، یا ایمیل متفاوت رندرش کنی. Card schema رابط پایدار بین لایه هوشمندی و لایه بصری میشه، و این جداسازی همون چیزیه که اجازه میده هر طرف رو مستقل تکامل بدی.
الگوی reviewer مستقل در توسعه agentی یه دسته از bug ها رو پیدا میکنه که test suite ها miss میکنن: اثرات تعاملی بین بخشهایی که مستقل پیادهسازی شدن، guard های از دست رفته روی input path های adversarial، تستهایی که pass میشن ولی چیز معناداری assert نمیکنن. Reviewer بدون context از قصد و نیت تو مشکلات متفاوتی نسبت به کسی که قبلاً میفهمه چی داشتی میکردی پیدا میکنه.
برای هر کسی که داره چیز مشابهی طراحی میکنه، معماریای که کار کرده: SQLite برای checkpointing idempotent، OpenRouter برای routing انعطافپذیر مدل به ازای هر task، یه typed output schema که محتوا رو از rendering جدا میکنه، و یه validation layer که دادههایی رو که منبع نداشته forward نمیکنه. سرویسهای خاص قابل تعویضن. اصول طراحی نیستن.
سورس pipeline عمومی نیست، ولی معماری، stack، و فرآیند توسعه توصیفشده اینجا واقعیان و هر روز اجرا میشن.