| import os, json |
| from typing import List |
| from openai import AsyncOpenAI |
| from schemas import PostOut |
| from datetime import datetime, timedelta, timezone |
|
|
| OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") |
| if not OPENAI_API_KEY: |
| raise RuntimeError("OPENAI_API_KEY is required") |
|
|
| client = AsyncOpenAI(api_key=OPENAI_API_KEY) |
|
|
| async def summarize_trends_llm(items: List[dict], brand: str, language: str = "ja") -> str: |
| """(既存)トレンド要約""" |
| sys = "You are a marketing analyst. Summarize social trends succinctly with bullet points and actionable insights." |
| lang = "日本語" if language == "ja" else "English" |
| content = f"""Summarize the following social posts for {brand} in {lang}. |
| Return: |
| - Key themes (3-5 bullets) |
| - Opportunities (2-3 bullets) |
| - Risks (1-2 bullets) |
| - Suggested angles (3 bullets, ultra concise) |
| |
| Posts JSON: |
| {items} |
| """ |
| resp = await client.chat.completions.create( |
| model="gpt-4o-mini", |
| messages=[{"role":"system","content":sys},{"role":"user","content":content}], |
| temperature=0.4, |
| ) |
| return resp.choices[0].message.content.strip() |
|
|
| def _monday_start_utc(start_date_iso: str | None) -> datetime: |
| if start_date_iso: |
| base = datetime.fromisoformat(start_date_iso) |
| if base.tzinfo is None: |
| base = base.replace(tzinfo=timezone.utc) |
| else: |
| base = base.astimezone(timezone.utc) |
| else: |
| base = datetime.now(timezone.utc) |
| |
| delta = (base.weekday() + 7 - 0) % 7 |
| monday = (base - timedelta(days=delta)).replace(hour=0, minute=0, second=0, microsecond=0) |
| return monday |
|
|
| def _iso_utc(dt: datetime) -> str: |
| return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") |
|
|
| async def generate_week_plan_llm( |
| brand: str, |
| language: str, |
| platforms: list[str], |
| keywords: list[str], |
| start_date: str | None, |
| tone: str, |
| cta: str, |
| image_style_hint: str, |
| ) -> List[PostOut]: |
| """ |
| 1週間分の『そのまま投稿できる』草案を厳密JSONで生成。 |
| - X: <=280 chars (目安<=270), 0-2 hashtags, 0-2 emojis, 日本語では自然な句読点 |
| - IG: 本文120-180字 + 改行 + 末尾に関連ハッシュタグ(最大8)。ハッシュタグは本文末尾に集約 |
| - URL/「http(s)://」等のプレースホルダは禁止。リンク案内は「プロフィールのリンクから」等で表現 |
| - バラエティ: 教育/価値, 製品/機能, UGC/お客様の声, 裏側, トレンド乗り を混在 |
| - 出力JSONスキーマ: |
| {"posts":[{"platform":"x|instagram","text":"...","image_prompt":"..."} * 7 * len(platforms)]} |
| """ |
| total_needed = 7 * max(1, len(platforms)) |
| lang = "日本語" if language == "ja" else "English" |
| kw_text = ", ".join(keywords) if keywords else "N/A" |
|
|
| sys = "You are a senior social media planner who writes ready-to-post copy." |
| prompt = f""" |
| Write ready-to-post social content for brand "{brand}" in {lang}. |
| Requirements: |
| - Platforms to cover each day: {", ".join(platforms) if platforms else "x, instagram"} |
| - Tone: {tone} |
| - CTA to incorporate naturally (no URLs): {cta} |
| - Keywords to weave in lightly: {kw_text} |
| - No placeholders like [link], {{URL}}, or "http". Do not include external links. |
| - Emojis: 0-2 per post, only if they add clarity. Avoid spam. |
| - Hashtags: |
| * X: up to 2 short, relevant hashtags. Place at end. |
| * Instagram: up to 8 relevant hashtags. Place at end on a new line. |
| - Brevity with specificity. Use numbers, proof-points, benefits, and micro-CTAs. |
| - Use natural Japanese if language is Japanese. |
| |
| Return STRICT JSON with this exact schema (no extra keys, no comments): |
| {{ |
| "posts": [ |
| {{"platform":"x","text":"<X post (<=280 chars)>","image_prompt":"<Concise Canva brief>"}}, |
| {{"platform":"instagram","text":"<IG caption 120-180 chars + newline + hashtags>","image_prompt":"<Concise Canva brief>"}} |
| // ... repeat until exactly {total_needed} items total (7 days × number of platforms), in day order |
| ] |
| }} |
| """ |
|
|
| |
| resp = await client.chat.completions.create( |
| model="gpt-4o-mini", |
| response_format={"type": "json_object"}, |
| messages=[{"role":"system","content":sys},{"role":"user","content":prompt}], |
| temperature=0.5, |
| ) |
|
|
| |
| try: |
| data = json.loads(resp.choices[0].message.content) |
| posts_json = data.get("posts", []) |
| except Exception: |
| posts_json = [] |
|
|
| |
| if not posts_json: |
| for day in range(7): |
| for plat in (platforms or ["x","instagram"]): |
| if plat == "x": |
| posts_json.append({ |
| "platform": "x", |
| "text": f"{brand} の新提案。{cta}|詳細はプロフィールから #お知らせ", |
| "image_prompt": f"Canva: {brand} / X / {image_style_hint} / 見出し+要点3箇条" |
| }) |
| else: |
| posts_json.append({ |
| "platform": "instagram", |
| "text": f"{brand} の最新情報。{cta}\n#お知らせ #{brand.replace(' ', '')}", |
| "image_prompt": f"Canva: {brand} / Instagram / {image_style_hint} / 正方形, 余白多め" |
| }) |
|
|
| |
| start_monday_utc = _monday_start_utc(start_date) |
| |
| TIME_UTC = {"x": (0, 15), "instagram": (1, 0)} |
|
|
| |
| need = 7 * (len(platforms) or 2) |
| base_platforms = platforms or ["x", "instagram"] |
| if len(posts_json) < need: |
| |
| while len(posts_json) < need: |
| posts_json.append(posts_json[-1]) |
| else: |
| posts_json = posts_json[:need] |
|
|
| |
| ordered = [] |
| idx = 0 |
| for day in range(7): |
| for plat in base_platforms: |
| item = posts_json[idx] if idx < len(posts_json) else {"platform": plat, "text": "", "image_prompt": ""} |
| |
| item["platform"] = plat |
| ordered.append(item) |
| idx += 1 |
|
|
| |
| out: List[PostOut] = [] |
| for i, item in enumerate(ordered): |
| day_idx = i // len(base_platforms) |
| plat = item.get("platform", "x") |
| hour_utc, min_utc = TIME_UTC.get(plat, (0, 30)) |
| dt = (start_monday_utc + timedelta(days=day_idx)).replace(hour=hour_utc, minute=min_utc, second=0, microsecond=0) |
| scheduled_iso = _iso_utc(dt) |
|
|
| out.append(PostOut( |
| id=None, |
| platform=plat, |
| scheduled_at=scheduled_iso, |
| text=item.get("text", "").strip(), |
| image_prompt=item.get("image_prompt", "").strip(), |
| status="draft", |
| )) |
| return out |
|
|