Corin1998 commited on
Commit
edb9c1a
·
verified ·
1 Parent(s): 54aec3b

Update services/llm.py

Browse files
Files changed (1) hide show
  1. services/llm.py +126 -63
services/llm.py CHANGED
@@ -1,8 +1,8 @@
1
- import os
2
  from typing import List
3
  from openai import AsyncOpenAI
4
- from schemas import TrendItem, PostOut
5
- from datetime import datetime, timedelta
6
 
7
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
8
  if not OPENAI_API_KEY:
@@ -11,7 +11,7 @@ if not OPENAI_API_KEY:
11
  client = AsyncOpenAI(api_key=OPENAI_API_KEY)
12
 
13
  async def summarize_trends_llm(items: List[dict], brand: str, language: str = "ja") -> str:
14
- """トレンド要約(競合/話題)"""
15
  sys = "You are a marketing analyst. Summarize social trends succinctly with bullet points and actionable insights."
16
  lang = "日本語" if language == "ja" else "English"
17
  content = f"""Summarize the following social posts for {brand} in {lang}.
@@ -31,6 +31,23 @@ Posts JSON:
31
  )
32
  return resp.choices[0].message.content.strip()
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  async def generate_week_plan_llm(
35
  brand: str,
36
  language: str,
@@ -41,72 +58,118 @@ async def generate_week_plan_llm(
41
  cta: str,
42
  image_style_hint: str,
43
  ) -> List[PostOut]:
44
- """1週間分の投稿案 + 画像ラフ文。Canvaに貼るラフ用テキストを image_prompt として出力"""
45
- sys = "You are a senior social media planner. Produce platform-specific, concise posts ready for scheduling."
 
 
 
 
 
 
 
 
46
  lang = "日本語" if language == "ja" else "English"
 
47
 
48
- if start_date:
49
- start = datetime.fromisoformat(start_date)
50
- else:
51
- # 月曜始まりにそろえる
52
- today = datetime.utcnow()
53
- delta = (today.weekday() + 7 - 0) % 7 # 0=Monday
54
- start = today - timedelta(days=delta)
55
- days = [start + timedelta(days=i) for i in range(7)]
56
-
57
- prompt = f"""Create a 7-day content plan for {brand} in {lang}.
58
- Constraints:
59
  - Tone: {tone}
60
- - CTA: {cta}
61
- - Keywords to weave in: {", ".join(keywords) if keywords else "N/A"}
62
- - Platforms: {", ".join(platforms)}
63
- - Each item: platform, scheduled_at (ISO), text (<= 220 chars for Instagram caption short), image_prompt (concise Canva-ready brief).
64
- - No emojis unless clearly on-brand. Avoid hashtags spam (<=2).
65
- - Ensure variety: value/education, product, UGC, behind-the-scenes, trend-jump.
66
-
67
- Week start ISO (UTC): {days[0].date().isoformat()}
 
 
 
 
 
 
 
 
 
 
68
  """
69
 
 
70
  resp = await client.chat.completions.create(
71
  model="gpt-4o-mini",
 
72
  messages=[{"role":"system","content":sys},{"role":"user","content":prompt}],
73
  temperature=0.5,
74
  )
75
- text = resp.choices[0].message.content.strip()
76
-
77
- # ざっくりパース(LLM出力はJSONを期待できないため行単位で拾う簡易実装)
78
- # 実運用では function calling / JSONモードを推奨
79
- posts: List[PostOut] = []
80
- for line in text.splitlines():
81
- line = line.strip()
82
- if not line or line.startswith("#") or line.lower().startswith("day "):
83
- continue
84
- # 期待フォーマット例: platform=instagram | scheduled_at=2025-09-01T09:00:00Z | text=... | image_prompt=...
85
- parts = [p.strip() for p in line.split("|")]
86
- data = {}
87
- for p in parts:
88
- if "=" in p:
89
- k, v = p.split("=", 1)
90
- data[k.strip()] = v.strip()
91
- if "platform" in data and "text" in data:
92
- posts.append(PostOut(
93
- id=None,
94
- platform=data.get("platform"),
95
- scheduled_at=data.get("scheduled_at"),
96
- text=data.get("text"),
97
- image_prompt=data.get("image_prompt"),
98
- status="draft"
99
- ))
100
- # フォールバックパース0件なら単発で作る
101
- if not posts:
102
- for i, d in enumerate(days):
103
- for plat in platforms:
104
- posts.append(PostOut(
105
- id=None,
106
- platform=plat,
107
- scheduled_at=(d.replace(hour=9, minute=0, second=0, microsecond=0).isoformat() + "Z"),
108
- text=f"{brand}: {lang}向けサンプル投稿案({plat}・Day{i+1})。CTA: {cta}",
109
- image_prompt=f"Canva用ラフ: {brand} / {plat} / {image_style_hint}",
110
- status="draft",
111
- ))
112
- return posts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, json
2
  from typing import List
3
  from openai import AsyncOpenAI
4
+ from schemas import PostOut
5
+ from datetime import datetime, timedelta, timezone
6
 
7
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
8
  if not OPENAI_API_KEY:
 
11
  client = AsyncOpenAI(api_key=OPENAI_API_KEY)
12
 
13
  async def summarize_trends_llm(items: List[dict], brand: str, language: str = "ja") -> str:
14
+ """(既存)トレンド要約"""
15
  sys = "You are a marketing analyst. Summarize social trends succinctly with bullet points and actionable insights."
16
  lang = "日本語" if language == "ja" else "English"
17
  content = f"""Summarize the following social posts for {brand} in {lang}.
 
31
  )
32
  return resp.choices[0].message.content.strip()
33
 
34
+ def _monday_start_utc(start_date_iso: str | None) -> datetime:
35
+ if start_date_iso:
36
+ base = datetime.fromisoformat(start_date_iso)
37
+ if base.tzinfo is None:
38
+ base = base.replace(tzinfo=timezone.utc)
39
+ else:
40
+ base = base.astimezone(timezone.utc)
41
+ else:
42
+ base = datetime.now(timezone.utc)
43
+ # 月曜始まりにそろえる(0=Mon)
44
+ delta = (base.weekday() + 7 - 0) % 7
45
+ monday = (base - timedelta(days=delta)).replace(hour=0, minute=0, second=0, microsecond=0)
46
+ return monday
47
+
48
+ def _iso_utc(dt: datetime) -> str:
49
+ return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
50
+
51
  async def generate_week_plan_llm(
52
  brand: str,
53
  language: str,
 
58
  cta: str,
59
  image_style_hint: str,
60
  ) -> List[PostOut]:
61
+ """
62
+ 1週間分の『そのまま投稿できる』草案を厳密JSONで生成。
63
+ - X: <=280 chars (目安<=270), 0-2 hashtags, 0-2 emojis, 日本語では自然な句読点
64
+ - IG: 本文120-180字 + 改行 + 末尾に関連ハッシュタグ(最大8)。ハッシュタグは本文末尾に集約
65
+ - URL/「http(s)://」等のプレースホルダは禁止。リンク案内は「プロフィールのリンクから」等で表現
66
+ - バラエティ: 教育/価値, 製品/機能, UGC/お客様の声, 裏側, トレンド乗り を混在
67
+ - 出力JSONスキーマ:
68
+ {"posts":[{"platform":"x|instagram","text":"...","image_prompt":"..."} * 7 * len(platforms)]}
69
+ """
70
+ total_needed = 7 * max(1, len(platforms))
71
  lang = "日本語" if language == "ja" else "English"
72
+ kw_text = ", ".join(keywords) if keywords else "N/A"
73
 
74
+ sys = "You are a senior social media planner who writes ready-to-post copy."
75
+ prompt = f"""
76
+ Write ready-to-post social content for brand "{brand}" in {lang}.
77
+ Requirements:
78
+ - Platforms to cover each day: {", ".join(platforms) if platforms else "x, instagram"}
 
 
 
 
 
 
79
  - Tone: {tone}
80
+ - CTA to incorporate naturally (no URLs): {cta}
81
+ - Keywords to weave in lightly: {kw_text}
82
+ - No placeholders like [link], {{URL}}, or "http". Do not include external links.
83
+ - Emojis: 0-2 per post, only if they add clarity. Avoid spam.
84
+ - Hashtags:
85
+ * X: up to 2 short, relevant hashtags. Place at end.
86
+ * Instagram: up to 8 relevant hashtags. Place at end on a new line.
87
+ - Brevity with specificity. Use numbers, proof-points, benefits, and micro-CTAs.
88
+ - Use natural Japanese if language is Japanese.
89
+
90
+ Return STRICT JSON with this exact schema (no extra keys, no comments):
91
+ {{
92
+ "posts": [
93
+ {{"platform":"x","text":"<X post (<=280 chars)>","image_prompt":"<Concise Canva brief>"}},
94
+ {{"platform":"instagram","text":"<IG caption 120-180 chars + newline + hashtags>","image_prompt":"<Concise Canva brief>"}}
95
+ // ... repeat until exactly {total_needed} items total (7 days × number of platforms), in day order
96
+ ]
97
+ }}
98
  """
99
 
100
+ # JSONモードで厳密JSONを要求
101
  resp = await client.chat.completions.create(
102
  model="gpt-4o-mini",
103
+ response_format={"type": "json_object"},
104
  messages=[{"role":"system","content":sys},{"role":"user","content":prompt}],
105
  temperature=0.5,
106
  )
107
+
108
+ # JSONパース
109
+ try:
110
+ data = json.loads(resp.choices[0].message.content)
111
+ posts_json = data.get("posts", [])
112
+ except Exception:
113
+ posts_json = []
114
+
115
+ # フォールバック: JSON不備なら簡易テンプレで埋める
116
+ if not posts_json:
117
+ for day in range(7):
118
+ for plat in (platforms or ["x","instagram"]):
119
+ if plat == "x":
120
+ posts_json.append({
121
+ "platform": "x",
122
+ "text": f"{brand} の新提案。{cta}|詳細はプロフィールから #お知らせ",
123
+ "image_prompt": f"Canva: {brand} / X / {image_style_hint} / 見出し+要点3箇条"
124
+ })
125
+ else:
126
+ posts_json.append({
127
+ "platform": "instagram",
128
+ "text": f"{brand} の最新情報。{cta}\n#お知らせ #{brand.replace(' ', '')}",
129
+ "image_prompt": f"Canva: {brand} / Instagram / {image_style_hint} / 正方形, 余白多め"
130
+ })
131
+
132
+ # 7日分のUTCスケジュール自動割当X=00:15Z, IG=01:00Z
133
+ start_monday_utc = _monday_start_utc(start_date)
134
+ # JST 09:15→UTC 00:15, JST 10:00→UTC 01:00
135
+ TIME_UTC = {"x": (0, 15), "instagram": (1, 0)}
136
+
137
+ # 期待数に整形(多ければ切り、足りなければループで補充)
138
+ need = 7 * (len(platforms) or 2)
139
+ base_platforms = platforms or ["x", "instagram"]
140
+ if len(posts_json) < need:
141
+ # 足りない分は最後の要素を複製
142
+ while len(posts_json) < need:
143
+ posts_json.append(posts_json[-1])
144
+ else:
145
+ posts_json = posts_json[:need]
146
+
147
+ # 並びを「日×プラットフォーム順」に調整(念のため)
148
+ ordered = []
149
+ idx = 0
150
+ for day in range(7):
151
+ for plat in base_platforms:
152
+ item = posts_json[idx] if idx < len(posts_json) else {"platform": plat, "text": "", "image_prompt": ""}
153
+ # プラットフォームが食い違っていたら上書き(文面は流用)
154
+ item["platform"] = plat
155
+ ordered.append(item)
156
+ idx += 1
157
+
158
+ # PostOut に変換+scheduled_at 付与
159
+ out: List[PostOut] = []
160
+ for i, item in enumerate(ordered):
161
+ day_idx = i // len(base_platforms)
162
+ plat = item.get("platform", "x")
163
+ hour_utc, min_utc = TIME_UTC.get(plat, (0, 30))
164
+ dt = (start_monday_utc + timedelta(days=day_idx)).replace(hour=hour_utc, minute=min_utc, second=0, microsecond=0)
165
+ scheduled_iso = _iso_utc(dt)
166
+
167
+ out.append(PostOut(
168
+ id=None,
169
+ platform=plat,
170
+ scheduled_at=scheduled_iso,
171
+ text=item.get("text", "").strip(),
172
+ image_prompt=item.get("image_prompt", "").strip(),
173
+ status="draft",
174
+ ))
175
+ return out